m11-voice-input (итерация 5): голосовой офлайн-учёт Ф-1 — Vosk-STT (грамматика-список слов + fallback на свободный разбор, n-best 3), парсер Ф-1.2 (NumberRu 0–999 словами/цифрами, «с половиной» → +0,5 кг только у веса, «эр пэ э»→«рпэ», Rejected out_of_grammar/out_of_range), executor через WorkoutCore 🛡 + журнал каждой команды (Ф-1.5), TTS-подтверждения + сигналы BEEP/NACK, Mic-кнопка (RECORD_AUDIO runtime, диалог загрузки модели ~44 МБ alphacephei → filesDir, не в APK/репо), «текущее упражнение» тапом в плане, блок «Распознавание речи» в настройках (URL/статус/загрузка/Проверить TTS); расширения: WorkoutCore.setLastSetRpe + DAO lastSet (m09), AppSettings.voiceModelUrl + https-валидатор (m10); unit 63/63 (m11 — 27), APK 57,3 МБ (vosk JNI ×4 ABI); errata vosk-android 0.3.47 (stop/shutdown, onResult/onFinalResult, TONE_PROP_NACK); распознавание на устройстве — приёмка владельца, КУ-1 вручную

This commit is contained in:
virus-home 2026-10-19 10:37:51 +05:00
parent e4be7ceb31
commit 28445f28b9
27 changed files with 2260 additions and 8 deletions

View File

@ -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-смок пройден) | | 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) | | 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; запуск на устройстве — приёмка владельца) | | 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 | ⬜ | | m12-watch-channel | `WatchChannel`: companion-канал Connect IQ (прототип ОВ-3 — итерация 1, чек-лист 7.1) | 5 | ⬜ |
| m13-fenix-data-field | `FenixDataField` (Monkey C, таргет `fenix6x`): пульс/зоны/отдых | 5 | ⬜ | | m13-fenix-data-field | `FenixDataField` (Monkey C, таргет `fenix6x`): пульс/зоны/отдых | 5 | ⬜ |

View File

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

View File

@ -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<String>): 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 из первой.

View File

@ -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<String>) -> 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<File>
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).

View File

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

View File

@ -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-голоса.

View File

@ -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`: `<uses-permission
android:name="android.permission.RECORD_AUDIO"/>` — runtime
(dangerous), запрос только из кнопки микрофона (не при старте
приложения);
- интернет для загрузки модели — существующее разрешение m09
(SyncWorker); распознавание сеть не использует (🛡 офлайн).
## 7. Тесты
- unit (pure JVM): ViewModel-логика эффектов (WorkingWeight →
состояние веса, RestStarted → секунды, Message → строка
статуса) — где выделено в чистые функции; валидация
`Voice.ModelUrl` (паттерн SettingsValidator m10);
- ручной смок — критерии 2/3 module_reqs (полный голосовой
сценарий + синхронизация command_log).

View File

@ -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); автоматизации не подлежит, фиксируется
в журнале приёмки.

View File

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

View File

@ -58,12 +58,17 @@ dependencies {
implementation(composeBom) implementation(composeBom)
implementation("androidx.compose.ui:ui") implementation("androidx.compose.ui:ui")
implementation("androidx.compose.material3:material3") 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.activity:activity-compose:1.9.3")
implementation("androidx.navigation:navigation-compose:2.8.5") implementation("androidx.navigation:navigation-compose:2.8.5")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.compose.ui:ui-tooling-preview") 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("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.1") testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.1")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
} }

View File

@ -2,6 +2,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- голос m11: runtime-запрос из кнопки микрофона (spec 05 §6) -->
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application <application
android:name="com.watchbody.phone.WatchBodyApp" android:name="com.watchbody.phone.WatchBodyApp"
android:label="Феникс 6X" android:label="Феникс 6X"

View File

@ -5,6 +5,9 @@ import com.watchbody.phone.core.WorkoutCore
import com.watchbody.phone.db.AppDatabase import com.watchbody.phone.db.AppDatabase
import com.watchbody.phone.settings.SettingsRepository import com.watchbody.phone.settings.SettingsRepository
import com.watchbody.phone.sync.ApiClient import com.watchbody.phone.sync.ApiClient
import com.watchbody.phone.voice.AndroidTtsFeedback
import com.watchbody.phone.voice.ModelStore
import java.io.File
/** Ручной DI-контейнер (v1 без Hilt, spec 01 §1). */ /** Ручной DI-контейнер (v1 без Hilt, spec 01 §1). */
class AppContainer(context: android.content.Context) { class AppContainer(context: android.content.Context) {
@ -13,6 +16,16 @@ class AppContainer(context: android.content.Context) {
val settings = SettingsRepository(appContext) val settings = SettingsRepository(appContext)
val core: WorkoutCore by lazy { WorkoutCore(db) } val core: WorkoutCore by lazy { WorkoutCore(db) }
/** m11: модель Vosk в filesDir (не в APK/репо) + системный TTS. */
val modelStore: ModelStore by lazy {
ModelStore(
baseDir = File(appContext.filesDir, "models"),
cacheDir = File(appContext.cacheDir, "models"),
urlProvider = { settings.get().voiceModelUrl },
)
}
val ttsFeedback: AndroidTtsFeedback by lazy { AndroidTtsFeedback(appContext) }
/** Клиент по актуальным настройкам — создаётся на каждое обращение. */ /** Клиент по актуальным настройкам — создаётся на каждое обращение. */
suspend fun api(): com.watchbody.phone.sync.WatchBodyApi { suspend fun api(): com.watchbody.phone.sync.WatchBodyApi {
val s = settings.get() val s = settings.get()

View File

@ -51,6 +51,15 @@ object SettingsValidator {
errors["hrStrengthMax"] = "порог силовой 100–180" errors["hrStrengthMax"] = "порог силовой 100–180"
} }
// m11: URL модели Vosk — абсолютный https (spec 05 §5)
val vurl = runCatching { URI(s.voiceModelUrl.trim()) }.getOrNull()
when {
s.voiceModelUrl.isBlank() -> errors["voiceModelUrl"] = "URL модели не задан"
vurl == null || vurl.host.isNullOrBlank() || vurl.scheme != "https" ->
errors["voiceModelUrl"] = "нужен абсолютный https://адрес"
else -> {}
}
return Result(errors, httpWarning = url?.scheme == "http") return Result(errors, httpWarning = url?.scheme == "http")
} }
} }

View File

@ -127,6 +127,19 @@ class WorkoutCore(
enqueue("command_log", id, PayloadFactory.commandLog(command)) 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) ---------- // ---------- дневник и перерывы (spec 02 §4) ----------
suspend fun saveDailyLog( suspend fun saveDailyLog(

View File

@ -38,6 +38,9 @@ interface WorkoutDao {
@Query("SELECT * FROM workout_set WHERE exercise_id = :exerciseId ORDER BY done_at DESC LIMIT 1") @Query("SELECT * FROM workout_set WHERE exercise_id = :exerciseId ORDER BY done_at DESC LIMIT 1")
suspend fun lastSetForExercise(exerciseId: String): WorkoutSet? 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) @Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsertCommand(command: CommandLog) suspend fun upsertCommand(command: CommandLog)
} }

View File

@ -23,6 +23,7 @@ data class AppSettings(
val hrMaxBpm: Int? = null, // 120–240 или null — расчёт по Танаке (Ф-6.2) val hrMaxBpm: Int? = null, // 120–240 или null — расчёт по Танаке (Ф-6.2)
val safety: SafetySettings = SafetySettings(), val safety: SafetySettings = SafetySettings(),
val hrStrengthMax: Int = 120, // предупреждение во время силовой (Ф-13.7, применяется в m12) 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) { class SettingsRepository(private val context: Context) {
@ -38,6 +39,7 @@ class SettingsRepository(private val context: Context) {
val bpCardioDias = intPreferencesKey("safety.bp_cardio_dias") val bpCardioDias = intPreferencesKey("safety.bp_cardio_dias")
val restingHrSitting = intPreferencesKey("safety.resting_hr_sitting") val restingHrSitting = intPreferencesKey("safety.resting_hr_sitting")
val hrStrengthMax = intPreferencesKey("safety.hr_strength_max") val hrStrengthMax = intPreferencesKey("safety.hr_strength_max")
val voiceModelUrl = stringPreferencesKey("voice.model_url")
} }
suspend fun get(): AppSettings = context.dataStore.data.first().toSettings() 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.bpCardioDias] = settings.safety.bpCardioDias
prefs[Keys.restingHrSitting] = settings.safety.restingHrSitting prefs[Keys.restingHrSitting] = settings.safety.restingHrSitting
prefs[Keys.hrStrengthMax] = settings.hrStrengthMax 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, restingHrSitting = this[Keys.restingHrSitting] ?: defaults.safety.restingHrSitting,
), ),
hrStrengthMax = this[Keys.hrStrengthMax] ?: defaults.hrStrengthMax, hrStrengthMax = this[Keys.hrStrengthMax] ?: defaults.hrStrengthMax,
voiceModelUrl = this[Keys.voiceModelUrl] ?: defaults.voiceModelUrl,
) )
} }
} }

View File

@ -10,6 +10,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
@ -36,14 +37,54 @@ import com.watchbody.phone.settings.AppSettings
import com.watchbody.phone.sync.SyncWorker import com.watchbody.phone.sync.SyncWorker
import kotlinx.coroutines.launch 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() { class SettingsViewModel(private val container: AppContainer) : ViewModel() {
var loaded by mutableStateOf<AppSettings?>(null) var loaded by mutableStateOf<AppSettings?>(null)
private set 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<String?>(null)
private set
init { init {
viewModelScope.launch { loaded = container.settings.get() } 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) { fun save(settings: AppSettings, onSaved: () -> Unit) {
@ -78,6 +119,7 @@ fun SettingsScreen(container: AppContainer, standalone: Boolean, onSaved: () ->
var bpCardioDias by remember { mutableStateOf(90) } var bpCardioDias by remember { mutableStateOf(90) }
var restingHrSitting by remember { mutableStateOf(90) } var restingHrSitting by remember { mutableStateOf(90) }
var hrStrengthMax by remember { mutableStateOf(120) } 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) { // одна загрузка в форму LaunchedEffect(vm.loaded) { // одна загрузка в форму
vm.loaded?.let { s -> vm.loaded?.let { s ->
@ -88,6 +130,7 @@ fun SettingsScreen(container: AppContainer, standalone: Boolean, onSaved: () ->
bpCardioSys = s.safety.bpCardioSys; bpCardioDias = s.safety.bpCardioDias bpCardioSys = s.safety.bpCardioSys; bpCardioDias = s.safety.bpCardioDias
restingHrSitting = s.safety.restingHrSitting restingHrSitting = s.safety.restingHrSitting
hrStrengthMax = s.hrStrengthMax hrStrengthMax = s.hrStrengthMax
voiceModelUrl = s.voiceModelUrl
} }
} }
@ -98,6 +141,7 @@ fun SettingsScreen(container: AppContainer, standalone: Boolean, onSaved: () ->
bpCancelSys, bpCancelDias, bpCardioSys, bpCardioDias, restingHrSitting, bpCancelSys, bpCancelDias, bpCardioSys, bpCardioDias, restingHrSitting,
), ),
hrStrengthMax = hrStrengthMax, hrStrengthMax = hrStrengthMax,
voiceModelUrl = voiceModelUrl,
) )
val validation = SettingsValidator.validate(draft) 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 = " уд/мин") IntPicker("Порог в силовой", hrStrengthMax, { hrStrengthMax = it ?: 120 }, min = 100, max = 180, start = 120, suffix = " уд/мин")
validation.errors["hrStrengthMax"]?.let { Text(it, color = MaterialTheme.colorScheme.error) } 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) { if (!standalone) {
Text( Text(
"Очередь отправки: $queue записей", "Очередь отправки: $queue записей",

View File

@ -1,6 +1,7 @@
package com.watchbody.phone.ui package com.watchbody.phone.ui
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row 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.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Edit 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.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@ -23,6 +27,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -32,7 +37,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp 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.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.watchbody.phone.AppContainer 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.Workout
import com.watchbody.phone.db.WorkoutSet import com.watchbody.phone.db.WorkoutSet
import com.watchbody.phone.sync.SyncWorker 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.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -51,10 +63,11 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.time.Instant import java.time.Instant
import java.time.ZoneOffset import java.time.ZoneOffset
/** Состояние экрана тренировки (spec 03). */ /** Состояние экрана тренировки (spec 03 + m11 spec 05 §1–3). */
data class WorkoutUiState( data class WorkoutUiState(
val workout: Workout? = null, val workout: Workout? = null,
val sets: List<WorkoutSet> = emptyList(), val sets: List<WorkoutSet> = emptyList(),
@ -92,6 +105,130 @@ class WorkoutViewModel(private val container: AppContainer) : ViewModel() {
} }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), WorkoutUiState()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), WorkoutUiState())
// ---------- голос m11 (spec 05 §1–3) ----------
var listening by mutableStateOf(false)
private set
var voiceMessage by mutableStateOf<String?>(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<String?>(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<String>) {
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<Double?, Int?> { suspend fun defaults(exerciseId: String?): Pair<Double?, Int?> {
if (exerciseId == null) return null to null if (exerciseId == null) return null to null
@ -166,15 +303,57 @@ fun WorkoutScreen(container: AppContainer) {
var confirmCancel by remember { mutableStateOf(false) } 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( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("Тренировка") }, title = { Text("Тренировка") },
actions = { 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 }) { IconButton(onClick = { formOpen = true }) {
Icon( Icon(
Icons.Filled.Edit, 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 { item {
RestCard(restLeft, onStart = { restLeft = restTotal }) RestCard(restLeft, onStart = { restLeft = restTotal })
} }
item { 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 -> items(state.sets, key = { it.id }) { set ->
@ -272,6 +472,40 @@ fun WorkoutScreen(container: AppContainer) {
dismissButton = { TextButton(onClick = { confirmCancel = false }) { Text("Оставить") } }, 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 @Composable
private fun PlanBlock(items: List<com.watchbody.phone.core.ItemView>, exercises: List<Exercise>) { private fun PlanBlock(
items: List<com.watchbody.phone.core.ItemView>,
exercises: List<Exercise>,
currentId: String?,
onSelect: (String?) -> Unit,
) {
if (items.isEmpty()) { if (items.isEmpty()) {
Text("План на сегодня не задан — тренировка вне программы", style = MaterialTheme.typography.bodyMedium) Text("План на сегодня не задан — тренировка вне программы", style = MaterialTheme.typography.bodyMedium)
return return
} }
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text("План сессии", style = MaterialTheme.typography.titleSmall) Text("План сессии (тап — выбрать текущим для голоса)", style = MaterialTheme.typography.titleSmall)
items.forEach { item -> 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}" } 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),
)
} }
} }
} }

View File

@ -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<File> =
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"
}
}

View File

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

View File

@ -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<Pair<String, Long>>()
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
}
}

View File

@ -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<String>,
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)
}
}

View File

@ -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<String, Pair<Int, Int>> = 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<String> = words.keys.toList()
data class Num(val value: Int, val half: Boolean, val size: Int)
/** Число с позиции i: цифры («120», «120.5») или [сотни][десятки][единицы] (+ «с половиной»). */
fun parseAt(tokens: List<String>, 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<String>): VoiceCommand? = parseWithText(alternatives)?.first
/** Команда + исходная гипотеза (recognized_text журнала, Ф-1.5). */
fun parseWithText(alternatives: List<String>): Pair<VoiceCommand, String>? {
var firstRejected: Pair<VoiceCommand, String>? = 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<String>): 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<String>): 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<String>): 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<String>): 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<String> {
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<String>(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('.', ',')

View File

@ -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<String> = 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())
}

View File

@ -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<String, String>, 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<Int>()
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"))
}
}

View File

@ -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<Set>()
val commands = mutableListOf<Command>()
val rpeCalls = mutableListOf<Pair<String, Int>>()
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<String>()
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<VoiceEffect>()
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)
}
}

View File

@ -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<String, Any>): 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)
}
}