From b126e4bf8b3f8ccbc682ac8f8a5672d6f751ccf0 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 27 Sep 2026 16:32:34 +0400 Subject: [PATCH] feat(core): save captured audio and initial note; in-memory port fakes Refs #1 Co-Authored-By: Claude Opus 5.5 --- src/core/capture.ts | 50 ++++++++++++++++++++++++ tests/capture.test.ts | 80 ++++++++++++++++++++++++++++++++++++++ tests/fakes.ts | 90 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 220 insertions(+) create mode 100644 src/core/capture.ts create mode 100644 tests/capture.test.ts create mode 100644 tests/fakes.ts diff --git a/src/core/capture.ts b/src/core/capture.ts new file mode 100644 index 0000000..c8cc3ae --- /dev/null +++ b/src/core/capture.ts @@ -0,0 +1,50 @@ +import { audioPath, makeId, notePath } from './naming'; +import { initialNote } from './note'; +import type { Env, NoteStore, RecordingStore } from './ports'; +import type { VoiceRecord } from './record'; + +export interface Capture { + chunks: ArrayBuffer[]; + mime: string; + startedAt: Date; + endedAt: Date; + interrupted: boolean; +} + +export interface CaptureDeps { + recordings: RecordingStore; + notes: NoteStore; + env: Env; +} + +// Сохраняет запись до любой сети. Возвращает путь заметки или null, если данных нет. +export async function saveCapture(deps: CaptureDeps, capture: Capture): Promise { + const size = capture.chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + if (size === 0) return null; + + const bytes = new Uint8Array(size); + let offset = 0; + for (const chunk of capture.chunks) { + bytes.set(new Uint8Array(chunk), offset); + offset += chunk.byteLength; + } + + const id = makeId(capture.startedAt, () => deps.env.random()); + const record: VoiceRecord = { + id, + status: 'saved', + device: deps.env.deviceId, + audio: audioPath(id, capture.mime), + mime: capture.mime, + attempts: 0, + }; + if (capture.interrupted) record.interrupted = true; + + await deps.recordings.write(record.audio, bytes.buffer); + + let n = 1; + while (await deps.notes.exists(notePath(capture.startedAt, n))) n++; + const path = notePath(capture.startedAt, n); + await deps.notes.create(path, initialNote(record, capture.endedAt.getTime() - capture.startedAt.getTime())); + return path; +} diff --git a/tests/capture.test.ts b/tests/capture.test.ts new file mode 100644 index 0000000..5a792b3 --- /dev/null +++ b/tests/capture.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; +import { saveCapture, type Capture } from '../src/core/capture'; +import { initialNote, readVoiceFields } from '../src/core/note'; +import { MemoryNotes, MemoryRecordings, TestEnv } from './fakes'; + +const T = new Date('2026-09-27T11:15:03.000Z'); +const buf = (...bytes: number[]) => new Uint8Array(bytes).buffer; + +function setup() { + const env = new TestEnv(T); + const notes = new MemoryNotes(); + const recordings = new MemoryRecordings(); + return { env, notes, recordings, deps: { env, notes, recordings } }; +} + +const capture = (over: Partial = {}): Capture => ({ + chunks: [buf(1, 2), buf(3, 4, 5)], + mime: 'audio/mp4', + startedAt: T, + endedAt: new Date(T.getTime() + 47_000), + interrupted: false, + ...over, +}); + +describe('saveCapture', () => { + it('writes concatenated audio and a saved note', async () => { + const { deps, notes, recordings } = setup(); + const path = await saveCapture(deps, capture()); + + expect(path).toBe('voice/inbox/2026-09-27 14-15-03.md'); + const audio = recordings.files.get('voice/source/20260927-111503-0000.m4a'); + expect(new Uint8Array(audio!)).toEqual(new Uint8Array([1, 2, 3, 4, 5])); + expect(notes.files.get(path!)).toBe( + initialNote( + { + id: '20260927-111503-0000', + status: 'saved', + device: 'test-device', + audio: 'voice/source/20260927-111503-0000.m4a', + mime: 'audio/mp4', + attempts: 0, + }, + 47_000, + ), + ); + }); + + it.each([[[]], [[new ArrayBuffer(0)]], [[new ArrayBuffer(0), new ArrayBuffer(0)]]])( + 'creates nothing for empty data %#', + async (chunks) => { + const { deps, notes, recordings } = setup(); + expect(await saveCapture(deps, capture({ chunks, interrupted: true }))).toBeNull(); + expect(notes.files.size).toBe(0); + expect(recordings.files.size).toBe(0); + }, + ); + + it('marks interrupted recordings with duration', async () => { + const { deps, notes } = setup(); + const path = await saveCapture(deps, capture({ interrupted: true })); + const content = notes.files.get(path!)!; + expect(readVoiceFields(content)['voice-interrupted']).toBe('true'); + expect(content).toContain('> [!note] Запись прервана, сохранено 0:47'); + }); + + it('adds (2), (3) on name collision', async () => { + const { deps, notes } = setup(); + notes.files.set('voice/inbox/2026-09-27 14-15-03.md', 'чужая'); + expect(await saveCapture(deps, capture())).toBe('voice/inbox/2026-09-27 14-15-03 (2).md'); + expect(await saveCapture(deps, capture())).toBe('voice/inbox/2026-09-27 14-15-03 (3).md'); + expect(notes.files.get('voice/inbox/2026-09-27 14-15-03.md')).toBe('чужая'); + }); + + it('keeps full mime and picks extension from it', async () => { + const { deps, notes, recordings } = setup(); + const path = await saveCapture(deps, capture({ mime: 'audio/webm;codecs=opus' })); + expect(recordings.files.has('voice/source/20260927-111503-0000.webm')).toBe(true); + expect(readVoiceFields(notes.files.get(path!)!)['voice-mime']).toBe('audio/webm;codecs=opus'); + }); +}); diff --git a/tests/fakes.ts b/tests/fakes.ts new file mode 100644 index 0000000..acbed02 --- /dev/null +++ b/tests/fakes.ts @@ -0,0 +1,90 @@ +import { readVoiceFields } from '../src/core/note'; +import type { + Env, + ListedNote, + NoteStore, + RecordingStore, + TranscribeResult, + Transcriber, +} from '../src/core/ports'; + +export class MemoryRecordings implements RecordingStore { + files = new Map(); + + async write(path: string, data: ArrayBuffer): Promise { + this.files.set(path, data.slice(0)); + } + + async read(path: string): Promise { + const data = this.files.get(path); + if (!data) throw new Error(`no such file: ${path}`); + return data.slice(0); + } +} + +export class MemoryNotes implements NoteStore { + files = new Map(); + // Имитация отстающего metadataCache: если задано, list() возвращает это. + staleList: ListedNote[] | null = null; + listCalls = 0; + + async exists(path: string): Promise { + return this.files.has(path); + } + + async create(path: string, content: string): Promise { + if (this.files.has(path)) throw new Error(`already exists: ${path}`); + this.files.set(path, content); + } + + async list(): Promise { + this.listCalls++; + if (this.staleList) return this.staleList; + return [...this.files] + .map(([path, content]) => ({ path, frontmatter: readVoiceFields(content) as Record })) + .filter((note) => 'voice-id' in note.frontmatter); + } + + async process(path: string, fn: (content: string) => string): Promise { + const content = this.files.get(path); + if (content === undefined) return false; + this.files.set(path, fn(content)); + return true; + } +} + +export type Step = + | TranscribeResult + | Error + | ((audio: ArrayBuffer, mime: string) => Promise); + +export class ScriptedTranscriber implements Transcriber { + calls: { audio: ArrayBuffer; mime: string; signal: AbortSignal }[] = []; + + constructor(public steps: Step[] = []) {} + + async transcribe(audio: ArrayBuffer, mime: string, signal: AbortSignal): Promise { + this.calls.push({ audio, mime, signal }); + const step = this.steps.shift(); + if (step === undefined) throw new Error('ScriptedTranscriber: no more steps'); + if (step instanceof Error) throw step; + if (typeof step === 'function') return step(audio, mime); + return step; + } +} + +export class TestEnv implements Env { + constructor( + public time = new Date('2026-09-27T11:15:03.000Z'), + public deviceId = 'test-device', + public random: () => number = () => 0, + ) {} + + now(): Date { + return new Date(this.time); + } + + advance(ms: number): void { + this.time = new Date(this.time.getTime() + ms); + } +}