28 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:*. Тесты запускаются с фиксированным TZ=Europe/Moscow (задаётся в vitest.config.ts): имена заметок и строка статуса retry зависят от локального времени, а golden-сравнение побайтное. Фейки в tests/fakes.ts: 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, а не текстом. Рендер должен отличать её от обычной ошибки без разбора сообщений. Нулевая: значение внутреннее. - 2026-09-27 (план) — Все строковые значения
voice-*пишутся в двойных кавычках черезJSON.stringify; числа (voice-attempts) иtrue— без кавычек. JSON-строка — валидная YAML-строка: переводы строк, кавычки и двоеточия из сообщений провайдера экранируются в одну строку; id вида20260927-...не рискует распарситься YAML как число/дата. Кавычки видны в режиме исходника — косметика. - 2026-09-27 (план) — Из
retryзапись переходит сразу вsent; промежуточноеsaved(на схеме «срок наступил → saved») не записывается. Лишняя запись в файл без наблюдаемой разницы. Нулевая:savedи «retryс наступившим сроком» обрабатываются одинаково. - 2026-09-27 (план) — «Повторить распознавание» переназначает запись текущему устройству. Иначе
failed-запись с телефона, повторённая на маке, вернулась бы вsavedс устройством телефона, и мак её не отправил бы — команда выглядела бы сломанной. Если телефон одновременно тоже повторит ту же запись — один лишний запрос; текст не портится. - 2026-09-27 (план) — Тесты идут с
TZ=Europe/Moscow(задаётся вvitest.config.ts). Имена заметок и строки статуса зависят от локального времени; UTC+3 без летнего времени даёт детерминизм и заметно отличается от UTC. Если CI окажется на Windows, гдеTZигнорируется — тесты с локальным временем упадут; сейчас CI нет. - 2026-09-27 (план) — id плагина
voice-note;npm run devпосле каждой сборки копируетmain.jsиmanifest.jsonв<тестовый vault>/.obsidian/plugins/voice-note/, если vault есть на машине. CLAUDE.md: «dev — сборка в тестовый vault». Смена id позже — переустановка плагина в vault; данные core живут во frontmatter, не теряются. - 2026-09-27 (план) — Если маркеры области стёрты, а запись ещё не
inserted(retry/failed), плагин обновляет только frontmatter, строку статуса в тело не дописывает; транскрипт при успехе дописывается в конец. Не мусорить в заметке, которую пользователь сознательно переделал. Пользователь не видит в теле статус ожидания/ошибки такой заметки — он остаётся в Properties. - 2026-09-27 (план) — Причина ошибки в callout сжимается в одну строку (пробельные символы → один пробел). Многострочное сообщение ломает callout. Потеря переносов в тексте ошибки — косметика; полное сообщение остаётся во frontmatter.
- 2026-09-27 (план) — Готовые к отправке записи обрабатываются по возрастанию
voice-id(старые первыми). Предсказуемо; id по UTC упорядочен во времени. Нулевая. - 2026-09-27 (план) — Нераспознаваемое
voice-next-attempt— запись готова к отправке; отсутствующее или нераспознаваемоеvoice-sent-atуsent— отправка считается зависшей. Ручная правка frontmatter не должна вешать запись навсегда. Один досрочный запрос. - 2026-09-27 (план) —
QueueRunner.dispose()отменяетAbortSignal, переданный провайдеру; прерванный запрос обрабатывается как любое исключение — временная ошибка. КонтрактTranscriberпринимает signal; выгрузка плагина должна обрывать загрузку. Выгрузка посреди запроса тратит одну попытку из пяти. - 2026-09-27 (план) — Golden-кейсы находятся по файлам
tests/fixtures/*.note.md; аудио ищется как.m4a,.webm,.oggс mimeaudio/mp4,audio/webm,audio/ogg. Добавить кейс — положить три файла, без правки теста. Нулевая. - 2026-09-27 (план) — Модели исполнителей: haiku —
naming,schedule(механика по готовому коду); opus —QueueRunner.tick(асинхронность, гонки); sonnet — остальное. Режим — subagent-driven-development (10 задач > 5). Правила Model Selection SDD и глобальный CLAUDE.md. Слабая модель на механической задаче — лишний круг ревью, не дефект в ветке. - 2026-09-27 (план, зафиксировано на ревью) — Зависший
sentзасчитывается как попытка (временная ошибка «отправка прервана»). Иначе запись, на которой Obsidian каждый раз выгружается, повторялась бы бесконечно. Пять выгрузок Obsidian подряд во время отправки дадутfailedбез единого ответа сервиса; спасает ручной повтор. - 2026-09-27 (план, зафиксировано на ревью) — Результат запроса пишется по пути, полученному из
list(). Если заметку переименовали или перенесли между первымprocess(sent) и вторым (результат), второйprocessне находит файл и результат отбрасывается. ПортNoteStoreадресует заметки путём; отслеживание переименований — усложнение обёртки ради редкого случая. Запись остаётся вsent, через 10 минут уходит в повтор — один лишний запрос. - 2026-09-27 (исполнение, контроллер) — Модели исполнителей пересмотрены по правилам Model Selection SDD: haiku — задачи 2, 3, 4, 5, 6, 7, 9 (в плане полный код и тесты, 1–2 файла); sonnet — задача 1 (установка зависимостей, сборка), задача 8 (самый большой асинхронный модуль, при падении тестов нужна отладка) и задача 10 (генерация эталона и разбор расхождений со спекой). Ревьюеры задач — sonnet, для задачи 8 — opus; финальное ревью — opus. Заменяет решение 26 и строки Model в плане. Перенос готового кода — дешёвый уровень; opus — для эскалации в цикле исправлений и финального ревью. Лишний круг исправлений, если слабая модель ошибётся при переносе; дефект в ветку не попадает — каждая задача проходит ревью.
- 2026-09-27 (исполнение, контроллер) — Почти одинаковые тестовые помощники (
setup/rec/seed/fields) вtests/queue.test.tsиtests/queue-commands.test.tsреализуются как в плане, без выноса вtests/fakes.ts. Это небольшая тестовая обвязка, а не логика; помощники уже различаются (сигнатураseed, байты аудио); превентивная правка меняла бы дословный текст двух задач. ~20 строк дублированной настройки тестов, которые надо держать в согласии; вынос позже механический. - 2026-09-27 (исполнение, контроллер) — Решения исполнителей задач дописываются ими в этот раздел в коммите задачи (продолжая нумерацию, пометка «исполнение»); рулинги контроллера — отдельными docs-коммитами с пометкой «исполнение, контроллер». Требование глобальных ограничений плана и CLAUDE.md; задачи идут строго последовательно, конфликтов нумерации нет. Шум в нумерации/формате раздела, правится руками.