18 KiB
Design: core-recording-lifecycle
Context
Репозиторий пуст (каркас OpenSpec, одна фикстура test-01). Ограничения из CLAUDE.md: mobile-first (iPhone), isDesktopOnly: false, никакого Node API в рантайме, src/core/ без импортов obsidian, core покрыт vitest полностью, src/obsidian/ — тонкая обёртка без юнит-тестов. Vault синхронизируется через iCloud между маком и iPhone, плагин стоит на обоих.
Goals / Non-Goals
Goals:
- Запись не теряется: аудио и заметка сохраняются до любой сети.
- Вся логика состояний, повторов, устройств и формата заметки — в core и под тестами.
- Обёртка сводится к реализации четырёх портов и вызову
tick()по событиям.
Non-Goals:
- Реальный провайдер, HTTP, коды ответа, переключение провайдеров (issue провайдера).
- Команды, настройки, UI,
MediaRecorder, реализация портов наapp.vault(issue обёртки). - Периодический сброс записи на диск во время записи (отдельный spike на реальном iPhone: неизвестно, читается ли частичный mp4 из Safari
MediaRecorder). - Конвертация форматов, удаление аудио, daily notes.
Decisions
Подход: чистое ядро + оркестратор на портах
Машина состояний, планировщик и формат заметки — чистые функции. Поверх них асинхронный QueueRunner в core работает через порты. Отвергнуто: (A) полностью чистый редьюсер с эффектами — оркестрация уехала бы в нетестируемую обёртку; (C) XState — лишняя зависимость и вес бандла для ~8 состояний.
Модули src/core/
| Модуль | Ответственность |
|---|---|
record.ts |
тип VoiceRecord, Status, transition(record, event, now), MAX_ATTEMPTS = 5, BACKOFF_MS = [30s, 2m, 10m, 30m], SENT_STALE_MS = 10m, NO_SPEECH = 'no-speech' |
schedule.ts |
due(records, now, deviceId), isStaleSent(record, now), claimable(records, now, deviceId) |
note.ts |
parseRecord(fields), readVoiceFields(content), renderNote(content, record, text?), построчный патч ключей voice-* во frontmatter, initialNote(record) |
naming.ts |
makeId(date, random), extFromMime, audioPath, notePath(date, n) |
ports.ts |
интерфейсы портов (ниже) |
capture.ts |
saveCapture(ports, capture) |
queue.ts |
QueueRunner с tick(), retry(notePath), claimAll() |
Порты
interface RecordingStore {
write(path: string, data: ArrayBuffer): Promise<void>;
read(path: string): Promise<ArrayBuffer>;
}
interface NoteStore {
exists(path: string): Promise<boolean>;
create(path: string, content: string): Promise<void>;
// Все заметки vault, у которых во frontmatter есть voice-id, с их frontmatter.
// КОНТРАКТ: реализация MUST брать данные из metadataCache и MUST NOT читать
// содержимое файлов (vault.read/cachedRead/adapter). Вызывается на каждом tick,
// в т.ч. на телефоне. Данные могут отставать от диска — core перепроверяет
// состояние внутри process().
list(): Promise<{ path: string; frontmatter: Record<string, unknown> }[]>;
// атомарное чтение-изменение-запись; false, если заметки нет
process(path: string, fn: (content: string) => string): Promise<boolean>;
}
interface Transcriber {
transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise<TranscribeResult>;
}
type TranscribeResult =
| { ok: true; text: string }
| { ok: false; kind: 'temporary' | 'permanent'; message: string };
interface Env { now(): Date; random(): number; deviceId: string; }
Env объединяет часы, случайность и id устройства — в тестах всё детерминировано, что делает golden-сравнение побайтным. NoteStore.process в обёртке реализуется через vault.process. list() — только через metadataCache.getFileCache(file)?.frontmatter по наличию voice-id; поэтому порт отдаёт frontmatter-объект, а не текст заметки.
Frontmatter правит core, одной записью
Статус и тело должны меняться атомарно, иначе обрыв между processFrontMatter и process оставит inserted без текста или текст без статуса. Поэтому core сам построчно патчит только строки voice-*: … в блоке ---, не трогая остальные строки и их порядок, без YAML-библиотеки. Значения пишутся скалярами; строки с :/кавычками экранируются в двойных кавычках.
Выполнение отправки
tick(): защита от повторного входа (флаг running + флаг rerun) → list() → parseRecord(frontmatter) → просроченные sent своего устройства превращаются во временную ошибку → due() → по одной: process (внутри функция заново читает voice-* из актуального content через readVoiceFields; если запись уже не в ожидаемом состоянии или не своего устройства — возвращает content без изменений и запись пропускается; иначе статус sent, voice-sent-at) → read аудио → transcribe → process (inserted + текст или retry/failed + область). Если process вернул false — запись отменена, идём дальше. Исключение из transcribe или read трактуется как временная ошибка с его сообщением.
Ручные команды
claimAll(): для каждой записи из claimable() одной записью voice-device := текущее, voice-status := saved, удалить voice-next-attempt и voice-sent-at; voice-attempts сохраняется. Затем tick(). retry(notePath): только из failed/retry → saved, voice-attempts := 0, удалить voice-next-attempt/voice-error; затем tick().
Захват
saveCapture({ chunks: ArrayBuffer[], mime, startedAt, endedAt, interrupted }): суммарно 0 байт → null; иначе склейка → write аудио → подбор свободного имени заметки через exists → create с initialNote → возвращает путь. MediaRecorder и детекция обрыва — в обёртке.
Тестовая инфраструктура (Task 1)
Шаблон obsidian-sample-plugin (esbuild, manifest.json с isDesktopOnly: false), vitest. Тест-страж сканирует src/core/**/*.ts и падает на импорт obsidian, fs, path, child_process, node:*. Фейки в tests/fakes/: in-memory RecordingStore/NoteStore, скриптуемый Transcriber, Env с управляемыми часами.
Аудиофикстуры *.m4a лежат в git (test-01.m4a ~85 КБ), так что npm test зелёный на чистом клоне. Если для эталона <name>.note.md нет аудиофайла, golden-тест MUST падать с сообщением «нет аудиофикстуры tests/fixtures/.m4a», а не пропускаться молча.
Risks / Trade-offs
- Лаг iCloud. Команда «забрать все» может отправить запись, которую телефон как раз отправляет. Цена — один лишний запрос; вставка идемпотентна по статусу. Принято.
- Самописный патч frontmatter. Не покрывает многострочные YAML-значения в ключах
voice-*; плагин сам их не пишет, а пользовательские ключи не трогает. Покрывается тестами. - Потеря при жёстком убийстве процесса во время записи. Принято до spike периодического сброса.
list()по всему vault. Дёшево только при реализации черезmetadataCache(закреплено в контракте порта). Читаются лишь заметки, которые реально обрабатываются, черезprocess.- Отставание
metadataCache. После записи кэш обновляется асинхронно, следующий tick может увидеть старый статус. Защита — перепроверка состояния внутриprocessпо актуальному содержимому.
Decisions made without discussion
Формат: дата — решение. Обоснование. Цена ошибки. Пометка «пересмотрено» — решение скорректировано человеком на ревью.
- 2026-09-27 — Пустой (после обрезки пробелов) транскрипт переводит запись в
failedс причинойno-speech; в заметке> [!info] В записи не распознано речи, без слова «ошибка» и без призыва повторить. Аудио остаётся. Повтор тишины бесполезен; это свойство записи, а не сбой сервиса (формулировка пересмотрена на ревью). Если провайдер изредка возвращает пустоту на нормальной записи — пользователь увидит «нет речи» и должен сам запустить «Повторить распознавание»; потери данных нет. - 2026-09-27 — Очередь ищет записи по
voice-idво всём vault, а не только вvoice/inbox/. Пользователь может перенести заметку до распознавания. Пересмотрено:NoteStore.list()обязан работать черезmetadataCacheбез чтения файлов, это записано в контракт порта. Если обёртка нарушит контракт — тормоза на телефоне на каждом tick; ловится ревью обёртки, в core не проверяемо. - 2026-09-27 — Добавлены ключи
voice-mime(исходный mime с параметрами) иvoice-sent-at(ISO UTC). Провайдеру нужен точный mime, а не восстановленный по расширению; зависшийsentнужно отличать от идущего. Лишние поля во frontmatter — косметика; без них — неверный mime у провайдера или вечныйsent. - 2026-09-27 — Порог зависшего
sent— 10 минут (пересмотрено с 5). Автоматически касается только своих записей, чужие — только черезclaimAll. Двухминутная запись через агрегатор на плохом канале может идти дольше 5 минут. Слишком мал — дубль запроса (текст не портится: вставка идемпотентна); слишком велик — запись после убийства Obsidian ждёт повтора до 10 минут. - 2026-09-27 —
claimAllсохраняетvoice-attempts, меняет только устройство, статус (вsaved) и удаляетvoice-next-attempt/voice-sent-at. «Повторить распознавание» работает дляfailedиretryи сбрасывает счётчик в 0.claimAll— про «где», а не «заново»; ручной повтор — осознанный перезапуск. ЕслиclaimAllдолжен был давать 5 свежих попыток — запись с телефона уйдёт вfailedраньше; исправляется ручным повтором. - 2026-09-27 — Неизвестный mime сохраняется с расширением
.bin, запись не отвергается. Сохранить аудио важнее красивого расширения; принимать ли формат, решает провайдер. Файл.binне проигрывается встроенным плеером Obsidian; аудио при этом не потеряно. - 2026-09-27 — При переходе в
insertedудаляютсяvoice-next-attempt,voice-sent-at,voice-error;voice-attemptsостаётся. Итоговая заметка чище; счётчик полезен как история. Косметика. - 2026-09-27 — Frontmatter правит сам core построчно, только ключи
voice-*, без YAML-библиотеки; статус и тело меняются одной операциейprocess. Два вызова (processFrontMatter+process) не атомарны: обрыв между ними даётinsertedбез текста. Самописный патч может сломаться на экзотическом YAML в ключахvoice-*— плагин такой не пишет; сохранность чужих ключей закрыта тестами. - 2026-09-27 — Golden-сравнение побайтное при фиксированном
Env(время, случайность, устройство). Нет аудиофикстуры для эталона — тест падает с понятным сообщением, а не пропускается. Пересмотрено на ревью:tests/fixtures/*.m4aубран из.gitignore,test-01.m4aзакоммичен, чтобы на чистом клоне тесты были зелёными. Молчаливый skip дал бы ложно-зелёный прогон. Нулевая: фикстура в репозитории; падение возможно только если добавить эталон без аудио — это и должно быть ошибкой. - 2026-09-27 — В
.gitignoreдобавлен.claude/worktrees/. Хук guard-main запретил правку.git/info/excludeв main; вложенные worktree иначе видны как неотслеживаемые. Нулевая. - 2026-09-27 —
voice-idстроится по UTC (пересмотрено на ревью, было локальное время); имя заметки в inbox — по локальному времени. Имя аудиофайла следуетvoice-id, т.е. тоже UTC. Порядок id не зависит от пояса устройства; имя заметки — для человека. Имена аудио и заметки одной записи расходятся на смещение пояса — можно спутать при ручном поиске; связь всё равно хранится ссылкойvoice-audio. - 2026-09-27 —
NoteStore.list()отдаёт frontmatter-объект, а не текст заметки; core перепроверяет состояние внутриprocessпо актуальному содержимому и пропускает запись, если она уже не в ожидаемом состоянии или не своего устройства. Следствие правки 2: без чтения файлов текста нет, аmetadataCacheобновляется асинхронно. Без перепроверки — повторная отправка только что отправленной записи. - 2026-09-27 — Исключение из
RecordingStore.readилиTranscriber.transcribeтрактуется как временная ошибка со своим сообщением. Классифицирует провайдер, но непредвиденный throw не должен ронять очередь. Если исключение на деле постоянное (например, аудиофайл удалён) — 5 бесполезных попыток за ~42 минуты, затемfailed. - 2026-09-27 — Причина «нет речи» хранится кодом
voice-error: no-speech, а не текстом. Рендер должен отличать её от обычной ошибки без разбора сообщений. Нулевая: значение внутреннее.