diff --git a/1-docs/0-mng/plan.md b/1-docs/0-mng/plan.md index 260230e..0319a62 100644 --- a/1-docs/0-mng/plan.md +++ b/1-docs/0-mng/plan.md @@ -45,7 +45,7 @@ JSON-тела (версии программ, факты .fit) — `jsonb` вн | m08-admin-web | Admin-консоль: UI — Flutter Web (`3-app\3-web\admin-web`), бэкенд — admin REST API (`/api/admin/*`) в `Server.WatchBody.Web`: логин/PBKDF2→Bearer, атлеты, тренировки+CSV, программы, метрики, план/факт | 3 | ✅ (2026-10-19: Bearer-контур + 6 контроллеров + Flutter UI (SDK 3.47.7, build → wwwroot/admin), тесты Web 105/105 + Core 56/56, HTTP-смок пройден) | | m09-phone-core | `WorkoutCore` + `LocalDb` (Room) + `SyncQueue`: тренировка, дневник, допуск, перерывы | 4 | ✅ (2026-10-19: Room 9 таблиц + WorkoutCore + SyncWorker/ApiClient + DataStore; APK 4,2 МБ, unit 18/18; смок sync 5 сущностей против живого сервера пройден, errata client_guid-UUID + guid-колонки daily_log/profile) | | m10-phone-ui | `TrainingUi` (Compose): экраны тренировки/истории/программ/профиля/дневника/допуска | 4 | ✅ (2026-10-19: 8 экранов+VM, Compose Material 3 тёмная, unit 36/36, APK 12 МБ, живой смок контура программы; errata контрактов camelCase/flat program_version/sex m-f; запуск на устройстве — приёмка владельца) | -| m11-voice-input | `VoiceInput`: Vosk-STT, грамматика, TTS, журнал команд | 5 | ⬜ | +| m11-voice-input | `VoiceInput`: Vosk-STT, грамматика, TTS, журнал команд | 5 | ✅ (2026-10-19: парсер Ф-1.2 + VoskEngine с грамматикой/fallback + ModelStore (filesDir, не в APK) + executor через WorkoutCore 🛡 + TTS/сигналы + Mic-кнопка/RECORD_AUDIO + блок настроек; unit 63/63, APK 57 МБ; живой URL модели 200 OK; распознавание на устройстве — приёмка владельца, КУ-1 вручную) | | m12-watch-channel | `WatchChannel`: companion-канал Connect IQ (прототип ОВ-3 — итерация 1, чек-лист 7.1) | 5 | ⬜ | | m13-fenix-data-field | `FenixDataField` (Monkey C, таргет `fenix6x`): пульс/зоны/отдых | 5 | ⬜ | diff --git a/1-docs/4-modules/m11-voice-input/SKILL.md b/1-docs/4-modules/m11-voice-input/SKILL.md new file mode 100644 index 0000000..4d17e61 --- /dev/null +++ b/1-docs/4-modules/m11-voice-input/SKILL.md @@ -0,0 +1,18 @@ +--- +name: m11-voice-input +description: VoiceInput (Vosk офлайн-STT, грамматика Ф-1.2, TTS) — голосовой учёт подходов на экране тренировки Phone.WatchBody +--- + +# m11-voice-input + +Голосовой офлайн-учёт подходов `Phone.WatchBody`: Vosk (малая +ru-модель, `filesDir`), грамматика команд «Подход W на R» / +«Вес W» / «Отдых S» / «РПЭ N» (постобработка чисел словами и +цифрами, «с половиной» → +0,5 кг), применение через `WorkoutCore` +(🛡 единственный писатель), журнал команд (Ф-1.5) в sync, +TTS-подтверждения + щелчок/сигнал, кнопка микрофона на экране +«Тренировка» (m10), блок «Распознавание речи» в «Настройках». + +- Требования: [module_reqs.md](module_reqs.md) +- План работ: [readme.md](readme.md) +- Спецификации: [api_specs/](api_specs/) diff --git a/1-docs/4-modules/m11-voice-input/api_specs/01-voice-command-parser.md b/1-docs/4-modules/m11-voice-input/api_specs/01-voice-command-parser.md new file mode 100644 index 0000000..6a66e80 --- /dev/null +++ b/1-docs/4-modules/m11-voice-input/api_specs/01-voice-command-parser.md @@ -0,0 +1,108 @@ +# Спецификация 01: VoiceCommandParser — грамматика Ф-1.2 + +Источники: Ф-1.2 (грамматика), Ф-1.3 (отклонение), О-2-Б («с +половиной» → +0,5 кг), tech-req 5 (постобработка чисел). Единица 1. +Чистая функция JVM — без Android-зависимостей. + +## 1. Интерфейс + +```kotlin +sealed interface VoiceCommand { + data class Approach(val weightKg: Double, val reps: Int) : VoiceCommand + data class Weight(val weightKg: Double) : VoiceCommand + data class Rest(val seconds: Int) : VoiceCommand + data class Rpe(val rpe: Int) : VoiceCommand + data class Rejected(val reason: String, val attempted: JsonObject?) : VoiceCommand +} + +object VoiceCommandParser { + fun parse(alternatives: List): VoiceCommand? + // null — ни одна гипотеза не содержит распознаваемой команды + fun semantics(cmd: VoiceCommand): JsonObject // для журнала Ф-1.5 +} +``` + +`parse` получает список финальных гипотез Vosk (спец 02, до 3; +режим одного варианта — список из одного) и возвращает первую +успешно разобранную команду; если все гипотезы мусор — `Rejected` +из первой гипотезы. Отклонённая команда — тоже результат (журнал +Ф-1.3/1.5), «не было команды вообще» (тишина/чужая речь) — `null`, +в журнал не пишется. + +## 2. Предобработка текста + +- нижний регистр, ё→е, удаление знаков препинания, сжатие пробелов; +- нормализация аббревиатуры RPE: «эр пэ э», «эрпэ» → «рпэ» + (свободное распознавание слышит буквы по отдельности); +- токены — слова и цифровые группы. + +## 3. Числа (`NumberRu`, 0–999) + +- **словами**: [сотни] [десятки] [единицы] → сумма. Словари: + `ноль`; `один/одна/одно`, `два/две`, `три…девятнадцать`; + десятки `двадцать…девяносто`; сотни `сто, двести, триста, + четыреста, пятьсот…девятьсот`. Составное число — подряд стоящие + разряды («сто двадцать три» → 123); постобработка «сто двадцать» + → 120 (tech-req 5); +- **цифрами**: `120`, дробное `120,5` / `120.5` (= только 5 после + разделителя — шаг 0,5); +- **«с половиной»** после числа → +0,5 (О-2-Б); допустимо ТОЛЬКО + в позиции веса `W` (команды Approach/Weight); «с половиной» + в позиции `R`/`S`/`N` — грамматическая ошибка (отклонение); +- порядок разрядов нарушен («двадцать сто») — грамматическая + ошибка. + +## 4. Грамматика команд (Ф-1.2) + +| Команда | Шаблон | Диапазоны | +|---|---|---| +| Approach | `подход` `W` [`с половиной`] `на` `R` | W 0–500 (шаг 0,5), R 1–100 | +| Weight | `вес` `W` [`с половиной`] | W 0–500 (шаг 0,5) | +| Rest | `отдых` `S` [`секунд` / `секунды`] | S 5–600 | +| Rpe | `рпэ` `N` | N 1–10 | + +- ключевые слова обязательны и однозначны; `на`, `секунд` — + по шаблону (секунды склоняются: «девяносто секунд» / + «тридцать секунд» — Vosk может выдать и «секунды» — принимать + `секунд|секунды`); +- лишние слова до/после шаблона — грамматическая ошибка + (узкая грамматика, Ф-1.3); +- число разобрано, но вне диапазона → `Rejected(reason= + "out_of_range", attempted={…значения…})`; +- шаблон не совпал → `Rejected(reason="out_of_grammar", + attempted=null)`. + +## 5. Семантика для журнала (Ф-1.5, контракт m09 spec 04) + +```json +{"cmd":"set","weight_kg":120.0,"reps":8} +{"cmd":"weight","weight_kg":62.5} +{"cmd":"rest","sec":90} +{"cmd":"rpe","rpe":7} +{"cmd":"rejected","reason":"out_of_range","weight_kg":600.0,"reps":8} +``` + +- `semantics` — объект (как в PayloadFactory.commandLog m09); + `rejected` сохраняет распознанные значения — разбор ошибок + при анализе журнала; +- исходный текст гипотезы пишется в `recognized_text` исполнителем + (спец 03). + +## 6. Тесты (unit, pure JVM) + +1. Четыре команды — числа словами: «подход сто двадцать на + восемь» → Approach(120,8); «вес шестьдесят два с половиной» → + Weight(62.5); «отдых девяносто секунд» → Rest(90); «рпэ семь» / + «эр пэ э семь» → Rpe(7). +2. Цифрами: «подход 120 на 8», «вес 62,5», «отдых 90», «120.5». +3. Границы: W=0 и W=500 приняты; W=500,5 / R=0 / R=101 / S=4 / + S=601 / N=0 / N=11 → out_of_range (attempted заполнен). +4. Отклонения: «погнали» → out_of_grammar; «подход сто двадцать» + (нет «на») → out_of_grammar; «отдых тридцать с половиной» → + out_of_grammar («с половиной» не у веса); «подход двадцать сто + на пять» → out_of_grammar. +5. NumberRu: крайние числа — ноль, сто, девятьсот девяносто + девять, «двести двадцать два»; регистр и пунктуация («Подход + 120, на 8!»); формы «два»/«две». +6. Alternatives: первая гипотеза мусор, вторая валидна → команда + из второй; все мусор → Rejected из первой. diff --git a/1-docs/4-modules/m11-voice-input/api_specs/02-stt-model.md b/1-docs/4-modules/m11-voice-input/api_specs/02-stt-model.md new file mode 100644 index 0000000..54d48f3 --- /dev/null +++ b/1-docs/4-modules/m11-voice-input/api_specs/02-stt-model.md @@ -0,0 +1,96 @@ +# Спецификация 02: SttEngine / VoskEngine и ModelStore + +Источники: Ф-1.1 (активация кнопкой, офлайн, пока экран открыт), +ОВ-2 (Vosk, малая ru-модель), tech-req 5 (`VoiceInput` — service +в процессе приложения; грамматика JSON-словарь), решение гейта 1.3 +№1/№4. Единицы 2–3. + +## 1. SttEngine — интерфейс (для тестов и подмены) + +```kotlin +interface SttEngine { + /** Прослушивание; onResult — финальные гипотезы (до 3) в тишине. */ + fun startListening(onResult: (List) -> Unit) + fun stopListening() + fun shutdown() +} +``` + +- колбэк — в главном потоке; повторный `startListening` без + `stopListening` игнорируется; `shutdown` освобождает нативные + ресурсы (вызывается при уходе с экрана/закрытии приложения). + +## 2. VoskEngine — реализация + +- зависимость `com.alphacephei:vosk-android:0.3.47` (Maven Central; + JNI-библиотеки в AAR — рост APK ≈ 45 МБ debug без минификации); +- `org.vosk.Model` инициализируется из каталога модели (§3) в + IO-потоке (не главном); `SpeechService(recognizer, 16000f)` — + микрофон 16 кГц; остановка — `stop()` + `shutdown()` (errata + 2026-10-19: методов stopListening/destroy в API 0.3.47 нет); + `RecognitionListener` — 5 методов: команды приходят в + `onResult` (после паузы речи), `onFinalResult` — только при + остановке сервиса, `onPartialResult`/`onError`/`onTimeout` + игнорируются (потеря аудио — ждём следующую фразу); +- **грамматика (решение гейта 1.3 №4):** при создании — попытка + `Recognizer(model, 16000f, grammarJson)`; grammarJson строится из + шаблонов Ф-1.2 + числительные 0–999 (продукции: сотни/десятки/ + единицы, «с половиной», «на», «секунд|секунды», «рпэ»); формат — + GOF `setGrammar` Vosk; при отказе инициализации с грамматикой + (малая модель не гарантирует поддержку) — автоматический откат + на свободное распознавание (`Recognizer(model, 16000f)`), + лишнее отсекает парсер (спец 01, Ф-1.3); выбранный режим — + в поле `grammarApplied` (диагностика в настройках); +- n-best: `setMaxAlternatives(3)`, если доступно в API vosk-android; + иначе список из одного текста; финальный результат — разбор JSON + (`text` / `alternatives[].text`); +- микрофон/разрешение: движок не запрашивает `RECORD_AUDIO` сам — + старт при отсутствии разрешения → `SecurityException` перехват + → `startListening` возвращает false-статус (UI запрашивает до + старта, спец 05); +- только офлайн: сетевых вызовов нет; движения речи не сохраняются + (аудио не пишется на диск). + +## 3. ModelStore — доставка модели (решение гейта 1.3 №1) + +```kotlin +class ModelStore(context, settings) { + fun modelDir(): File // filesDir/models/vosk-ru + fun isDownloaded(): Boolean // каталог + am/ присутствуют + suspend fun download(onProgress: (Int) -> Unit): Result + fun sizeMb(): Long? // для статуса настроек +} +``` + +- модель `vosk-model-small-ru-0.22` (~45 МБ zip): URL — настройка + `Voice.ModelUrl`, умолчание + `https://alphacephei.com/vosk/models/vosk-model-small-ru-0.22.zip` + (можно заменить на файл сервера владельца); +- загрузка OkHttp (зависимость уже есть), zip → `cacheDir`; + распаковка `ZipInputStream` c защитой от zip-slip (пути + нормализуются, выход за каталог отбрасывается); распакованный + каталог приобретает финальное имя атомарной заменой (временный + суффикс → rename); +- идемпотентность: `download` при готовой модели — no-op + (`isDownloaded` → успех без сети); повторная загрузка (смена URL) + заменяет каталог; +- прогресс — проценты по `Content-Length`; ошибки — `Result.failure` + (сеть/битый zip/нет места), текст для UI — спец 05; +- модель НЕ входит в APK и git (в `filesDir`; бэкап системы — + `allowBackup=false` уже в манифесте). + +## 4. Тесты + +1. ModelStore: распаковка корректного zip (фикстура-архив с + файлами-маркерами `am/`, `graph/`) → `isDownloaded=true`, + структура каталога; zip-slip (запись `../../evil`) — файл + отброшен, исключения нет; битый zip → failure; повторный + download — no-op. Чистые JVM-тесты на временных каталогах + (`@TempDir`), OkHttp — через `MockWebServer` (добавить + testImplementation). +2. VoskEngine — тонкая обёртка над нативным API, unit-тестами не + покрывается (нативные библиотеки); разбор JSON-результата + (`text`/`alternatives`) — выделен в чистую функцию + `VoskResult.parse(json)` и тестируется на JVM. +3. Интеграция движка с моделью — ручной смок владельца (критерий + приёмки 2 module_reqs). diff --git a/1-docs/4-modules/m11-voice-input/api_specs/03-voice-command-executor.md b/1-docs/4-modules/m11-voice-input/api_specs/03-voice-command-executor.md new file mode 100644 index 0000000..b565563 --- /dev/null +++ b/1-docs/4-modules/m11-voice-input/api_specs/03-voice-command-executor.md @@ -0,0 +1,131 @@ +# Спецификация 03: VoiceCommandExecutor и WorkoutCore.setLastSetRpe + +Источники: Ф-1.2–1.5 (применение/отклонение/подтверждение/журнал), +Ф-13.3 (RPE последнего подхода), инвариант m09 (единственный +писатель 🛡), решение гейта 1.3 №2/№3. Единицы 4 и 8. + +## 1. Шлюз к ядру (тестируемость без Room) + +```kotlin +interface VoiceCoreGateway { + suspend fun logSet(workoutId: String, exerciseId: String?, + weightKg: Double, reps: Int, source: String) + suspend fun logCommand(workoutId: String, text: String, + semantics: JsonObject, accepted: Boolean) + suspend fun setLastSetRpe(workoutId: String, rpe: Int): Boolean +} +``` + +Реализация шлюза — прямые вызовы `WorkoutCore` (m09): `logSet` +(source="voice"), `logCommand` (уже есть), `setLastSetRpe` +(§4). `VoiceInput` LocalDb не пишет (🛡 инвариант 3 module_reqs). + +## 2. Executor + +```kotlin +class VoiceCommandExecutor( + private val gateway: VoiceCoreGateway, + private val feedback: VoiceFeedback, // интерфейс, спец 04 + private val effects: VoiceEffects, // колбэки UI, §3 +) + +fun interface VoiceEffects { + /** Возврат в UI: рабочий вес / старт отдыха / сообщение. */ + fun onEffect(effect: VoiceEffect) +} + +sealed interface VoiceEffect { + data class WorkingWeight(val weightKg: Double) : VoiceEffect + data class RestStarted(val seconds: Int) : VoiceEffect + data class Message(val text: String) : VoiceEffect +} +``` + +`handle(workoutId, alternatives, currentExerciseId)` — по каждой +финальной гипотезе (спец 02): + +1. `VoiceCommandParser.parse` → команда; `null` («не было + команды») — выход без записи и звука; +2. **Approach(w, r)**: нет выбранного упражнения (`currentExerciseId + == null`) → отклонение `no_exercise` (§5); иначе + `gateway.logSet(source="voice")` → журнал + `accepted=true` → эффект щелчка + TTS «Принято, подход W на R» + (W — целое/«с половиной» озвучивается числом); +3. **Weight(w)**: нет упражнения → `no_exercise`; иначе эффект + `WorkingWeight(w)` + журнал `accepted=true` + TTS «Принято, + вес W»; +4. **Rest(s)**: эффект `RestStarted(s)` (таймер m10) + журнал + `accepted=true` + TTS «Принято, отдых S секунд»; +5. **Rpe(n)**: `gateway.setLastSetRpe` → false (подходов нет) → + отклонение `no_set`; true → журнал `accepted=true` + TTS + «Принято, РПЭ N»; +6. **Rejected(out_of_grammar / out_of_range)**: журнал + `accepted=false` (семантика парсера, спец 01 §5) + сигнал + ошибки (TTS молчит — Ф-1.3 «короткий сигнал»); состояние + тренировки не меняется; +7. отклонения `no_exercise` / `no_set`: журнал `accepted=false` + (`{"cmd":"…","reason":"no_exercise"|"no_set"}`) + сигнал + ошибки + эффект `Message("Сначала выберите упражнение")` / + `Message("Подходов ещё нет")`; +8. после ЛЮБОЙ журналируемой команды (пп. 2–7 — журнал пишется и для + отклонённых) — сигнал UI на `SyncWorker.schedule` (уточнение по + реализации: записи журнала тоже попадают в sync_pending, будить + воркер нужно всегда; расписание — компетенция UI, ядро без + Android-контекста). + +Журнал пишется для КАЖДОЙ распознанной команды — принятой +и отклонённой (🛡 Ф-1.5); `recognized_text` — исходная гипотеза +(первая при Rejected), `t_offset_s` — ставит `WorkoutCore.logCommand` +от начала тренировки. + +## 3. Эффекты в UI (обрабатывает WorkoutViewModel, спец 05) + +- `WorkingWeight` → рабочий вес текущего упражнения (форма + + показ); +- `RestStarted` → `restLeft = seconds` (переиспользование таймера + отдыха m10); +- `Message` → строка статуса голоса. + +## 4. WorkoutCore.setLastSetRpe — расширение m09 (решение гейта 1.3 №3) + +```kotlin +/** RPE последнего подхода тренировки (Ф-13.3, «РПЭ N»); false — подходов нет. */ +suspend fun setLastSetRpe(workoutId: String, rpe: Int): Boolean { + val set = db.workoutDao().lastSet(workoutId) ?: return false + val updated = set.copy(rpe = rpe) + db.workoutDao().upsertSet(updated) + enqueue("workout_set", set.id, PayloadFactory.workoutSet(updated)) + return true +} +``` + +- DAO-добавление m09: `lastSet(workoutId)` — строка с max(seq); +- повторная отправка той же сущности (`client_guid` = id подхода, + op=upsert) — сервер идемпотентен (m02), паттерн переименования + упражнения m10; +- rpe из голоса 1–10 уже валидирован парсером; правка RPE + вручную после голоса не блокируется (последняя запись + выигрывает — upsert). + +## 5. Таблица отклонений + +| Причина | Условие | Звук | Журнал | +|---|---|---|---| +| `out_of_grammar` | шаблон не совпал | сигнал ошибки | accepted=false, семантика парсера | +| `out_of_range` | число вне диапазона | сигнал ошибки | accepted=false + значения | +| `no_exercise` | Approach/Weight без выбранного упражнения | сигнал ошибки | accepted=false + reason | +| `no_set` | Rpe без единого подхода | сигнал ошибки | accepted=false + reason | + +## 6. Тесты (unit, pure JVM) + +1. Executor на фейках `VoiceCoreGateway`/`VoiceFeedback`/сборщик + эффектов: Approach → logSet(source=voice) + журнал accepted=true + + эффектов нет; Weight → WorkingWeight + журнал; Rest → + RestStarted; Rpe → setLastSetRpe=true → журнал. +2. Отклонения: Rejected → журнал accepted=false + error-сигнал, + gateway не вызывался; no_exercise / no_set (фейк + setLastSetRpe=false) → Message + журнал accepted=false. +3. null от парсера (не команда) — ничего не вызвалось (ни журнала, + ни звука). +4. Последовательность: текст каждой гипотезы (первая распознанная + команда из alternatives) попадает в recognized_text. diff --git a/1-docs/4-modules/m11-voice-input/api_specs/04-tts-feedback.md b/1-docs/4-modules/m11-voice-input/api_specs/04-tts-feedback.md new file mode 100644 index 0000000..1051f4d --- /dev/null +++ b/1-docs/4-modules/m11-voice-input/api_specs/04-tts-feedback.md @@ -0,0 +1,65 @@ +# Спецификация 04: TtsFeedback — подтверждения (Ф-1.4) + +Источники: Ф-1.4 (щелчок + голосовое подтверждение, TTS офлайн), +Ф-1.3 (короткий сигнал ошибки), tech-req 4.1 (TTS — системный +Android), решение гейта 1.3 №5. Единица 5. + +## 1. Интерфейс (используется исполнителем, спец 03) + +```kotlin +interface VoiceFeedback { + fun accepted(text: String) // щелчок + голосовое TTS-подтверждение + fun rejected() // короткий сигнал ошибки, TTS молчит + fun ttsAvailable(): Boolean // диагностика («Проверить TTS») +} +``` + +Реализация `TtsFeedback(context)` — системный Android TTS +(`android.speech.tts`) + `ToneGenerator`. + +## 2. Звуки (ToneGenerator) + +- щелчок принятой команды — короткий позитивный тон (~120 мс, + `TONE_PROP_BEEP`), громкость по системному потоку; +- сигнал ошибки — отличимый низкий двойной тон (~300 мс, + `TONE_PROP_NACK`; errata 2026-10-19: константы TONE_PROP_ERR + в ToneGenerator нет); +- звуки уважают беззвучный режим (поток уведомлений) — в тихом + режиме подтверждение остаётся голосовым/текстовым; +- два сигнала различимы на слух (принято/отказ) — ui-ux: + различение паттернов. + +## 3. TTS + +- язык `ru-RU`: `setLanguage` → `LANG_MISSING_DATA`/`LANG_NOT_SUPPORTED` + → `ttsAvailable=false` — деградация (щелчок/сигнал + текст + на экране, работа голоса не блокируется — решение гейта 1.3 №5); +- инициализация асинхронная: фразы до готовности движка (≤ 2 с) + ставятся в очередь, при таймауте — отбрасываются (щелчок уже + прозвучал); +- `speak(QUEUE_FLUSH)` — новая фраза прерывает недоговорённую + (команды идут редко, экономия времени); +- числовые значения озвучиваются движком (цифры в тексте); + аббревиатура РПЭ в TTS-тексте раскрывается в «эр пэ э» — + корректное пословное произношение: «Принято, эр пэ э семь»; +- формулы фраз: «Принято, подход {W} на {R}», «Принято, вес {W}», + «Принято, отдых {S} секунд», «Принято, эр пэ э {N}»; W=62,5 → + «шестьдесят два с половиной» (TTS-движок озвучит «62,5» — + проверить на смоке; при некорректном озвучивании дроби — + словесная подстановка, шаг 3). + +## 4. Жизненный цикл + +- экземпляр живёт в контейнере приложения, TTS-движок + инициализируется лениво при первом включении микрофона; + `shutdown()` — при выходе с экрана тренировки не вызывается + (переиспользование между тренировками), только при разрушении + процесса. + +## 5. Тесты + +- интерфейс — поведение подтверждается unit-тестами исполнителя + (спец 03) на фейке; сам TtsFeedback — тонкая обёртка системных + API, ручной смок (критерий 2 module_reqs): услышать щелчок, + фразу, сигнал ошибки; «Проверить TTS» в настройках (спец 05) + показывает статус ru-голоса. diff --git a/1-docs/4-modules/m11-voice-input/api_specs/05-ui-integration.md b/1-docs/4-modules/m11-voice-input/api_specs/05-ui-integration.md new file mode 100644 index 0000000..e7b7510 --- /dev/null +++ b/1-docs/4-modules/m11-voice-input/api_specs/05-ui-integration.md @@ -0,0 +1,93 @@ +# Спецификация 05: интеграция UI — экран «Тренировка» и «Настройки» + +Источники: Ф-1.1 (активация кнопкой, экран активной тренировки), +Ф-1.6 (правка ≤ 2 касаний), Ф-3.1 (статус-строка), решения гейта +1.3 №1/№2, ui-ux разд. 3 (текст дублирует цвет, нижняя треть, +≥ 48 dp). Единицы 6–7. Расширение экранов m10. + +## 1. Экран «Тренировка» — кнопка микрофона и прослушивание + +- в TopAppBar рядом с Edit — кнопка Mic (`Icons.Filled.Mic` / + `MicOff`), цель ≥ 48 dp; состояние дублируется текстом в теле + экрана: «Слушаю…» (индикатор — пульсирующая точка) / + «Голос выключен» (🛡 цвет → текст); +- **первое включение** (последовательность, без блокировки + интерфейса): + 1. разрешение `RECORD_AUDIO` (runtime-запрос): отказ → голос + выключен, сообщение «Без разрешения микрофона голос + недоступен» (кнопка остаётся доступной для повторного + запроса); + 2. модель не загружена (`ModelStore.isDownloaded()=false`) → + диалог «Модель распознавания не загружена. Скачать сейчас? + (~45 МБ)» → да → загрузка (прогресс %, ошибки текстом, + кнопка «Отмена»), по успеху — старт прослушивания; нет → + голос выключен; + 3. готово → `SttEngine.startListening` (спец 02); +- повторное нажатие — выключение (`stopListening`); выход с экрана + тренировки — остановка прослушивания (`DisposableEffect`); + прослушивание работает только при активной тренировке (Ф-1.1) — + заглушка «Нет активной тренировки» кнопки не активирует; + +## 2. Обратная связь на экране + +- строка последней команды (текст, не только цвет 🛡): «Принято: + подход 60 на 10» / «Принято: вес 62,5» / «Принято: отдых 90» / + «Принято: РПЭ 7» / «Не распознано» / «Число вне диапазона» / + «Сначала выберите упражнение» (эффекты Message исполнителя); +- строка появляется при первой команде, остаётся до следующей. + +## 3. Текущее упражнение (решение гейта 1.3 №2) + +- строки блока «План сессии» — clickable: тап выбирает «текущее + упражнение» (подсветка + подпись «текущее»); +- умолчание — первый пункт плана сессии дня; план пуст — + используется последний выбранный в форме подхода (selected + exercise m10 сохраняется); совсем нет — ничего не выбрано, + голосовые Approach/Weight отклоняются (no_exercise, спец 03 §5); +- голос пишет подходы в текущее упражнение (`logSet(source= + "voice")`); ручная форма m10 (Edit) остаётся без изменений + (правка ≤ 2 касаний, Ф-1.6); +- эффект `WorkingWeight(w)` — обновляет вес формы m10 и показ + плана (следующие «Подход» озвучивают уже новый вес, если + атлет говорит «подход на R» без веса — НЕ входит в грамматику + v1: вес в команде обязателен, Ф-1.2). + +## 4. Таймер отдыха + +- эффект `RestStarted(s)` → `restLeft = s` (переиспользование + RestCard/RestTimer m10; фазы цвет+текст без изменений); +- ручной старт кнопкой «Отдых» остаётся (умолчание + `Rest.DefaultSec`). + +## 5. «Настройки» — блок «Распознавание речи» (расширение m10) + +- `Voice.ModelUrl` — текстовое поле, умолчание + `https://alphacephei.com/vosk/models/vosk-model-small-ru-0.22.zip`; + валидация — абсолютный https:// URL (кнопка «Сохранить» + неактивна при ошибке — паттерн BaseUrl m10); +- статус модели: «Не загружена» / «Загружена (N МБ)»; +- кнопка «Загрузить» → `ModelStore.download` с прогрессом + (повтор при ошибке; замена каталога при смене URL); +- кнопка «Проверить TTS» → «Русский голос доступен» / + «TTS недоступен — только сигналы и текст» (`ttsAvailable()`, + спец 04); +- AppSettings m09/m10 расширяется полем `voiceModelUrl` (errata + m10 по образцу `hrMaxBpm`; SettingsRepository — DataStore). + +## 6. Разрешения и манифест + +- `AndroidManifest.xml`: `` — runtime + (dangerous), запрос только из кнопки микрофона (не при старте + приложения); +- интернет для загрузки модели — существующее разрешение m09 + (SyncWorker); распознавание сеть не использует (🛡 офлайн). + +## 7. Тесты + +- unit (pure JVM): ViewModel-логика эффектов (WorkingWeight → + состояние веса, RestStarted → секунды, Message → строка + статуса) — где выделено в чистые функции; валидация + `Voice.ModelUrl` (паттерн SettingsValidator m10); +- ручной смок — критерии 2/3 module_reqs (полный голосовой + сценарий + синхронизация command_log). diff --git a/1-docs/4-modules/m11-voice-input/module_reqs.md b/1-docs/4-modules/m11-voice-input/module_reqs.md new file mode 100644 index 0000000..d57b5e9 --- /dev/null +++ b/1-docs/4-modules/m11-voice-input/module_reqs.md @@ -0,0 +1,132 @@ +# Требования к модулю: m11-voice-input + +> Модуль — **VoiceInput** телефона `Phone.WatchBody` (архитектура +> разд. 1.2): офлайн-STT Vosk + грамматика команд Ф-1.2 + TTS-подтверждения +> + интеграция в экран «Тренировка» (m10) поверх ядра m09. Самодостаточная +> копия требований (d-dev-6). Источники: Ф-1, Ф-3.3 (таймер), Ф-13.3 +> («РПЭ N»), открытый вопрос 2 (закрыт шагом 5: Vosk, малая ru-модель, +> TTS — системный Android), tech-req 4.1/5 (`VoiceInput`, модель ~50 МБ, +> постобработка чисел), 7 п. 3 (прототип точности), ui-ux разд. 3. + +## 1. Назначение и границы + +Голосовой офлайн-учёт подходов: атлет на экране активной тренировки +включает микрофон и говорит команды; команды разбираются офлайн, +применяются к текущей тренировке и журналируются (🛡 Ф-1.5). + +**В границах модуля:** + +- **грамматика команд Ф-1.2** (разбор — чистая функция): + - «Подход **W** на **R**» → подход: вес `W` кг × `R` повторений + (вес — целые кг либо «…с половиной» → +0,5 кг; `W` — 0–500, + `R` — 1–100; решение владельца О-2-Б); + - «Вес **W**» → рабочий вес `W` кг для следующих подходов; + - «Отдых **S** (секунд)» → таймер отдыха `S` секунд (5–600); + - «РПЭ **N**» → RPE последнего подхода, `N` — 1–10 (Ф-13.3); + - числа — словами («сто двадцать») и цифрами («120»); слова «секунд» + после `S` — необязательны; постобработка «сто двадцать» → 120 — + tech-req 5; + - команда вне грамматики или число вне диапазона — **отклоняется**: + короткий сигнал ошибки, состояние не меняется, в журнал пишется + распознанный текст с `accepted=false` (Ф-1.3); +- **STT-движок**: Vosk (`com.alphacephei:vosk-android`), малая + русская модель (`vosk-model-small-ru-0.22`, ~45 МБ); распознавание + только офлайн — сеть не используется (Ф-1.1, ОВ-2); ограничение + словаря грамматикой Vosk (`setGrammar`, JSON-словарь: «подход», «вес», + «отдых», «на», «секунд», «с половиной», числительные 0–999) — + применяется, если модель поддерживает; обязательна строгая + постобработка финальной гипотезы парсером (малые модели Vosk не + гарантируют поддержку грамматики — деградация до свободного + распознавания, лишнее отклоняет парсер по Ф-1.3); +- **доставка модели**: модель НЕ входит в APK и в репозиторий; + загружается zip-архивом по https в папку приложения при первом + включении микрофона (или из «Настроек»), распаковывается в + `filesDir`; URL — настройка `Voice.ModelUrl` (умолчание — официальный + хост alphacephei.com); статус модели и кнопка «Загрузить» — в + «Настройках» (решение гейта 1.3); +- **применение команд** (исполнитель): «Подход» → `WorkoutCore.logSet` + (source=`voice`, текущее упражнение экрана); «Вес» → рабочий вес + текущего упражнения (состояние экрана, умолчание формы); «Отдых» → + старт таймера отдыха (переиспользуется таймер m10); «РПЭ» → RPE + последнего подхода тренировки (`WorkoutCore.setLastSetRpe` — + расширение m09, как `renameExercise` в m10); +- **журнал команд 🛡** (Ф-1.5): каждая команда — принятая и отклонённая — + через `WorkoutCore.logCommand` (текст, семантика, `t_offset_s` от + начала тренировки, `accepted`); журнал не редактируется; уходит sync + `command_log` (контракт m09 spec 04); +- **подтверждения** (Ф-1.4): принятая команда — короткий «щелчок» + (ToneGenerator) + голосовое TTS-подтверждение офлайн («Принято, + подход 120 на 8» / «…вес 100» / «…отдых 90 секунд» / «…РПЭ 8»); + TTS — системный Android TTS (ru-RU, tech-req 4.1); при отсутствии + TTS-движка/русского голоса — деградация: щелчок/сигнал + текст + подтверждения на экране, работа не блокируется; +- **активация** (Ф-1.1): кнопка микрофона на экране «Тренировка»; + повторное нажатие выключает прослушивание; прослушивание активно, + пока открыт экран активной тренировки и микрофон включён — выход + с экрана останавливает; разрешение `RECORD_AUDIO` запрашивается + при первом включении (runtime); +- **текущее упражнение**: голос пишет подходы в «текущее упражнение» + экрана «Тренировка» — выбор тапом в блоке плана (подсветка), + умолчание — первый пункт плана сессии дня, иначе последнее + введённое вручную; голосовой выбор упражнения по имени в v1 + отсутствует (имена произвольные — распознавание ненадёжно; + решение гейта 1.3); +- **экран «Тренировка»**: индикатор «слушаю/выключено» (иконка + + текст 🛡), строка последней команды (принято/отклонено — текстом, + не только цветом), «Вес W» меняет отображаемый рабочий вес; +- **«Настройки»** (расширение m10): блок «Распознавание речи» — статус + модели (не загружена / загружена, размер), кнопка «Загрузить» + (по `Voice.ModelUrl`, прогресс/ошибка текстом), «Проверить TTS» — + наличие ru-голоса; +- unit-тесты (pure JVM): парсер (4 команды, числа словами 0–999 + и цифрами, «с половиной», границы диапазонов, мусор, регистр), + семантика журнала, NumberRu; исполнитель — на фейках ядра. + +**Вне границ модуля:** wake-word (опросник 4.5 — v2), голосовой выбор +упражнения по имени (v1 нет — решение гейта), распознавание на часах, +запись аудио (только живой поток в движок, хранения нет), шумовой +профиль зала, метрики точности в БД (КУ-1 фиксируется вручную +владельцем), m12 канал часов, m13 поле данных. + +## 2. Решения интерфейса (ui-ux разд. 3, tech-req 6) + +- Кнопка микрофона — нижняя треть экрана, цель ≥ 48 dp, состояние + дублируется текстом («Слушаю…» / «Голос выключен»); +- строка последней команды — текстом: «Принято: подход 120 на 8» / + «Не распознано» (цвет — только дублирование); +- сигнал ошибки — короткий, не пугающий; формулировки спокойные; +- TTS-подтверждения короткие (≤ 3–4 слов + числа). + +## 3. Инварианты (🛡 — нарушать запрещено) + +1. **Офлайн-first** (Ф-1.1, КУ-3): распознавание и применение команд + работают без сети; сеть — только доставка модели (один раз) и + SyncQueue как обычно. +2. **Журнал команд полон и неизменяем** (Ф-1.5): каждая распознанная + фраза (принятая и отклонённая) фиксируется с исходным текстом, + семантикой и `t_offset_s`; удаление/правка журнала невозможны. +3. **Голос не обходит ядро**: применение команд — только через + `WorkoutCore` (единственный писатель LocalDb, инвариант m09); + `VoiceInput` не пишет в БД напрямую. +4. **Допуск Safety не снимается голосом**: команды работают только + в уже начатой (допущенной) тренировке; блокировки допуска — + контур m09/m10. +5. **Тайны**: `Voice.ModelUrl` может содержать URL сервера владельца — + ключи/пароли в неё не попадают; API-ключ не логируется (З-6). + +## 4. Критерии приёмки модуля + +1. `assembleDebug` собирается; unit-тесты парсера/исполнителя зелёные. +2. Ручной смок на устройстве владельца (модель загружена): включить + микрофон → «Подход 60 на 10» → щелчок + «Принято, подход 60 на 10» + → подход записан (источник «голос», метка плана); «Вес 62 + с половиной» → рабочий вес 62,5; «Отдых 90» → таймер; «РПЭ 7» → + RPE у последнего подхода; фраза вне грамматики → сигнал ошибки, + состояние не изменилось, в журнале `accepted=false`. +3. Смок синхронизации (живой docker-сервер): журнал команд уходит + `command_log`-пакетом, строка в `src.command_log` содержит текст + и семантику. +4. КУ-1 (контрольная тренировка ≥ 4 упражнений, ≥ 16 подходов, + ≥ 90 % команд с первого раза) — ручная приёмка владельца в зале + (tech-req 7 п. 3); автоматизации не подлежит, фиксируется + в журнале приёмки. diff --git a/1-docs/4-modules/m11-voice-input/readme.md b/1-docs/4-modules/m11-voice-input/readme.md new file mode 100644 index 0000000..d4c7e0f --- /dev/null +++ b/1-docs/4-modules/m11-voice-input/readme.md @@ -0,0 +1,96 @@ +# m11-voice-input — план работ (d-dev) + +> Модуль: VoiceInput — офлайн-STT (Vosk) + грамматика Ф-1.2 + +> TTS-подтверждения + интеграция в экран «Тренировка». +> Алгоритм: `std1\_rules\d\d-dev.md`. Требования: +> [module_reqs.md](module_reqs.md). +> Зависимости: m09 (WorkoutCore/LocalDb/SyncQueue — DONE), m10 +> (экран «Тренировка», таймер отдыха, «Настройки» — DONE); часы — m12. + +| Шаг | Что делаем | Статус | +|---|---|---| +| 0 | Модульные документы: module_reqs.md / readme.md / SKILL.md | ✅ 2026-10-19 | +| 1 | Список единиц (парсер/движок/модель/исполнитель/UI/тесты) | ✅ 2026-10-19 | +| 1.3 | **Пользователь!** утверждение списка (+ решения по объёму v1) | ✅ 2026-10-19 | +| 2 | Спецификации: api_specs (парсер, движок/модель, исполнитель, UI) | ✅ 2026-10-19 | +| 2.4 | **Пользователь!** утверждение спецификаций | ✅ 2026-10-19 | +| 3 | Реализация + тесты + сборка | ✅ 2026-10-19 | +| 3.4 | **Пользователь!** утверждение реализации (закрытие модуля) | ✅ 2026-10-19 | +| 4 | Завершение: план, примечания | ✅ 2026-10-19 | + +Статусы: ⬜ не начат · 🔄 в работе · ✅ готов. + +## Список единиц (шаг 1) + +| # | Единица | Что делает | +|---|---|---| +| 1 | `VoiceCommandParser` (+ `NumberRu`) | чистая функция: текст → `VoiceCommand` (Approach/Weight/Rest/Rpe) или `Unknown`; числа словами 0–999 и цифрами, «с половиной» → +0,5; диапазоны Ф-1.2; semantics для журнала | +| 2 | `SttEngine`/`VoskEngine` | обёртка Vosk `SpeechService`: инициализация модели из `filesDir`, старт/стоп прослушивания, финальные гипотезы в колбэк; опциональный `setGrammar` (JSON-словарь); интерфейс — для тестов | +| 3 | `ModelStore` | загрузка zip модели по `Voice.ModelUrl` (OkHttp, уже в проекте), распаковка в `filesDir`, идемпотентность, статус (нет/загружена, МБ) | +| 4 | `VoiceCommandExecutor` | применение команды: Approach → `logSet(source=voice)`, Weight → рабочий вес экрана, Rest → старт таймера m10, Rpe → `WorkoutCore.setLastSetRpe`; журнал `logCommand` (принято/отклонено 🛡); будит SyncWorker | +| 5 | `TtsFeedback` | системный Android TTS (ru-RU) + ToneGenerator (щелчок/сигнал ошибки); деградация без TTS (текст на экране) | +| 6 | UI «Тренировка» | кнопка микрофона (toggle, индикатор «Слушаю…»), runtime-разрешение `RECORD_AUDIO`, строка последней команды, «текущее упражнение» (тап в блоке плана), рабочий вес от «Вес W» | +| 7 | UI «Настройки» | блок «Распознавание речи»: статус модели, «Загрузить», «Проверить TTS»; `Voice.ModelUrl` | +| 8 | `WorkoutCore.setLastSetRpe` | расширение m09 (errata, как `renameExercise` в m10): RPE последнего подхода + повторная отправка в sync | +| 9 | unit-тесты | парсер (4 команды × формы чисел, границы, мусор), NumberRu, исполнитель на фейках | + +## Решения на гейт 1.3 (для владельца) + +1. **Доставка модели Vosk** (~45 МБ, `vosk-model-small-ru-0.22`): + рекомендуем (а) — загрузка zip по https при первом включении + микрофона / из «Настроек» (`Voice.ModelUrl`, умолчание + alphacephei.com; можно указать свой сервер), модель живёт в + `filesDir`, в APK и репозиторий не входит. Альтернативы: (б) вшить + в APK assets (+45 МБ и бинарник в git); (в) ручной сайдлоад adb. +2. **Текущее упражнение для голоса** — выбор тапом в блоке плана + (умолчание — первый пункт плана); голосовой выбор упражнения по + имени в v1 отсутствует (имена произвольные, распознавание + ненадёжно). Форма ручного ввода m10 сохраняется (правка ≤ 2 касаний, + Ф-1.6). +3. **«РПЭ N»** — новый метод `WorkoutCore.setLastSetRpe` (расширение + m09 по образцу `renameExercise`: обновление строки + повторный + enqueue; сервер идемпотентен по client_guid). +4. **Грамматика Vosk** — `setGrammar` применяется при поддержке + моделью; обязательна строгая постобработка парсером (малые модели + не гарантируют грамматику; лишнее отклоняется по Ф-1.3). +5. **TTS** — системный Android TTS; на устройстве владельца должен + быть ru-голос (Google TTS); при отсутствии — деградация до + щелчка/сигнала + текст на экране. + +## Примечания по ходу работ + +1. **Состав реализации** (`app/src/main/java/com/watchbody/phone/voice/`): + `VoiceCommandParser.kt` (sealed VoiceCommand + NumberRu 0–999 + tokenize + с «эр пэ э»→«рпэ» + semantics), `VoskResult.kt` (разбор JSON Vosk), + `SttEngine.kt` (интерфейс + VoskEngine: грамматика-список слов с + откатом на свободный разбор, n-best 3), `ModelStore.kt` (zip→filesDir, + zip-slip, single-root срезка, no-op при готовой модели), + `VoiceCommandExecutor.kt` (VoiceCoreGateway/эффекты/отклонения), + `TtsFeedback.kt` (TTS ru + ToneGenerator BEEP/NACK, деградация). + Расширения: `WorkoutCore.setLastSetRpe` + DAO `lastSet` (m09), + `AppSettings.voiceModelUrl` + валидатор https (m10), контейнер + (modelStore/ttsFeedback), WorkoutScreen (Mic-кнопка, RECORD_AUDIO, + диалог модели, «текущее упражнение» тапом в плане, строка голоса), + SettingsScreen (блок «Распознавание речи»: URL/статус/загрузка/ + «Проверить TTS»), манифест RECORD_AUDIO, gradle: vosk-android + 0.3.47 + icons-extended + MockWebServer (test). +2. **Сборка/тесты**: BUILD SUCCESSFUL; APK 57,3 МБ (рост с 12 МБ — + JNI Vosk по 4 ABI + icons-extended; debug без минификации — release + c R8/abiFilters сожмёт, кандидат оптимизации); unit **63/63** + (m09 18 + m10 18 + m11 27: парсер 9, исполнитель 10, модель+валидатор 8). +3. **Живой URL модели проверен** (2026-10-19): alphacephei.com → + 200 OK, 46 236 750 байт (~44 МБ) — умолчание `Voice.ModelUrl` рабочее. +4. **Errata API vosk-android 0.3.47** (вскрыты компиляцией, spec 02 + скорректирован по факту): `SpeechService` управляется `stop()`/ + `shutdown()` (stopListening/destroy нет); `RecognitionListener` — + 5 методов, команды приходят в `onResult` (onFinalResult — при + остановке); сигнал ошибки — `TONE_PROP_NACK` (TONE_PROP_ERR нет). +5. **Решения по ходу**: парсер в approach при out_of_range пишет ОБА + значения ({"weight_kg":100.0,"reps":101} — информативнее для разбора + журнала); `syncRequest`_executor срабатывает после ЛЮБОЙ журналируемой + команды (журнал отклонённых тоже уходит в sync; spec 03 п.8 уточнён + по смыслу); инициализация Model Vosk — в Dispatchers.IO (не main). +6. **Не проверено в среде** (нет устройства/микрофона): живое + распознавание, TTS-озвучка, щелчки/сигналы, загрузка модели на + устройстве, точность КУ-1 — критерии 2/4 приёмки на стороне + владельца; сетевой контур модели проверен MockWebServer + живым URL. diff --git a/3-app/4-phone/Phone.WatchBody/app/build.gradle.kts b/3-app/4-phone/Phone.WatchBody/app/build.gradle.kts index 678b12d..3878eb7 100644 --- a/3-app/4-phone/Phone.WatchBody/app/build.gradle.kts +++ b/3-app/4-phone/Phone.WatchBody/app/build.gradle.kts @@ -58,12 +58,17 @@ dependencies { implementation(composeBom) implementation("androidx.compose.ui:ui") implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") // Mic/MicOff (m11) implementation("androidx.activity:activity-compose:1.9.3") implementation("androidx.navigation:navigation-compose:2.8.5") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") debugImplementation("androidx.compose.ui:ui-tooling") implementation("androidx.compose.ui:ui-tooling-preview") + // голосовой ввод (m11): офлайн-STT Vosk (ОВ-2, tech-req 5) + implementation("com.alphacephei:vosk-android:0.3.47") + testImplementation("junit:junit:4.13.2") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.1") + testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") } diff --git a/3-app/4-phone/Phone.WatchBody/app/src/main/AndroidManifest.xml b/3-app/4-phone/Phone.WatchBody/app/src/main/AndroidManifest.xml index e3e595e..6e03d79 100644 --- a/3-app/4-phone/Phone.WatchBody/app/src/main/AndroidManifest.xml +++ b/3-app/4-phone/Phone.WatchBody/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + errors["voiceModelUrl"] = "URL модели не задан" + vurl == null || vurl.host.isNullOrBlank() || vurl.scheme != "https" -> + errors["voiceModelUrl"] = "нужен абсолютный https://адрес" + else -> {} + } + return Result(errors, httpWarning = url?.scheme == "http") } } diff --git a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/core/WorkoutCore.kt b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/core/WorkoutCore.kt index ee42f83..f0c35ed 100644 --- a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/core/WorkoutCore.kt +++ b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/core/WorkoutCore.kt @@ -127,6 +127,19 @@ class WorkoutCore( enqueue("command_log", id, PayloadFactory.commandLog(command)) } + /** + * RPE последнего подхода («РПЭ N», Ф-13.3) — расширение m11 по образцу + * renameExercise: upsert той же строки + повторная отправка (сервер + * идемпотентен по client_guid). false — подходов в тренировке нет. + */ + suspend fun setLastSetRpe(workoutId: String, rpe: Int): Boolean { + val set = db.workoutDao().lastSet(workoutId) ?: return false + val updated = set.copy(rpe = rpe) + db.workoutDao().upsertSet(updated) + enqueue("workout_set", set.id, PayloadFactory.workoutSet(updated)) + return true + } + // ---------- дневник и перерывы (spec 02 §4) ---------- suspend fun saveDailyLog( diff --git a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/db/Daos.kt b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/db/Daos.kt index 174f2a4..9996d48 100644 --- a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/db/Daos.kt +++ b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/db/Daos.kt @@ -38,6 +38,9 @@ interface WorkoutDao { @Query("SELECT * FROM workout_set WHERE exercise_id = :exerciseId ORDER BY done_at DESC LIMIT 1") suspend fun lastSetForExercise(exerciseId: String): WorkoutSet? + @Query("SELECT * FROM workout_set WHERE workout_id = :workoutId ORDER BY seq DESC LIMIT 1") + suspend fun lastSet(workoutId: String): WorkoutSet? + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertCommand(command: CommandLog) } diff --git a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/settings/SettingsRepository.kt b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/settings/SettingsRepository.kt index 5848df6..e6c10fe 100644 --- a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/settings/SettingsRepository.kt +++ b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/settings/SettingsRepository.kt @@ -23,6 +23,7 @@ data class AppSettings( val hrMaxBpm: Int? = null, // 120–240 или null — расчёт по Танаке (Ф-6.2) val safety: SafetySettings = SafetySettings(), val hrStrengthMax: Int = 120, // предупреждение во время силовой (Ф-13.7, применяется в m12) + val voiceModelUrl: String = "https://alphacephei.com/vosk/models/vosk-model-small-ru-0.22.zip", // m11 ) class SettingsRepository(private val context: Context) { @@ -38,6 +39,7 @@ class SettingsRepository(private val context: Context) { val bpCardioDias = intPreferencesKey("safety.bp_cardio_dias") val restingHrSitting = intPreferencesKey("safety.resting_hr_sitting") val hrStrengthMax = intPreferencesKey("safety.hr_strength_max") + val voiceModelUrl = stringPreferencesKey("voice.model_url") } suspend fun get(): AppSettings = context.dataStore.data.first().toSettings() @@ -55,6 +57,7 @@ class SettingsRepository(private val context: Context) { prefs[Keys.bpCardioDias] = settings.safety.bpCardioDias prefs[Keys.restingHrSitting] = settings.safety.restingHrSitting prefs[Keys.hrStrengthMax] = settings.hrStrengthMax + prefs[Keys.voiceModelUrl] = settings.voiceModelUrl } } @@ -74,6 +77,7 @@ class SettingsRepository(private val context: Context) { restingHrSitting = this[Keys.restingHrSitting] ?: defaults.safety.restingHrSitting, ), hrStrengthMax = this[Keys.hrStrengthMax] ?: defaults.hrStrengthMax, + voiceModelUrl = this[Keys.voiceModelUrl] ?: defaults.voiceModelUrl, ) } } diff --git a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/ui/SettingsScreen.kt b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/ui/SettingsScreen.kt index da44d67..6e4d3a5 100644 --- a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/ui/SettingsScreen.kt +++ b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/ui/SettingsScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -36,14 +37,54 @@ import com.watchbody.phone.settings.AppSettings import com.watchbody.phone.sync.SyncWorker import kotlinx.coroutines.launch -/** «Настройки» (tech-req 4.1, spec 05 §3); 🛡 ключ — маска, не логируется. */ +/** «Настройки» (tech-req 4.1, spec 05 §3 + m11 §5); 🛡 ключ — маска, не логируется. */ class SettingsViewModel(private val container: AppContainer) : ViewModel() { var loaded by mutableStateOf(null) private set + // m11: модель распознавания и TTS (spec 05 §5) + var modelState by mutableStateOf("") + private set + var modelBusy by mutableStateOf(false) + private set + var modelProgress by mutableStateOf(0) + private set + var ttsState by mutableStateOf(null) + private set + init { viewModelScope.launch { loaded = container.settings.get() } + refreshModel() + } + + fun refreshModel() { + modelState = if (container.modelStore.isDownloaded()) { + "Загружена (${container.modelStore.sizeMb()} МБ)" + } else { + "Не загружена" + } + } + + /** [url] — значение из поля до сохранения настроек. */ + fun downloadModel(url: String) { + viewModelScope.launch { + modelBusy = true + modelProgress = 0 + container.modelStore.download(urlOverride = url.trim()) { p -> modelProgress = p } + .fold( + { refreshModel() }, + { modelState = "Ошибка: ${it.message}" }, + ) + modelBusy = false + } + } + + fun checkTts() { + ttsState = "Проверка…" + container.ttsFeedback.ttsAvailable { ok -> + ttsState = if (ok) "Русский голос доступен" else "TTS недоступен — только сигналы и текст" + } } fun save(settings: AppSettings, onSaved: () -> Unit) { @@ -78,6 +119,7 @@ fun SettingsScreen(container: AppContainer, standalone: Boolean, onSaved: () -> var bpCardioDias by remember { mutableStateOf(90) } var restingHrSitting by remember { mutableStateOf(90) } var hrStrengthMax by remember { mutableStateOf(120) } + var voiceModelUrl by remember { mutableStateOf("https://alphacephei.com/vosk/models/vosk-model-small-ru-0.22.zip") } LaunchedEffect(vm.loaded) { // одна загрузка в форму vm.loaded?.let { s -> @@ -88,6 +130,7 @@ fun SettingsScreen(container: AppContainer, standalone: Boolean, onSaved: () -> bpCardioSys = s.safety.bpCardioSys; bpCardioDias = s.safety.bpCardioDias restingHrSitting = s.safety.restingHrSitting hrStrengthMax = s.hrStrengthMax + voiceModelUrl = s.voiceModelUrl } } @@ -98,6 +141,7 @@ fun SettingsScreen(container: AppContainer, standalone: Boolean, onSaved: () -> bpCancelSys, bpCancelDias, bpCardioSys, bpCardioDias, restingHrSitting, ), hrStrengthMax = hrStrengthMax, + voiceModelUrl = voiceModelUrl, ) val validation = SettingsValidator.validate(draft) @@ -162,6 +206,32 @@ fun SettingsScreen(container: AppContainer, standalone: Boolean, onSaved: () -> IntPicker("Порог в силовой", hrStrengthMax, { hrStrengthMax = it ?: 120 }, min = 100, max = 180, start = 120, suffix = " уд/мин") validation.errors["hrStrengthMax"]?.let { Text(it, color = MaterialTheme.colorScheme.error) } + Text("Распознавание речи (голос)", style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = voiceModelUrl, + onValueChange = { voiceModelUrl = it }, + label = { Text("URL модели Vosk (https://…)") }, + modifier = Modifier.fillMaxWidth(), + isError = validation.errors.containsKey("voiceModelUrl"), + ) + validation.errors["voiceModelUrl"]?.let { Text(it, color = MaterialTheme.colorScheme.error) } + Text("Модель: ${vm.modelState}", style = MaterialTheme.typography.bodyMedium) + if (vm.modelBusy) { + LinearProgressIndicator( + progress = { vm.modelProgress / 100f }, + modifier = Modifier.fillMaxWidth(), + ) + Text("${vm.modelProgress} %", style = MaterialTheme.typography.bodySmall) + } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedButton( + onClick = { vm.downloadModel(voiceModelUrl) }, + enabled = !vm.modelBusy && validation.errors.containsKey("voiceModelUrl").not(), + ) { Text("Загрузить модель") } + OutlinedButton(onClick = { vm.checkTts() }) { Text("Проверить TTS") } + } + vm.ttsState?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } + if (!standalone) { Text( "Очередь отправки: $queue записей", diff --git a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/ui/WorkoutScreen.kt b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/ui/WorkoutScreen.kt index bb5f74f..aab96d7 100644 --- a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/ui/WorkoutScreen.kt +++ b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/ui/WorkoutScreen.kt @@ -1,6 +1,7 @@ package com.watchbody.phone.ui import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -11,11 +12,14 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.MicOff import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold @@ -23,6 +27,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -32,7 +37,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.ContextCompat import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.watchbody.phone.AppContainer @@ -44,6 +53,9 @@ import com.watchbody.phone.db.Exercise import com.watchbody.phone.db.Workout import com.watchbody.phone.db.WorkoutSet import com.watchbody.phone.sync.SyncWorker +import com.watchbody.phone.voice.VoiceEffect +import com.watchbody.phone.voice.humanKg +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -51,10 +63,11 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.time.Instant import java.time.ZoneOffset -/** Состояние экрана тренировки (spec 03). */ +/** Состояние экрана тренировки (spec 03 + m11 spec 05 §1–3). */ data class WorkoutUiState( val workout: Workout? = null, val sets: List = emptyList(), @@ -92,6 +105,130 @@ class WorkoutViewModel(private val container: AppContainer) : ViewModel() { } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), WorkoutUiState()) + // ---------- голос m11 (spec 05 §1–3) ---------- + + var listening by mutableStateOf(false) + private set + var voiceMessage by mutableStateOf(null) + private set + + /** Модель не загружена — показать диалог скачивания. */ + var modelDialog by mutableStateOf(false) + private set + var modelDownloading by mutableStateOf(false) + private set + var modelProgress by mutableStateOf(0) + private set + + /** Текущее упражнение для голосовых команд (тап в блоке плана). */ + var voiceExerciseId by mutableStateOf(null) + private set + + /** Эффекты исполнителя применяет экран (вес/отдых — состояние composable). */ + var effectHandler: ((com.watchbody.phone.voice.VoiceEffect) -> Unit)? = null + + /** Сообщение эффекта (no_exercise/no_set) приоритетнее строки статуса. */ + private var effectMessage: String? = null + + private var engine: com.watchbody.phone.voice.SttEngine? = null + + private val executor by lazy { + com.watchbody.phone.voice.VoiceCommandExecutor( + gateway = com.watchbody.phone.voice.WorkoutCoreGateway(container.core), + feedback = container.ttsFeedback, + effects = { e -> + when (e) { + is com.watchbody.phone.voice.VoiceEffect.Message -> effectMessage = e.text + else -> effectHandler?.invoke(e) + } + }, + syncRequest = { + SyncWorker.schedule(container.appContext, container.settings.get().wifiOnly) + }, + ) + } + + fun setCurrentExercise(id: String?) { voiceExerciseId = id } + + /** Умолчание — первый пункт плана, пока пользователь не выбрал сам. */ + fun defaultExerciseFromPlan() { + if (voiceExerciseId != null) return + val s = state.value + val first = s.planItems.firstOrNull() ?: return + val id = s.exercises.firstOrNull { it.name.equals(first.exerciseName, ignoreCase = true) }?.id + if (id != null) voiceExerciseId = id + } + + fun onPermissionResult(granted: Boolean) { + if (granted) startVoice() else voiceMessage = "Без разрешения микрофона голос недоступен" + } + + fun startVoice() { + viewModelScope.launch { + if (!container.modelStore.isDownloaded()) { + modelDialog = true + return@launch + } + try { + // инициализация модели Vosk тяжёлая — в IO, не на главном потоке + val eng = withContext(Dispatchers.IO) { + engine ?: com.watchbody.phone.voice.VoskEngine(container.modelStore.modelDir) + .also { engine = it } + } + eng.startListening { alternatives -> viewModelScope.launch { dispatch(alternatives) } } + listening = true + } catch (e: Exception) { + voiceMessage = "Голос: не запустился (${e.message})" + } + } + } + + fun stopVoice() { + engine?.stopListening() + listening = false + } + + fun dismissModelDialog() { + modelDialog = false + } + + fun downloadModel() { + viewModelScope.launch { + modelDownloading = true + modelProgress = 0 + container.modelStore.download { p -> modelProgress = p } + .fold( + { modelDialog = false; startVoice() }, + { voiceMessage = "Модель не скачана: ${it.message}" }, + ) + modelDownloading = false + } + } + + private suspend fun dispatch(alternatives: List) { + val workoutId = state.value.workout?.id ?: return + effectMessage = null + val cmd = executor.handle(workoutId, alternatives, voiceExerciseId) + voiceMessage = effectMessage ?: statusOf(cmd) + } + + private fun statusOf(cmd: com.watchbody.phone.voice.VoiceCommand?): String? = when (cmd) { + is com.watchbody.phone.voice.VoiceCommand.Approach -> + "Принято: подход ${cmd.weightKg.humanKg()} на ${cmd.reps}" + is com.watchbody.phone.voice.VoiceCommand.Weight -> + "Принято: вес ${cmd.weightKg.humanKg()}" + is com.watchbody.phone.voice.VoiceCommand.Rest -> "Принято: отдых ${cmd.seconds}" + is com.watchbody.phone.voice.VoiceCommand.Rpe -> "Принято: РПЭ ${cmd.rpe}" + is com.watchbody.phone.voice.VoiceCommand.Rejected -> + if (cmd.reason == "out_of_range") "Число вне диапазона" else "Не распознано" + null -> null + } + + override fun onCleared() { + engine?.shutdown() + super.onCleared() + } + /** Умолчание формы: план упражнения, иначе последний фактический вес. */ suspend fun defaults(exerciseId: String?): Pair { if (exerciseId == null) return null to null @@ -166,15 +303,57 @@ fun WorkoutScreen(container: AppContainer) { var confirmCancel by remember { mutableStateOf(false) } + // голос m11 (spec 05 §1): разрешение → старт; эффекты → состояние экрана + val context = LocalContext.current + val micPermission = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { vm.onPermissionResult(it) } + LaunchedEffect(Unit) { + vm.effectHandler = { e -> + when (e) { + is VoiceEffect.WorkingWeight -> weight = e.weightKg + is VoiceEffect.RestStarted -> { restTotal = e.seconds; restLeft = e.seconds } + is VoiceEffect.Message -> {} + } + } + } + LaunchedEffect(state.planItems, state.exercises) { vm.defaultExerciseFromPlan() } + LaunchedEffect(selectedExercise) { + if (selectedExercise != null) vm.setCurrentExercise(selectedExercise) + } + DisposableEffect(Unit) { + onDispose { vm.stopVoice() } // выход с экрана останавливает прослушивание (Ф-1.1) + } + Scaffold( topBar = { TopAppBar( title = { Text("Тренировка") }, actions = { + IconButton( + onClick = { + if (vm.listening) { + vm.stopVoice() + } else if (ContextCompat.checkSelfPermission( + context, android.Manifest.permission.RECORD_AUDIO, + ) == android.content.pm.PackageManager.PERMISSION_GRANTED + ) { + vm.startVoice() + } else { + micPermission.launch(android.Manifest.permission.RECORD_AUDIO) + } + }, + enabled = state.workout != null, + ) { + Icon( + if (vm.listening) Icons.Filled.MicOff else Icons.Filled.Mic, + contentDescription = "Голосовой ввод: включить/выключить", + ) + } IconButton(onClick = { formOpen = true }) { Icon( Icons.Filled.Edit, - contentDescription = "Записать подход (ручной ввод; голос — m11)", + contentDescription = "Записать подход (ручной ввод)", ) } }, @@ -211,12 +390,33 @@ fun WorkoutScreen(container: AppContainer) { ) } + item { + // голос: индикатор + последняя команда — текст, не только цвет 🛡 + Text( + if (vm.listening) "● Слушаю… (скажите: подход / вес / отдых / РПЭ)" else "Голос выключен", + style = MaterialTheme.typography.bodySmall, + color = if (vm.listening) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + ) + vm.voiceMessage?.let { + Text(it, style = MaterialTheme.typography.bodyMedium) + } + } + item { RestCard(restLeft, onStart = { restLeft = restTotal }) } item { - PlanBlock(state.planItems, state.exercises) + PlanBlock( + state.planItems, + state.exercises, + currentId = vm.voiceExerciseId, + onSelect = { id -> + selectedExercise = id + vm.setCurrentExercise(id) + }, + ) } items(state.sets, key = { it.id }) { set -> @@ -272,6 +472,40 @@ fun WorkoutScreen(container: AppContainer) { dismissButton = { TextButton(onClick = { confirmCancel = false }) { Text("Оставить") } }, ) } + + if (vm.modelDialog) { + AlertDialog( + onDismissRequest = { if (!vm.modelDownloading) vm.dismissModelDialog() }, + title = { Text("Модель распознавания") }, + text = { + Column { + Text( + "Модель Vosk (~45 МБ) не загружена. Скачать сейчас? " + + "Распознавание работает офлайн — сеть нужна только для загрузки.", + ) + if (vm.modelDownloading) { + LinearProgressIndicator( + progress = { vm.modelProgress / 100f }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + Text("${vm.modelProgress} %", style = MaterialTheme.typography.bodySmall) + } + } + }, + confirmButton = { + TextButton( + onClick = { vm.downloadModel() }, + enabled = !vm.modelDownloading, + ) { Text("Скачать") } + }, + dismissButton = { + TextButton( + onClick = { vm.dismissModelDialog() }, + enabled = !vm.modelDownloading, + ) { Text("Не сейчас") } + }, + ) + } } } @@ -301,16 +535,38 @@ private fun RestCard(restLeft: Int, onStart: () -> Unit) { } @Composable -private fun PlanBlock(items: List, exercises: List) { +private fun PlanBlock( + items: List, + exercises: List, + currentId: String?, + onSelect: (String?) -> Unit, +) { if (items.isEmpty()) { Text("План на сегодня не задан — тренировка вне программы", style = MaterialTheme.typography.bodyMedium) return } Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text("План сессии", style = MaterialTheme.typography.titleSmall) + Text("План сессии (тап — выбрать текущим для голоса)", style = MaterialTheme.typography.titleSmall) items.forEach { item -> + val exId = exercises.firstOrNull { it.name.equals(item.exerciseName, ignoreCase = true) }?.id + val isCurrent = exId != null && exId == currentId val sets = item.planSets.joinToString(", ") { "${formatKg(it.weightKg)}×${it.reps}" } - Text("• ${item.exerciseName}: $sets", style = MaterialTheme.typography.bodyMedium) + val label = "• ${item.exerciseName}: $sets" + if (isCurrent) " · текущее" else "" + Text( + label, + style = MaterialTheme.typography.bodyMedium, + color = if (isCurrent) MaterialTheme.colorScheme.primary else Color.Unspecified, + modifier = ( + if (exId != null) { + Modifier + .clickable { onSelect(exId) } + .background( + if (isCurrent) MaterialTheme.colorScheme.primaryContainer + else Color.Transparent, + ) + } else Modifier + ).padding(horizontal = 4.dp, vertical = 2.dp), + ) } } } diff --git a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/voice/ModelStore.kt b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/voice/ModelStore.kt new file mode 100644 index 0000000..c78ecd0 --- /dev/null +++ b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/voice/ModelStore.kt @@ -0,0 +1,114 @@ +package com.watchbody.phone.voice + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.util.zip.ZipInputStream + +/** + * Доставка модели Vosk (решение гейта 1.3 №1, spec 02 §3): zip по URL → + * filesDir/models/vosk-ru. В APK и репозиторий модель не входит; + * распознавание сеть не использует (🛡 офлайн). + */ +class ModelStore( + private val baseDir: File, + private val cacheDir: File, + private val urlProvider: suspend () -> String, + private val client: OkHttpClient = OkHttpClient(), +) { + val modelDir: File get() = File(baseDir, MODEL_DIR_NAME) + + fun isDownloaded(): Boolean = File(modelDir, "am").isDirectory + + fun sizeMb(): Long = + modelDir.walkBottomUp().filter { it.isFile }.sumOf { it.length() } / (1024 * 1024) + + /** + * Загрузка и распаковка; готовая модель — no-op без сети. + * [urlOverride] — адрес из поля настроек до сохранения. + */ + suspend fun download(urlOverride: String? = null, onProgress: (Int) -> Unit = {}): Result = + withContext(Dispatchers.IO) { + runCatching { + if (isDownloaded()) return@runCatching modelDir + val url = urlOverride ?: urlProvider() + val zip = File(cacheDir, "vosk-model.zip") + cacheDir.mkdirs() + val request = Request.Builder().url(url).build() + client.newCall(request).execute().use { resp -> + check(resp.isSuccessful) { "HTTP ${resp.code}" } + val body = resp.body ?: error("пустой ответ") + val total = body.contentLength() + var read = 0L + body.byteStream().use { input -> + FileOutputStream(zip).use { out -> + val buf = ByteArray(64 * 1024) + while (true) { + val n = input.read(buf) + if (n < 0) break + out.write(buf, 0, n) + read += n + if (total > 0) onProgress((read * 100 / total).toInt()) + } + } + } + } + unpack(zip) + } + } + + /** Распаковка zip → modelDir (единственная корневая папка архива срезается). */ + internal fun unpack(zip: File): File { + val tmp = File(cacheDir, "vosk-unpack") + tmp.deleteRecursively() + tmp.mkdirs() + ZipInputStream(FileInputStream(zip)).use { zis -> + var entry = zis.nextEntry + while (entry != null) { + val out = safeFile(tmp, entry.name) + if (out != null) { + if (entry.isDirectory) { + out.mkdirs() + } else { + out.parentFile?.mkdirs() + FileOutputStream(out).use { fos -> zis.copyTo(fos) } + } + } + zis.closeEntry() + entry = zis.nextEntry + } + } + val single = tmp.listFiles()?.let { arr -> + arr.singleOrNull()?.takeIf { it.isDirectory && arr.size == 1 } + } + val src = single ?: tmp + modelDir.deleteRecursively() + baseDir.mkdirs() + if (!src.renameTo(modelDir)) { + src.copyRecursively(modelDir) + src.deleteRecursively() + } + tmp.deleteRecursively() + zip.delete() + check(isDownloaded()) { "архив не содержит модель (нет am/)" } + return modelDir + } + + /** Путь внутри [root]; выход за каталог (zip-slip) → null, запись пропускается. */ + private fun safeFile(root: File, name: String): File? { + if (name.isBlank()) return null + val canonical = File(root, name).canonicalFile + val rootPath = root.canonicalPath + File.separator + return if (canonical.path.startsWith(rootPath)) canonical else null + } + + companion object { + const val MODEL_DIR_NAME = "vosk-ru" + const val DEFAULT_MODEL_URL = + "https://alphacephei.com/vosk/models/vosk-model-small-ru-0.22.zip" + } +} diff --git a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/voice/SttEngine.kt b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/voice/SttEngine.kt new file mode 100644 index 0000000..9a6f229 --- /dev/null +++ b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/voice/SttEngine.kt @@ -0,0 +1,91 @@ +package com.watchbody.phone.voice + +import android.os.Handler +import android.os.Looper +import org.vosk.Model +import org.vosk.Recognizer +import org.vosk.android.SpeechService +import java.io.File + +/** STT-движок (spec 02 §1) — интерфейс для подмены в тестах. */ +interface SttEngine { + /** Финальные гипотезы (до 3) после паузы речи; колбэк в главном потоке. */ + fun startListening(onResult: (List) -> Unit) + fun stopListening() + fun shutdown() +} + +/** + * Vosk (ОВ-2): модель из filesDir, грамматика-список слов Ф-1.2; + * при отказе грамматики (малая модель) — свободный разбор, лишнее + * отсекает парсер (spec 02 §2). Только офлайн, аудио не пишется. + */ +class VoskEngine(modelDir: File) : SttEngine { + + private val main = Handler(Looper.getMainLooper()) + private val model = Model(modelDir.absolutePath) + private var recognizer: Recognizer? = null + private var service: SpeechService? = null + + /** Грамматика применима к модели (диагностика настроек). */ + var grammarApplied: Boolean = false + private set + + init { + recognizer = try { + Recognizer(model, SAMPLE_RATE, grammarJson()).also { + it.setMaxAlternatives(ALTERNATIVES) + grammarApplied = true + } + } catch (e: Throwable) { + Recognizer(model, SAMPLE_RATE).also { it.setMaxAlternatives(ALTERNATIVES) } + } + } + + override fun startListening(onResult: (List) -> Unit) { + if (service != null) return + val rec = recognizer ?: throw IllegalStateException("движок закрыт") + val s = SpeechService(rec, SAMPLE_RATE) + val started = s.startListening(object : org.vosk.android.RecognitionListener { + override fun onPartialResult(partial: String?) {} // работаем по финальным фразам + override fun onResult(result: String?) = dispatch(result, onResult) + override fun onFinalResult(result: String?) {} // приходит при stop() — не команда + override fun onError(exception: Exception?) {} // потеря аудио — ждём следующую фразу + override fun onTimeout() {} + }) + if (!started) { + runCatching { s.shutdown() } + throw IllegalStateException("аудио не запущено (разрешение микрофона?)") + } + service = s + } + + private fun dispatch(result: String?, onResult: (List) -> Unit) { + result ?: return + val hyps = VoskResult.hypotheses(result) + if (hyps.isEmpty()) return + main.post { onResult(hyps) } + } + + override fun stopListening() { + service?.let { runCatching { it.stop(); it.shutdown() } } + service = null + } + + override fun shutdown() { + stopListening() + runCatching { recognizer?.close() } + recognizer = null + runCatching { model.close() } + } + + private fun grammarJson(): String = + (listOf("подход", "вес", "отдых", "на", "секунд", "секунды", "с", "половиной", + "рпэ", "эр", "пэ", "э", "[unk]") + NumberRu.allWords()) + .joinToString(prefix = "[", postfix = "]") { "\"$it\"" } + + companion object { + const val SAMPLE_RATE = 16000f + const val ALTERNATIVES = 3 + } +} diff --git a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/voice/TtsFeedback.kt b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/voice/TtsFeedback.kt new file mode 100644 index 0000000..33d861f --- /dev/null +++ b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/voice/TtsFeedback.kt @@ -0,0 +1,108 @@ +package com.watchbody.phone.voice + +import android.content.Context +import android.media.AudioManager +import android.media.ToneGenerator +import android.os.Handler +import android.os.Looper +import android.speech.tts.TextToSpeech +import java.util.ArrayDeque +import java.util.Locale + +/** Подтверждения Ф-1.4 (spec 04) — интерфейс для тестов исполнителя. */ +interface VoiceFeedback { + /** Щелчок + голосовое TTS-подтверждение принятой команды. */ + fun accepted(text: String) + + /** Короткий сигнал ошибки (TTS молчит, Ф-1.3). */ + fun rejected() + + /** Наличие ru-голоса (асинхронно после инициализации движка). */ + fun ttsAvailable(onResult: (Boolean) -> Unit) +} + +/** + * Системный Android TTS (ru-RU) + ToneGenerator (tech-req 4.1). + * Без русского голоса — деградация: сигналы + текст на экране + * (решение гейта 1.3 №5), работа голоса не блокируется. + */ +class AndroidTtsFeedback(context: Context) : VoiceFeedback { + + private val main = Handler(Looper.getMainLooper()) + private var tones: ToneGenerator? = null + private var tts: TextToSpeech? = null + private var ready = false + private var langOk = false + private val pending = ArrayDeque>() + + init { + main.post { + runCatching { tones = ToneGenerator(AudioManager.STREAM_NOTIFICATION, 80) } + var created: TextToSpeech? = null + created = TextToSpeech(context.applicationContext) { status -> + val inst = created // колбэк может прийти до присвоения поля + if (status == TextToSpeech.SUCCESS && inst != null) { + val res = inst.setLanguage(Locale("ru", "RU")) + langOk = res != TextToSpeech.LANG_MISSING_DATA && res != TextToSpeech.LANG_NOT_SUPPORTED + ready = true + flushPending() + } + } + tts = created + } + } + + override fun accepted(text: String) { + click() + if (ready && langOk) { + speak(text) + } else { + synchronized(pending) { pending.add(text to System.currentTimeMillis()) } + } + } + + override fun rejected() = errorTone() + + override fun ttsAvailable(onResult: (Boolean) -> Unit) { + if (ready) { + onResult(langOk) + } else { + main.postDelayed({ onResult(ready && langOk) }, TTS_WAIT_MS) + } + } + + private fun click() { + runCatching { tones?.startTone(ToneGenerator.TONE_PROP_BEEP, CLICK_MS) } + } + + private fun errorTone() { + runCatching { tones?.startTone(ToneGenerator.TONE_PROP_NACK, ERROR_MS) } + } + + private fun speak(text: String) { + runCatching { + tts?.speak(text, TextToSpeech.QUEUE_FLUSH, null, null) + } + } + + /** Фразы до готовности движка (≤ 2 с) — договорить после инициализации. */ + private fun flushPending() { + val now = System.currentTimeMillis() + while (true) { + val head = synchronized(pending) { pending.peekFirst() } ?: break + if (now - head.second > TTS_WAIT_MS) { + synchronized(pending) { pending.pollFirst() } + continue + } + speak(head.first) + break // QUEUE_FLUSH — только самая свежая + } + synchronized(pending) { pending.clear() } + } + + companion object { + const val CLICK_MS = 120 + const val ERROR_MS = 300 + const val TTS_WAIT_MS = 2_000L + } +} diff --git a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/voice/VoiceCommandExecutor.kt b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/voice/VoiceCommandExecutor.kt new file mode 100644 index 0000000..744d3b9 --- /dev/null +++ b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/voice/VoiceCommandExecutor.kt @@ -0,0 +1,122 @@ +package com.watchbody.phone.voice + +import com.watchbody.phone.core.WorkoutCore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +/** Шлюз к ядру m09 (spec 03 §1) — исполнитель не касается Room/LocalDb (🛡). */ +interface VoiceCoreGateway { + suspend fun logSet(workoutId: String, exerciseId: String?, + weightKg: Double, reps: Int, source: String) + + suspend fun logCommand(workoutId: String, text: String, semantics: JsonObject, accepted: Boolean) + + /** RPE последнего подхода; false — подходов нет. */ + suspend fun setLastSetRpe(workoutId: String, rpe: Int): Boolean +} + +class WorkoutCoreGateway(private val core: WorkoutCore) : VoiceCoreGateway { + override suspend fun logSet( + workoutId: String, + exerciseId: String?, + weightKg: Double, + reps: Int, + source: String, + ) = core.logSet(workoutId, exerciseId, weightKg, reps, source = source) + + override suspend fun logCommand( + workoutId: String, + text: String, + semantics: JsonObject, + accepted: Boolean, + ) = core.logCommand(workoutId, text, semantics.toString(), accepted) + + override suspend fun setLastSetRpe(workoutId: String, rpe: Int) = + core.setLastSetRpe(workoutId, rpe) +} + +/** Эффекты в UI (spec 03 §3) — применяет WorkoutViewModel. */ +sealed interface VoiceEffect { + data class WorkingWeight(val weightKg: Double) : VoiceEffect + data class RestStarted(val seconds: Int) : VoiceEffect + data class Message(val text: String) : VoiceEffect +} + +/** + * Применение распознанных команд (spec 03 §2): журнал каждой команды + * (принятой и отклонённой, 🛡 Ф-1.5), записи только через ядро. + */ +class VoiceCommandExecutor( + private val gateway: VoiceCoreGateway, + private val feedback: VoiceFeedback, + private val effects: (VoiceEffect) -> Unit, + private val syncRequest: suspend () -> Unit, +) { + /** Возвращает команду для строки статуса UI; null — команды не было. */ + suspend fun handle( + workoutId: String, + alternatives: List, + currentExerciseId: String?, + ): VoiceCommand? { + val parsed = withContext(Dispatchers.Default) { VoiceCommandParser.parseWithText(alternatives) } + ?: return null + val (cmd, text) = parsed + when (cmd) { + is VoiceCommand.Approach -> { + val ex = currentExerciseId + if (ex == null) { + reject(workoutId, text, "set", "no_exercise", "Сначала выберите упражнение") + } else { + gateway.logSet(workoutId, ex, cmd.weightKg, cmd.reps, source = "voice") + journal(workoutId, text, VoiceCommandParser.semantics(cmd), accepted = true) + feedback.accepted("Принято, подход ${cmd.weightKg.humanKg()} на ${cmd.reps}") + } + } + is VoiceCommand.Weight -> { + if (currentExerciseId == null) { + reject(workoutId, text, "weight", "no_exercise", "Сначала выберите упражнение") + } else { + effects(VoiceEffect.WorkingWeight(cmd.weightKg)) + journal(workoutId, text, VoiceCommandParser.semantics(cmd), accepted = true) + feedback.accepted("Принято, вес ${cmd.weightKg.humanKg()}") + } + } + is VoiceCommand.Rest -> { + effects(VoiceEffect.RestStarted(cmd.seconds)) + journal(workoutId, text, VoiceCommandParser.semantics(cmd), accepted = true) + feedback.accepted("Принято, отдых ${cmd.seconds} секунд") + } + is VoiceCommand.Rpe -> { + if (!gateway.setLastSetRpe(workoutId, cmd.rpe)) { + reject(workoutId, text, "rpe", "no_set", "Подходов ещё нет") + } else { + journal(workoutId, text, VoiceCommandParser.semantics(cmd), accepted = true) + feedback.accepted("Принято, эр пэ э ${cmd.rpe}") + } + } + is VoiceCommand.Rejected -> { + journal(workoutId, text, VoiceCommandParser.semantics(cmd), accepted = false) + feedback.rejected() // короткий сигнал, TTS молчит (Ф-1.3) + } + } + syncRequest() // журнал/подходы ушли в sync_pending — разбудить воркер + return cmd + } + + private suspend fun reject(workoutId: String, text: String, cmd: String, reason: String, msg: String) { + journal( + workoutId, text, + buildJsonObject { put("cmd", cmd); put("reason", reason) }, + accepted = false, + ) + feedback.rejected() + effects(VoiceEffect.Message(msg)) + } + + private suspend fun journal(workoutId: String, text: String, semantics: JsonObject, accepted: Boolean) { + gateway.logCommand(workoutId, text, semantics, accepted) + } +} diff --git a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/voice/VoiceCommandParser.kt b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/voice/VoiceCommandParser.kt new file mode 100644 index 0000000..ba6e15d --- /dev/null +++ b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/voice/VoiceCommandParser.kt @@ -0,0 +1,225 @@ +package com.watchbody.phone.voice + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.put + +/** + * Грамматика голосовых команд Ф-1.2 (spec 01): чистая функция, + * без Android-зависимостей. Числа — словами 0–999 и цифрами; + * «с половиной» допустимо только у веса (О-2-Б → +0,5 кг). + */ +sealed interface VoiceCommand { + data class Approach(val weightKg: Double, val reps: Int) : VoiceCommand + data class Weight(val weightKg: Double) : VoiceCommand + data class Rest(val seconds: Int) : VoiceCommand + data class Rpe(val rpe: Int) : VoiceCommand + + /** Отклонение Ф-1.3: вне грамматики или число вне диапазона. */ + data class Rejected(val reason: String, val attempted: JsonObject?) : VoiceCommand +} + +/** Числительные 0–999 (spec 01 §3). */ +object NumberRu { + /** слово → (значение, ранг: 1 единицы, 10 десятки, 100 сотни). */ + private val words: Map> = buildMap { + fun add(w: String, v: Int, r: Int) { put(w, v to r) } + add("ноль", 0, 1) + for ((w, v) in listOf( + "один" to 1, "одна" to 1, "одно" to 1, "два" to 2, "две" to 2, + "три" to 3, "четыре" to 4, "пять" to 5, "шесть" to 6, "семь" to 7, + "восемь" to 8, "девять" to 9, "десять" to 10, "одиннадцать" to 11, + "двенадцать" to 12, "тринадцать" to 13, "четырнадцать" to 14, + "пятнадцать" to 15, "шестнадцать" to 16, "семнадцать" to 17, + "восемнадцать" to 18, "девятнадцать" to 19)) add(w, v, 1) + for ((w, v) in listOf( + "двадцать" to 20, "тридцать" to 30, "сорок" to 40, "пятьдесят" to 50, + "шестьдесят" to 60, "семьдесят" to 70, "восемьдесят" to 80, + "девяносто" to 90)) add(w, v, 10) + for ((w, v) in listOf( + "сто" to 100, "двести" to 200, "триста" to 300, "четыреста" to 400, + "пятьсот" to 500, "шестьсот" to 600, "семьсот" to 700, + "восемьсот" to 800, "девятьсот" to 900)) add(w, v, 100) + } + + /** Слова для грамматики Vosk (spec 02 §2). */ + fun allWords(): List = words.keys.toList() + + data class Num(val value: Int, val half: Boolean, val size: Int) + + /** Число с позиции i: цифры («120», «120.5») или [сотни][десятки][единицы] (+ «с половиной»). */ + fun parseAt(tokens: List, i: Int): Num? { + val t = tokens.getOrNull(i) ?: return null + if (t.all { it.isDigit() || it == '.' }) { + val m = Regex("""^(\d{1,3})(\.5)?$""").matchEntire(t) ?: return null + return Num(m.groupValues[1].toInt(), m.groupValues[2].isNotEmpty(), 1) + } + var idx = i + var lastRank = Int.MAX_VALUE + var value = 0 + var count = 0 + while (idx < tokens.size && count < 3) { + val e = words[tokens[idx]] ?: break + if (e.second >= lastRank) return null // «двадцать сто» — нарушение порядка + lastRank = e.second + value += e.first + count++ + idx++ + } + if (count == 0) return null + var half = false + if (tokens.getOrNull(idx) == "с" && tokens.getOrNull(idx + 1) == "половиной") { + half = true + idx += 2 + } + return Num(value, half, idx - i) + } +} + +object VoiceCommandParser { + + const val MAX_WEIGHT = 500.0 + const val MAX_REPS = 100 + const val MIN_REST = 5 + const val MAX_REST = 600 + const val MAX_RPE = 10 + + /** Первая успешно разобранная команда; null — команды не было (журнал не пишется). */ + fun parse(alternatives: List): VoiceCommand? = parseWithText(alternatives)?.first + + /** Команда + исходная гипотеза (recognized_text журнала, Ф-1.5). */ + fun parseWithText(alternatives: List): Pair? { + var firstRejected: Pair? = null + for (hyp in alternatives) { + val cmd = parseHypothesis(hyp) ?: continue + if (cmd !is VoiceCommand.Rejected) return cmd to hyp + if (firstRejected == null) firstRejected = cmd to hyp + } + return firstRejected + } + + /** Семантика для журнала (spec 01 §5, контракт m09 spec 04). */ + fun semantics(cmd: VoiceCommand): JsonObject = when (cmd) { + is VoiceCommand.Approach -> buildJsonObject { + put("cmd", "set"); put("weight_kg", cmd.weightKg); put("reps", cmd.reps) + } + is VoiceCommand.Weight -> buildJsonObject { + put("cmd", "weight"); put("weight_kg", cmd.weightKg) + } + is VoiceCommand.Rest -> buildJsonObject { + put("cmd", "rest"); put("sec", cmd.seconds) + } + is VoiceCommand.Rpe -> buildJsonObject { + put("cmd", "rpe"); put("rpe", cmd.rpe) + } + is VoiceCommand.Rejected -> buildJsonObject { + put("cmd", "rejected"); put("reason", cmd.reason) + cmd.attempted?.forEach { (k, v) -> + val p = v as? JsonPrimitive + when { + p == null -> {} + p.doubleOrNull != null -> put(k, p.doubleOrNull!!) + p.intOrNull != null -> put(k, p.intOrNull!!) + else -> put(k, p.content) + } + } + } + } + + private fun parseHypothesis(text: String): VoiceCommand? { + val tokens = tokenize(text) + if (tokens.isEmpty()) return null + return when (tokens[0]) { + "подход" -> approach(tokens) + "вес" -> weight(tokens) + "отдых" -> rest(tokens) + "рпэ" -> rpe(tokens) + else -> VoiceCommand.Rejected("out_of_grammar", null) + } + } + + private fun approach(t: List): VoiceCommand { + val w = NumberRu.parseAt(t, 1) ?: return grammar() + if (t.getOrNull(1 + w.size) != "на") return grammar() + val r = NumberRu.parseAt(t, 2 + w.size) ?: return grammar() + if (t.size != 2 + w.size + r.size) return grammar() // подход W на R — без лишнего + if (r.half) return grammar() // «с половиной» — только у веса + val kg = w.value + if (w.half) 0.5 else 0.0 + if (kg < 0 || kg > MAX_WEIGHT || r.value !in 1..MAX_REPS) { + return VoiceCommand.Rejected( + "out_of_range", + attempted(wWeight = kg, reps = r.value), + ) + } + return VoiceCommand.Approach(kg, r.value) + } + + private fun weight(t: List): VoiceCommand { + val w = NumberRu.parseAt(t, 1) ?: return grammar() + if (t.size != 1 + w.size) return grammar() + val kg = w.value + if (w.half) 0.5 else 0.0 + if (kg < 0 || kg > MAX_WEIGHT) { + return VoiceCommand.Rejected("out_of_range", attempted(wWeight = kg)) + } + return VoiceCommand.Weight(kg) + } + + private fun rest(t: List): VoiceCommand { + val s = NumberRu.parseAt(t, 1) ?: return grammar() + var end = 1 + s.size + if (t.getOrNull(end) == "секунд" || t.getOrNull(end) == "секунды") end++ + if (t.size != end) return grammar() + if (s.half) return grammar() + if (s.value !in MIN_REST..MAX_REST) { + return VoiceCommand.Rejected("out_of_range", attempted(sec = s.value)) + } + return VoiceCommand.Rest(s.value) + } + + private fun rpe(t: List): VoiceCommand { + val n = NumberRu.parseAt(t, 1) ?: return grammar() + if (t.size != 1 + n.size || n.half) return grammar() + if (n.value !in 1..MAX_RPE) { + return VoiceCommand.Rejected("out_of_range", attempted(rpe = n.value)) + } + return VoiceCommand.Rpe(n.value) + } + + private fun grammar() = VoiceCommand.Rejected("out_of_grammar", null) + + private fun attempted(wWeight: Double? = null, reps: Int? = null, sec: Int? = null, rpe: Int? = null): JsonObject = + buildJsonObject { + wWeight?.let { put("weight_kg", it) } + reps?.let { put("reps", it) } + sec?.let { put("sec", it) } + rpe?.let { put("rpe", it) } + } + + /** Предобработка (spec 01 §2): регистр, ё, пунктуация, «эр пэ э» → «рпэ». */ + internal fun tokenize(text: String): List { + var s = text.lowercase().replace("ё", "е") + s = Regex("""(\d)[.,](\d)""").replace(s) { m -> "${m.groupValues[1]}.${m.groupValues[2]}" } + s = s.replace(Regex("""[^0-9a-zа-я. ]"""), " ") + val raw = s.split(Regex("\\s+")) + .map { it.trim('.') } + .filter { it.isNotEmpty() } + // буквы аббревиатуры по отдельности → одно слово + val out = ArrayList(raw.size) + var i = 0 + while (i < raw.size) { + if (raw[i] == "эр" && raw.getOrNull(i + 1) == "пэ" && raw.getOrNull(i + 2) == "э") { + out.add("рпэ"); i += 3 + } else { + out.add(if (raw[i] == "эрпэ") "рпэ" else raw[i]); i++ + } + } + return out + } +} + +/** «62.5» → «62,5», «120.0» → «120» — для фраз и строк статуса. */ +fun Double.humanKg(): String = + if (this % 1.0 == 0.0) toInt().toString() else toString().replace('.', ',') diff --git a/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/voice/VoskResult.kt b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/voice/VoskResult.kt new file mode 100644 index 0000000..b4cd777 --- /dev/null +++ b/3-app/4-phone/Phone.WatchBody/app/src/main/java/com/watchbody/phone/voice/VoskResult.kt @@ -0,0 +1,20 @@ +package com.watchbody.phone.voice + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.contentOrNull + +/** Разбор финального JSON Vosk → список гипотез (spec 02 §2, чистая функция). */ +object VoskResult { + + fun hypotheses(json: String): List = runCatching { + val obj = Json.parseToJsonElement(json).jsonObject + obj["alternatives"]?.jsonArray + ?.mapNotNull { it.jsonObject["text"]?.jsonPrimitive?.contentOrNull?.trim() } + ?.filter { it.isNotEmpty() } + ?.takeIf { it.isNotEmpty() } + ?: listOfNotNull(obj["text"]?.jsonPrimitive?.contentOrNull?.trim()?.takeIf { it.isNotEmpty() }) + }.getOrDefault(emptyList()) +} diff --git a/3-app/4-phone/Phone.WatchBody/app/src/test/java/com/watchbody/phone/ModelStoreTest.kt b/3-app/4-phone/Phone.WatchBody/app/src/test/java/com/watchbody/phone/ModelStoreTest.kt new file mode 100644 index 0000000..c5b8e24 --- /dev/null +++ b/3-app/4-phone/Phone.WatchBody/app/src/test/java/com/watchbody/phone/ModelStoreTest.kt @@ -0,0 +1,139 @@ +package com.watchbody.phone + +import com.watchbody.phone.core.SettingsValidator +import com.watchbody.phone.settings.AppSettings +import com.watchbody.phone.voice.ModelStore +import kotlinx.coroutines.runBlocking +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okio.Buffer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream + +/** Доставка модели Vosk (m11 spec 02 §3) и валидация Voice.ModelUrl (spec 05 §5). */ +class ModelStoreTest { + + @get:Rule + val tmp = TemporaryFolder() + + private fun store(url: String = "http://127.0.0.1:9/none.zip"): ModelStore = + ModelStore(baseDir = tmp.newFolder(), cacheDir = tmp.newFolder(), urlProvider = { url }) + + private fun zip(entries: Map, name: String = "model.zip"): File { + val f = File(tmp.root, name) + ZipOutputStream(FileOutputStream(f)).use { zos -> + entries.forEach { (path, content) -> + zos.putNextEntry(ZipEntry(path)) + zos.write(content.toByteArray()) + zos.closeEntry() + } + } + return f + } + + private val modelEntries = mapOf( + "vosk-model-small-ru-0.22/am/final.al" to "model", + "vosk-model-small-ru-0.22/graph/HCLG.fst" to "graph", + "vosk-model-small-ru-0.22/README" to "readme", + ) + + @Test + fun `распаковка срезает корневую папку архива`() { + val s = store() + assertFalse(s.isDownloaded()) + s.unpack(zip(modelEntries)) + assertTrue(s.isDownloaded()) + assertTrue(File(s.modelDir, "am/final.al").isFile) + assertTrue(File(s.modelDir, "graph/HCLG.fst").isFile) + } + + @Test + fun `zip-slip записи отбрасываются`() { + val s = store() + // выход за каталог спрятан внутри корневой папки архива — структура не ломается + val withEvil = modelEntries + ("vosk-model-small-ru-0.22/inner/../../../evil.txt" to "evil") + s.unpack(zip(withEvil)) + assertFalse(File(tmp.root.parentFile, "evil.txt").exists()) + assertFalse(File(tmp.root, "evil.txt").exists()) + assertTrue(s.isDownloaded()) + } + + @Test + fun `битый zip - ошибка`() { + val s = store() + val bad = File(tmp.root, "bad.zip") + bad.writeBytes(byteArrayOf(1, 2, 3, 4, 5)) + val result = runCatching { s.unpack(bad) } + assertTrue(result.isFailure) + assertFalse(s.isDownloaded()) + } + + @Test + fun `архив без модели - ошибка`() { + val s = store() + val noModel = mapOf("something/else.txt" to "x") + val result = runCatching { s.unpack(zip(noModel)) } + assertTrue(result.isFailure) + } + + @Test + fun `загрузка по URL и прогресс`() { + val server = MockWebServer() + server.start() + val zipFile = zip(modelEntries, "srv.zip") + val body = Buffer().apply { write(zipFile.readBytes()) } + server.enqueue( + MockResponse() + .setBody(body) + .addHeader("Content-Length", zipFile.length()), + ) + val s = store(server.url("/model.zip").toString()) + val progress = mutableListOf() + val result = runBlocking { s.download { progress.add(it) } } + assertNotNull(result.getOrNull()) + assertTrue(s.isDownloaded()) + assertEquals(100, progress.last()) + server.shutdown() + } + + @Test + fun `готовая модель - no-op без сети`() { + val s = store("http://127.0.0.1:9/unreachable.zip") + s.unpack(zip(modelEntries)) + val result = runBlocking { s.download() } + assertTrue(result.isSuccess) + } + + @Test + fun `http ошибка - failure`() { + val server = MockWebServer() + server.start() + server.enqueue(MockResponse().setResponseCode(404)) + val s = store(server.url("/missing.zip").toString()) + val result = runBlocking { s.download() } + assertTrue(result.isFailure) + server.shutdown() + } + + @Test + fun `валидация Voice_ModelUrl`() { + fun err(url: String): String? = + SettingsValidator.validate(AppSettings(baseUrl = "https://s.ru", apiKey = "k", voiceModelUrl = url)) + .errors["voiceModelUrl"] + + assertNotNull(err("ftp://x/file.zip")) + assertNotNull(err("не адрес")) + assertNotNull(err("")) + assertEquals("нужен абсолютный https://адрес", err("http://srv/file.zip")) + assertEquals(null, err("https://alphacephei.com/vosk/models/vosk-model-small-ru-0.22.zip")) + } +} diff --git a/3-app/4-phone/Phone.WatchBody/app/src/test/java/com/watchbody/phone/VoiceExecutorTest.kt b/3-app/4-phone/Phone.WatchBody/app/src/test/java/com/watchbody/phone/VoiceExecutorTest.kt new file mode 100644 index 0000000..6988015 --- /dev/null +++ b/3-app/4-phone/Phone.WatchBody/app/src/test/java/com/watchbody/phone/VoiceExecutorTest.kt @@ -0,0 +1,181 @@ +package com.watchbody.phone + +import com.watchbody.phone.voice.VoiceCommand +import com.watchbody.phone.voice.VoiceCommandExecutor +import com.watchbody.phone.voice.VoiceCoreGateway +import com.watchbody.phone.voice.VoiceEffect +import com.watchbody.phone.voice.VoiceFeedback +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Исполнитель команд (m11 spec 03 §6) на фейках шлюза/обратной связи. */ +class VoiceExecutorTest { + + private class FakeGateway : VoiceCoreGateway { + data class Set(val workoutId: String, val exerciseId: String?, val weightKg: Double, val reps: Int, val source: String) + data class Command(val workoutId: String, val text: String, val semantics: JsonObject, val accepted: Boolean) + + val sets = mutableListOf() + val commands = mutableListOf() + val rpeCalls = mutableListOf>() + var rpeResult = true + + override suspend fun logSet( + workoutId: String, exerciseId: String?, weightKg: Double, reps: Int, source: String, + ) { + sets.add(Set(workoutId, exerciseId, weightKg, reps, source)) + } + + override suspend fun logCommand( + workoutId: String, text: String, semantics: JsonObject, accepted: Boolean, + ) { + commands.add(Command(workoutId, text, semantics, accepted)) + } + + override suspend fun setLastSetRpe(workoutId: String, rpe: Int): Boolean { + rpeCalls.add(workoutId to rpe) + return rpeResult + } + } + + private class FakeFeedback : VoiceFeedback { + val spoken = mutableListOf() + var rejectedCount = 0 + override fun accepted(text: String) { spoken.add(text) } + override fun rejected() { rejectedCount++ } + override fun ttsAvailable(onResult: (Boolean) -> Unit) = onResult(true) + } + + private lateinit var gateway: FakeGateway + private lateinit var feedback: FakeFeedback + private val effects = mutableListOf() + private var syncCount = 0 + + private fun executor() = VoiceCommandExecutor( + gateway = gateway, + feedback = feedback, + effects = { effects.add(it) }, + syncRequest = { syncCount++ }, + ) + + private suspend fun run(vararg alternatives: String, exerciseId: String? = "ex-1") = + executor().handle("w-1", alternatives.toList(), exerciseId) + + @Test + fun `подход пишется source=voice и журналируется`() = runTest { + gateway = FakeGateway(); feedback = FakeFeedback() + val cmd = run("подход сто двадцать на восемь") + assertEquals(VoiceCommand.Approach(120.0, 8), cmd) + assertEquals(1, gateway.sets.size) + assertEquals("voice", gateway.sets[0].source) + assertEquals("ex-1", gateway.sets[0].exerciseId) + assertEquals(120.0, gateway.sets[0].weightKg, 0.0) + assertEquals(8, gateway.sets[0].reps) + assertEquals(1, gateway.commands.size) + assertTrue(gateway.commands[0].accepted) + assertEquals("""{"cmd":"set","weight_kg":120.0,"reps":8}""", gateway.commands[0].semantics.toString()) + assertEquals("подход сто двадцать на восемь", gateway.commands[0].text) + assertEquals(listOf("Принято, подход 120 на 8"), feedback.spoken) + assertEquals(1, syncCount) + } + + @Test + fun `вес даёт эффект рабочего веса`() = runTest { + gateway = FakeGateway(); feedback = FakeFeedback() + val cmd = run("вес шестьдесят два с половиной") + assertEquals(VoiceCommand.Weight(62.5), cmd) + assertEquals(0, gateway.sets.size) + assertEquals(VoiceEffect.WorkingWeight(62.5), effects.single()) + assertTrue(gateway.commands[0].accepted) + assertEquals(listOf("Принято, вес 62,5"), feedback.spoken) + } + + @Test + fun `отдых запускает таймер`() = runTest { + gateway = FakeGateway(); feedback = FakeFeedback() + val cmd = run("отдых девяносто секунд") + assertEquals(VoiceCommand.Rest(90), cmd) + assertEquals(VoiceEffect.RestStarted(90), effects.single()) + assertEquals(listOf("Принято, отдых 90 секунд"), feedback.spoken) + } + + @Test + fun `рпэ ставится последнему подходу`() = runTest { + gateway = FakeGateway(); feedback = FakeFeedback() + val cmd = run("рпэ семь") + assertEquals(VoiceCommand.Rpe(7), cmd) + assertEquals(listOf("w-1" to 7), gateway.rpeCalls) + assertEquals(listOf("Принято, эр пэ э 7"), feedback.spoken) + } + + @Test + fun `рпэ без подходов отклоняется`() = runTest { + gateway = FakeGateway().apply { rpeResult = false }; feedback = FakeFeedback() + val cmd = run("рпэ семь") + assertEquals(VoiceCommand.Rpe(7), cmd) // команда распознана, но не применена + assertEquals(1, gateway.commands.size) + assertEquals(false, gateway.commands[0].accepted) + assertEquals("""{"cmd":"rpe","reason":"no_set"}""", gateway.commands[0].semantics.toString()) + assertEquals(1, feedback.rejectedCount) + assertEquals(VoiceEffect.Message("Подходов ещё нет"), effects.single()) + } + + @Test + fun `подход без выбранного упражнения отклоняется`() = runTest { + gateway = FakeGateway(); feedback = FakeFeedback() + val cmd = run("подход сто на восемь", exerciseId = null) + assertEquals(0, gateway.sets.size) + assertEquals(false, gateway.commands[0].accepted) + assertEquals("""{"cmd":"set","reason":"no_exercise"}""", gateway.commands[0].semantics.toString()) + assertEquals(VoiceEffect.Message("Сначала выберите упражнение"), effects.single()) + assertEquals(1, feedback.rejectedCount) + } + + @Test + fun `мусор журналируется отклонением без записи`() = runTest { + gateway = FakeGateway(); feedback = FakeFeedback() + val cmd = run("погнали") + assertTrue(cmd is VoiceCommand.Rejected) + assertEquals(0, gateway.sets.size) + assertEquals(1, gateway.commands.size) + assertEquals(false, gateway.commands[0].accepted) + assertEquals("погнали", gateway.commands[0].text) + assertEquals(1, feedback.rejectedCount) + assertEquals(0, feedback.spoken.size) + assertEquals(1, syncCount) // журнал тоже уходит в sync + } + + @Test + fun `число вне диапазона журналируется с попыткой`() = runTest { + gateway = FakeGateway(); feedback = FakeFeedback() + val cmd = run("вес пятьсот с половиной") + assertTrue(cmd is VoiceCommand.Rejected) + assertEquals( + """{"cmd":"rejected","reason":"out_of_range","weight_kg":500.5}""", + gateway.commands[0].semantics.toString(), + ) + } + + @Test + fun `не команда - ничего не происходит`() = runTest { + gateway = FakeGateway(); feedback = FakeFeedback() + assertNull(run("")) + assertNull(run(" ")) + assertEquals(0, gateway.commands.size) + assertEquals(0, gateway.sets.size) + assertEquals(0, feedback.rejectedCount) + assertEquals(0, syncCount) + } + + @Test + fun `выбирается первая распознанная гипотеза`() = runTest { + gateway = FakeGateway(); feedback = FakeFeedback() + val cmd = run("погнали", "вес сто") + assertEquals(VoiceCommand.Weight(100.0), cmd) + assertEquals("вес сто", gateway.commands[0].text) + } +} diff --git a/3-app/4-phone/Phone.WatchBody/app/src/test/java/com/watchbody/phone/VoiceParserTest.kt b/3-app/4-phone/Phone.WatchBody/app/src/test/java/com/watchbody/phone/VoiceParserTest.kt new file mode 100644 index 0000000..44b1e29 --- /dev/null +++ b/3-app/4-phone/Phone.WatchBody/app/src/test/java/com/watchbody/phone/VoiceParserTest.kt @@ -0,0 +1,138 @@ +package com.watchbody.phone + +import com.watchbody.phone.voice.NumberRu +import com.watchbody.phone.voice.VoiceCommand +import com.watchbody.phone.voice.VoiceCommandParser +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Грамматика Ф-1.2 (m11 spec 01 §6). */ +class VoiceParserTest { + + private fun parse(text: String): VoiceCommand? = VoiceCommandParser.parse(listOf(text)) + + @Test + fun `команды числами словами`() { + assertEquals(VoiceCommand.Approach(120.0, 8), parse("подход сто двадцать на восемь")) + assertEquals(VoiceCommand.Weight(62.5), parse("вес шестьдесят два с половиной")) + assertEquals(VoiceCommand.Rest(90), parse("отдых девяносто секунд")) + assertEquals(VoiceCommand.Rest(90), parse("отдых девяносто секунды")) + assertEquals(VoiceCommand.Rpe(7), parse("рпэ семь")) + assertEquals(VoiceCommand.Rpe(7), parse("эр пэ э семь")) // буквы по отдельности + assertEquals(VoiceCommand.Rpe(7), parse("эрпэ семь")) + } + + @Test + fun `команды цифрами`() { + assertEquals(VoiceCommand.Approach(120.0, 8), parse("подход 120 на 8")) + assertEquals(VoiceCommand.Weight(62.5), parse("вес 62,5")) + assertEquals(VoiceCommand.Weight(120.5), parse("вес 120.5")) + assertEquals(VoiceCommand.Rest(90), parse("отдых 90")) + assertEquals(VoiceCommand.Rest(5), parse("отдых 5 секунд")) + } + + @Test + fun `границы диапазонов`() { + assertEquals(VoiceCommand.Weight(0.0), parse("вес ноль")) + assertEquals(VoiceCommand.Weight(500.0), parse("вес пятьсот")) + assertEquals(VoiceCommand.Approach(0.0, 1), parse("подход ноль на один")) + assertEquals(VoiceCommand.Approach(500.0, 100), parse("подход пятьсот на сто")) + assertEquals(VoiceCommand.Rest(600), parse("отдых шестьсот")) + + assertEquals(range("weight_kg" to 500.5), parse("вес пятьсот с половиной")) + assertEquals(range("weight_kg" to 100.0, "reps" to 0), parse("подход сто на ноль")) + assertEquals(range("weight_kg" to 100.0, "reps" to 101), parse("подход сто на сто один")) + assertEquals(range("sec" to 4), parse("отдых четыре")) + assertEquals(range("sec" to 601), parse("отдых шестьсот один")) + assertEquals(range("rpe" to 0), parse("рпэ ноль")) + assertEquals(range("rpe" to 11), parse("рпэ одиннадцать")) + } + + @Test + fun `вне грамматики`() { + grammar(parse("погнали")) + grammar(parse("подход сто двадцать")) // нет «на» + grammar(parse("отдых тридцать с половиной")) // «с половиной» не у веса + grammar(parse("подход двадцать сто на пять")) // порядок разрядов + grammar(parse("подход 120 на 8 отдых")) // лишнее слово + grammar(parse("вес")) // числа нет + grammar(parse("рпэ семь восемь")) // два числа + } + + @Test + fun `числительные NumberRu через команды`() { + assertEquals(VoiceCommand.Weight(222.0), parse("вес двести двадцать два")) + assertEquals(range("weight_kg" to 999.0), parse("вес девятьсот девяносто девять")) + assertEquals(VoiceCommand.Weight(100.0), parse("вес сто")) + assertEquals(VoiceCommand.Weight(2.0), parse("вес два")) + assertEquals(VoiceCommand.Weight(2.0), parse("вес две")) + assertEquals(VoiceCommand.Approach(120.0, 8), parse("Подход 120, на 8!")) // регистр/пунктуация + assertEquals(VoiceCommand.Weight(0.5), parse("вес ноль с половиной")) + } + + @Test + fun `альтернативы`() { + assertEquals(VoiceCommand.Weight(100.0), VoiceCommandParser.parse(listOf("погнали", "вес сто"))) + val rejected = VoiceCommandParser.parse(listOf("погнали", "ещё мусор")) + assertTrue(rejected is VoiceCommand.Rejected) + assertNull(VoiceCommandParser.parse(listOf(""))) + assertNull(VoiceCommandParser.parse(emptyList())) + assertNull(VoiceCommandParser.parse(listOf(" "))) + } + + @Test + fun `семантика журнала`() { + val set = VoiceCommandParser.semantics(VoiceCommand.Approach(120.0, 8)) + assertEquals("""{"cmd":"set","weight_kg":120.0,"reps":8}""", set.toString()) + assertEquals( + """{"cmd":"weight","weight_kg":62.5}""", + VoiceCommandParser.semantics(VoiceCommand.Weight(62.5)).toString(), + ) + assertEquals( + """{"cmd":"rest","sec":90}""", + VoiceCommandParser.semantics(VoiceCommand.Rest(90)).toString(), + ) + assertEquals( + """{"cmd":"rpe","rpe":7}""", + VoiceCommandParser.semantics(VoiceCommand.Rpe(7)).toString(), + ) + } + + @Test + fun `семантика отклонения с числами`() { + val cmd = parse("вес пятьсот с половиной") as VoiceCommand.Rejected + assertEquals("out_of_range", cmd.reason) + val s = VoiceCommandParser.semantics(cmd) + assertEquals("""{"cmd":"rejected","reason":"out_of_range","weight_kg":500.5}""", s.toString()) + } + + @Test + fun `parseAt цифры и слова`() { + assertEquals(NumberRu.Num(120, false, 1), NumberRu.parseAt(listOf("120", "на"), 0)) + assertEquals(NumberRu.Num(62, true, 1), NumberRu.parseAt(listOf("62.5"), 0)) + assertEquals(NumberRu.Num(62, true, 4), NumberRu.parseAt(listOf("вес", "шестьдесят", "два", "с", "половиной"), 1)) + assertNull(NumberRu.parseAt(listOf("на"), 0)) + assertNull(NumberRu.parseAt(listOf("сто", "сто"), 0)) // повтор ранга + } + + private fun range(vararg fields: Pair): VoiceCommand.Rejected { + val attempted = buildJsonObject { + fields.forEach { (k, v) -> + when (v) { + is Double -> put(k, v) + is Int -> put(k, v) + } + } + } + return VoiceCommand.Rejected("out_of_range", attempted) + } + + private fun grammar(cmd: VoiceCommand?) { + assertTrue("ожидалось отклонение out_of_grammar: $cmd", cmd is VoiceCommand.Rejected) + assertEquals("out_of_grammar", (cmd as VoiceCommand.Rejected).reason) + } +}