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:
parent
e4be7ceb31
commit
28445f28b9
|
|
@ -45,7 +45,7 @@ JSON-тела (версии программ, факты .fit) — `jsonb` вн
|
|||
| m08-admin-web | Admin-консоль: UI — Flutter Web (`3-app\3-web\admin-web`), бэкенд — admin REST API (`/api/admin/*`) в `Server.WatchBody.Web`: логин/PBKDF2→Bearer, атлеты, тренировки+CSV, программы, метрики, план/факт | 3 | ✅ (2026-10-19: Bearer-контур + 6 контроллеров + Flutter UI (SDK 3.47.7, build → wwwroot/admin), тесты Web 105/105 + Core 56/56, HTTP-смок пройден) |
|
||||
| m09-phone-core | `WorkoutCore` + `LocalDb` (Room) + `SyncQueue`: тренировка, дневник, допуск, перерывы | 4 | ✅ (2026-10-19: Room 9 таблиц + WorkoutCore + SyncWorker/ApiClient + DataStore; APK 4,2 МБ, unit 18/18; смок sync 5 сущностей против живого сервера пройден, errata client_guid-UUID + guid-колонки daily_log/profile) |
|
||||
| m10-phone-ui | `TrainingUi` (Compose): экраны тренировки/истории/программ/профиля/дневника/допуска | 4 | ✅ (2026-10-19: 8 экранов+VM, Compose Material 3 тёмная, unit 36/36, APK 12 МБ, живой смок контура программы; errata контрактов camelCase/flat program_version/sex m-f; запуск на устройстве — приёмка владельца) |
|
||||
| m11-voice-input | `VoiceInput`: Vosk-STT, грамматика, TTS, журнал команд | 5 | ⬜ |
|
||||
| m11-voice-input | `VoiceInput`: Vosk-STT, грамматика, TTS, журнал команд | 5 | ✅ (2026-10-19: парсер Ф-1.2 + VoskEngine с грамматикой/fallback + ModelStore (filesDir, не в APK) + executor через WorkoutCore 🛡 + TTS/сигналы + Mic-кнопка/RECORD_AUDIO + блок настроек; unit 63/63, APK 57 МБ; живой URL модели 200 OK; распознавание на устройстве — приёмка владельца, КУ-1 вручную) |
|
||||
| m12-watch-channel | `WatchChannel`: companion-канал Connect IQ (прототип ОВ-3 — итерация 1, чек-лист 7.1) | 5 | ⬜ |
|
||||
| m13-fenix-data-field | `FenixDataField` (Monkey C, таргет `fenix6x`): пульс/зоны/отдых | 5 | ⬜ |
|
||||
|
||||
|
|
|
|||
|
|
@ -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/)
|
||||
|
|
@ -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 из первой.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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.
|
||||
|
|
@ -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-голоса.
|
||||
|
|
@ -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).
|
||||
|
|
@ -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); автоматизации не подлежит, фиксируется
|
||||
в журнале приёмки.
|
||||
|
|
@ -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.
|
||||
|
|
@ -58,12 +58,17 @@ dependencies {
|
|||
implementation(composeBom)
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("androidx.compose.material:material-icons-extended") // Mic/MicOff (m11)
|
||||
implementation("androidx.activity:activity-compose:1.9.3")
|
||||
implementation("androidx.navigation:navigation-compose:2.8.5")
|
||||
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
|
||||
// голосовой ввод (m11): офлайн-STT Vosk (ОВ-2, tech-req 5)
|
||||
implementation("com.alphacephei:vosk-android:0.3.47")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.1")
|
||||
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<!-- голос m11: runtime-запрос из кнопки микрофона (spec 05 §6) -->
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<application
|
||||
android:name="com.watchbody.phone.WatchBodyApp"
|
||||
android:label="Феникс 6X"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import com.watchbody.phone.core.WorkoutCore
|
|||
import com.watchbody.phone.db.AppDatabase
|
||||
import com.watchbody.phone.settings.SettingsRepository
|
||||
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). */
|
||||
class AppContainer(context: android.content.Context) {
|
||||
|
|
@ -13,6 +16,16 @@ class AppContainer(context: android.content.Context) {
|
|||
val settings = SettingsRepository(appContext)
|
||||
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 {
|
||||
val s = settings.get()
|
||||
|
|
|
|||
|
|
@ -51,6 +51,15 @@ object SettingsValidator {
|
|||
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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -127,6 +127,19 @@ class WorkoutCore(
|
|||
enqueue("command_log", id, PayloadFactory.commandLog(command))
|
||||
}
|
||||
|
||||
/**
|
||||
* RPE последнего подхода («РПЭ N», Ф-13.3) — расширение m11 по образцу
|
||||
* renameExercise: upsert той же строки + повторная отправка (сервер
|
||||
* идемпотентен по client_guid). false — подходов в тренировке нет.
|
||||
*/
|
||||
suspend fun setLastSetRpe(workoutId: String, rpe: Int): Boolean {
|
||||
val set = db.workoutDao().lastSet(workoutId) ?: return false
|
||||
val updated = set.copy(rpe = rpe)
|
||||
db.workoutDao().upsertSet(updated)
|
||||
enqueue("workout_set", set.id, PayloadFactory.workoutSet(updated))
|
||||
return true
|
||||
}
|
||||
|
||||
// ---------- дневник и перерывы (spec 02 §4) ----------
|
||||
|
||||
suspend fun saveDailyLog(
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ interface WorkoutDao {
|
|||
@Query("SELECT * FROM workout_set WHERE exercise_id = :exerciseId ORDER BY done_at DESC LIMIT 1")
|
||||
suspend fun lastSetForExercise(exerciseId: String): WorkoutSet?
|
||||
|
||||
@Query("SELECT * FROM workout_set WHERE workout_id = :workoutId ORDER BY seq DESC LIMIT 1")
|
||||
suspend fun lastSet(workoutId: String): WorkoutSet?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertCommand(command: CommandLog)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ data class AppSettings(
|
|||
val hrMaxBpm: Int? = null, // 120–240 или null — расчёт по Танаке (Ф-6.2)
|
||||
val safety: SafetySettings = SafetySettings(),
|
||||
val hrStrengthMax: Int = 120, // предупреждение во время силовой (Ф-13.7, применяется в m12)
|
||||
val voiceModelUrl: String = "https://alphacephei.com/vosk/models/vosk-model-small-ru-0.22.zip", // m11
|
||||
)
|
||||
|
||||
class SettingsRepository(private val context: Context) {
|
||||
|
|
@ -38,6 +39,7 @@ class SettingsRepository(private val context: Context) {
|
|||
val bpCardioDias = intPreferencesKey("safety.bp_cardio_dias")
|
||||
val restingHrSitting = intPreferencesKey("safety.resting_hr_sitting")
|
||||
val hrStrengthMax = intPreferencesKey("safety.hr_strength_max")
|
||||
val voiceModelUrl = stringPreferencesKey("voice.model_url")
|
||||
}
|
||||
|
||||
suspend fun get(): AppSettings = context.dataStore.data.first().toSettings()
|
||||
|
|
@ -55,6 +57,7 @@ class SettingsRepository(private val context: Context) {
|
|||
prefs[Keys.bpCardioDias] = settings.safety.bpCardioDias
|
||||
prefs[Keys.restingHrSitting] = settings.safety.restingHrSitting
|
||||
prefs[Keys.hrStrengthMax] = settings.hrStrengthMax
|
||||
prefs[Keys.voiceModelUrl] = settings.voiceModelUrl
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -74,6 +77,7 @@ class SettingsRepository(private val context: Context) {
|
|||
restingHrSitting = this[Keys.restingHrSitting] ?: defaults.safety.restingHrSitting,
|
||||
),
|
||||
hrStrengthMax = this[Keys.hrStrengthMax] ?: defaults.hrStrengthMax,
|
||||
voiceModelUrl = this[Keys.voiceModelUrl] ?: defaults.voiceModelUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import androidx.compose.foundation.rememberScrollState
|
|||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
|
|
@ -36,14 +37,54 @@ import com.watchbody.phone.settings.AppSettings
|
|||
import com.watchbody.phone.sync.SyncWorker
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** «Настройки» (tech-req 4.1, spec 05 §3); 🛡 ключ — маска, не логируется. */
|
||||
/** «Настройки» (tech-req 4.1, spec 05 §3 + m11 §5); 🛡 ключ — маска, не логируется. */
|
||||
class SettingsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
|
||||
var loaded by mutableStateOf<AppSettings?>(null)
|
||||
private set
|
||||
|
||||
// m11: модель распознавания и TTS (spec 05 §5)
|
||||
var modelState by mutableStateOf("")
|
||||
private set
|
||||
var modelBusy by mutableStateOf(false)
|
||||
private set
|
||||
var modelProgress by mutableStateOf(0)
|
||||
private set
|
||||
var ttsState by mutableStateOf<String?>(null)
|
||||
private set
|
||||
|
||||
init {
|
||||
viewModelScope.launch { loaded = container.settings.get() }
|
||||
refreshModel()
|
||||
}
|
||||
|
||||
fun refreshModel() {
|
||||
modelState = if (container.modelStore.isDownloaded()) {
|
||||
"Загружена (${container.modelStore.sizeMb()} МБ)"
|
||||
} else {
|
||||
"Не загружена"
|
||||
}
|
||||
}
|
||||
|
||||
/** [url] — значение из поля до сохранения настроек. */
|
||||
fun downloadModel(url: String) {
|
||||
viewModelScope.launch {
|
||||
modelBusy = true
|
||||
modelProgress = 0
|
||||
container.modelStore.download(urlOverride = url.trim()) { p -> modelProgress = p }
|
||||
.fold(
|
||||
{ refreshModel() },
|
||||
{ modelState = "Ошибка: ${it.message}" },
|
||||
)
|
||||
modelBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
fun checkTts() {
|
||||
ttsState = "Проверка…"
|
||||
container.ttsFeedback.ttsAvailable { ok ->
|
||||
ttsState = if (ok) "Русский голос доступен" else "TTS недоступен — только сигналы и текст"
|
||||
}
|
||||
}
|
||||
|
||||
fun save(settings: AppSettings, onSaved: () -> Unit) {
|
||||
|
|
@ -78,6 +119,7 @@ fun SettingsScreen(container: AppContainer, standalone: Boolean, onSaved: () ->
|
|||
var bpCardioDias by remember { mutableStateOf(90) }
|
||||
var restingHrSitting by remember { mutableStateOf(90) }
|
||||
var hrStrengthMax by remember { mutableStateOf(120) }
|
||||
var voiceModelUrl by remember { mutableStateOf("https://alphacephei.com/vosk/models/vosk-model-small-ru-0.22.zip") }
|
||||
|
||||
LaunchedEffect(vm.loaded) { // одна загрузка в форму
|
||||
vm.loaded?.let { s ->
|
||||
|
|
@ -88,6 +130,7 @@ fun SettingsScreen(container: AppContainer, standalone: Boolean, onSaved: () ->
|
|||
bpCardioSys = s.safety.bpCardioSys; bpCardioDias = s.safety.bpCardioDias
|
||||
restingHrSitting = s.safety.restingHrSitting
|
||||
hrStrengthMax = s.hrStrengthMax
|
||||
voiceModelUrl = s.voiceModelUrl
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -98,6 +141,7 @@ fun SettingsScreen(container: AppContainer, standalone: Boolean, onSaved: () ->
|
|||
bpCancelSys, bpCancelDias, bpCardioSys, bpCardioDias, restingHrSitting,
|
||||
),
|
||||
hrStrengthMax = hrStrengthMax,
|
||||
voiceModelUrl = voiceModelUrl,
|
||||
)
|
||||
val validation = SettingsValidator.validate(draft)
|
||||
|
||||
|
|
@ -162,6 +206,32 @@ fun SettingsScreen(container: AppContainer, standalone: Boolean, onSaved: () ->
|
|||
IntPicker("Порог в силовой", hrStrengthMax, { hrStrengthMax = it ?: 120 }, min = 100, max = 180, start = 120, suffix = " уд/мин")
|
||||
validation.errors["hrStrengthMax"]?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
|
||||
Text("Распознавание речи (голос)", style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(
|
||||
value = voiceModelUrl,
|
||||
onValueChange = { voiceModelUrl = it },
|
||||
label = { Text("URL модели Vosk (https://…)") },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
isError = validation.errors.containsKey("voiceModelUrl"),
|
||||
)
|
||||
validation.errors["voiceModelUrl"]?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
Text("Модель: ${vm.modelState}", style = MaterialTheme.typography.bodyMedium)
|
||||
if (vm.modelBusy) {
|
||||
LinearProgressIndicator(
|
||||
progress = { vm.modelProgress / 100f },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Text("${vm.modelProgress} %", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedButton(
|
||||
onClick = { vm.downloadModel(voiceModelUrl) },
|
||||
enabled = !vm.modelBusy && validation.errors.containsKey("voiceModelUrl").not(),
|
||||
) { Text("Загрузить модель") }
|
||||
OutlinedButton(onClick = { vm.checkTts() }) { Text("Проверить TTS") }
|
||||
}
|
||||
vm.ttsState?.let { Text(it, style = MaterialTheme.typography.bodyMedium) }
|
||||
|
||||
if (!standalone) {
|
||||
Text(
|
||||
"Очередь отправки: $queue записей",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.watchbody.phone.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
|
@ -11,11 +12,14 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MicOff
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
|
|
@ -23,6 +27,7 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -32,7 +37,11 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.watchbody.phone.AppContainer
|
||||
|
|
@ -44,6 +53,9 @@ import com.watchbody.phone.db.Exercise
|
|||
import com.watchbody.phone.db.Workout
|
||||
import com.watchbody.phone.db.WorkoutSet
|
||||
import com.watchbody.phone.sync.SyncWorker
|
||||
import com.watchbody.phone.voice.VoiceEffect
|
||||
import com.watchbody.phone.voice.humanKg
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
|
@ -51,10 +63,11 @@ import kotlinx.coroutines.flow.flatMapLatest
|
|||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
|
||||
/** Состояние экрана тренировки (spec 03). */
|
||||
/** Состояние экрана тренировки (spec 03 + m11 spec 05 §1–3). */
|
||||
data class WorkoutUiState(
|
||||
val workout: Workout? = null,
|
||||
val sets: List<WorkoutSet> = emptyList(),
|
||||
|
|
@ -92,6 +105,130 @@ class WorkoutViewModel(private val container: AppContainer) : ViewModel() {
|
|||
}
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), WorkoutUiState())
|
||||
|
||||
// ---------- голос m11 (spec 05 §1–3) ----------
|
||||
|
||||
var listening by mutableStateOf(false)
|
||||
private set
|
||||
var voiceMessage by mutableStateOf<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?> {
|
||||
if (exerciseId == null) return null to null
|
||||
|
|
@ -166,15 +303,57 @@ fun WorkoutScreen(container: AppContainer) {
|
|||
|
||||
var confirmCancel by remember { mutableStateOf(false) }
|
||||
|
||||
// голос m11 (spec 05 §1): разрешение → старт; эффекты → состояние экрана
|
||||
val context = LocalContext.current
|
||||
val micPermission = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { vm.onPermissionResult(it) }
|
||||
LaunchedEffect(Unit) {
|
||||
vm.effectHandler = { e ->
|
||||
when (e) {
|
||||
is VoiceEffect.WorkingWeight -> weight = e.weightKg
|
||||
is VoiceEffect.RestStarted -> { restTotal = e.seconds; restLeft = e.seconds }
|
||||
is VoiceEffect.Message -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(state.planItems, state.exercises) { vm.defaultExerciseFromPlan() }
|
||||
LaunchedEffect(selectedExercise) {
|
||||
if (selectedExercise != null) vm.setCurrentExercise(selectedExercise)
|
||||
}
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { vm.stopVoice() } // выход с экрана останавливает прослушивание (Ф-1.1)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Тренировка") },
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (vm.listening) {
|
||||
vm.stopVoice()
|
||||
} else if (ContextCompat.checkSelfPermission(
|
||||
context, android.Manifest.permission.RECORD_AUDIO,
|
||||
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
vm.startVoice()
|
||||
} else {
|
||||
micPermission.launch(android.Manifest.permission.RECORD_AUDIO)
|
||||
}
|
||||
},
|
||||
enabled = state.workout != null,
|
||||
) {
|
||||
Icon(
|
||||
if (vm.listening) Icons.Filled.MicOff else Icons.Filled.Mic,
|
||||
contentDescription = "Голосовой ввод: включить/выключить",
|
||||
)
|
||||
}
|
||||
IconButton(onClick = { formOpen = true }) {
|
||||
Icon(
|
||||
Icons.Filled.Edit,
|
||||
contentDescription = "Записать подход (ручной ввод; голос — m11)",
|
||||
contentDescription = "Записать подход (ручной ввод)",
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
@ -211,12 +390,33 @@ fun WorkoutScreen(container: AppContainer) {
|
|||
)
|
||||
}
|
||||
|
||||
item {
|
||||
// голос: индикатор + последняя команда — текст, не только цвет 🛡
|
||||
Text(
|
||||
if (vm.listening) "● Слушаю… (скажите: подход / вес / отдых / РПЭ)" else "Голос выключен",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (vm.listening) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
vm.voiceMessage?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
RestCard(restLeft, onStart = { restLeft = restTotal })
|
||||
}
|
||||
|
||||
item {
|
||||
PlanBlock(state.planItems, state.exercises)
|
||||
PlanBlock(
|
||||
state.planItems,
|
||||
state.exercises,
|
||||
currentId = vm.voiceExerciseId,
|
||||
onSelect = { id ->
|
||||
selectedExercise = id
|
||||
vm.setCurrentExercise(id)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
items(state.sets, key = { it.id }) { set ->
|
||||
|
|
@ -272,6 +472,40 @@ fun WorkoutScreen(container: AppContainer) {
|
|||
dismissButton = { TextButton(onClick = { confirmCancel = false }) { Text("Оставить") } },
|
||||
)
|
||||
}
|
||||
|
||||
if (vm.modelDialog) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { if (!vm.modelDownloading) vm.dismissModelDialog() },
|
||||
title = { Text("Модель распознавания") },
|
||||
text = {
|
||||
Column {
|
||||
Text(
|
||||
"Модель Vosk (~45 МБ) не загружена. Скачать сейчас? " +
|
||||
"Распознавание работает офлайн — сеть нужна только для загрузки.",
|
||||
)
|
||||
if (vm.modelDownloading) {
|
||||
LinearProgressIndicator(
|
||||
progress = { vm.modelProgress / 100f },
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
)
|
||||
Text("${vm.modelProgress} %", style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { vm.downloadModel() },
|
||||
enabled = !vm.modelDownloading,
|
||||
) { Text("Скачать") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(
|
||||
onClick = { vm.dismissModelDialog() },
|
||||
enabled = !vm.modelDownloading,
|
||||
) { Text("Не сейчас") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -301,16 +535,38 @@ private fun RestCard(restLeft: Int, onStart: () -> Unit) {
|
|||
}
|
||||
|
||||
@Composable
|
||||
private fun PlanBlock(items: List<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()) {
|
||||
Text("План на сегодня не задан — тренировка вне программы", style = MaterialTheme.typography.bodyMedium)
|
||||
return
|
||||
}
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text("План сессии", style = MaterialTheme.typography.titleSmall)
|
||||
Text("План сессии (тап — выбрать текущим для голоса)", style = MaterialTheme.typography.titleSmall)
|
||||
items.forEach { item ->
|
||||
val exId = exercises.firstOrNull { it.name.equals(item.exerciseName, ignoreCase = true) }?.id
|
||||
val isCurrent = exId != null && exId == currentId
|
||||
val sets = item.planSets.joinToString(", ") { "${formatKg(it.weightKg)}×${it.reps}" }
|
||||
Text("• ${item.exerciseName}: $sets", style = MaterialTheme.typography.bodyMedium)
|
||||
val label = "• ${item.exerciseName}: $sets" + if (isCurrent) " · текущее" else ""
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isCurrent) MaterialTheme.colorScheme.primary else Color.Unspecified,
|
||||
modifier = (
|
||||
if (exId != null) {
|
||||
Modifier
|
||||
.clickable { onSelect(exId) }
|
||||
.background(
|
||||
if (isCurrent) MaterialTheme.colorScheme.primaryContainer
|
||||
else Color.Transparent,
|
||||
)
|
||||
} else Modifier
|
||||
).padding(horizontal = 4.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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('.', ',')
|
||||
|
|
@ -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())
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue