feat: core recording lifecycle (#1) #2

Merged
aep merged 22 commits from feat/1-core-recording-lifecycle into main 2026-09-27 18:24:26 +03:00
3 changed files with 220 additions and 0 deletions
Showing only changes of commit b126e4bf8b - Show all commits

50
src/core/capture.ts Normal file
View File

@ -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<string | null> {
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;
}

80
tests/capture.test.ts Normal file
View File

@ -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> = {}): 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');
});
});

90
tests/fakes.ts Normal file
View File

@ -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<string, ArrayBuffer>();
async write(path: string, data: ArrayBuffer): Promise<void> {
this.files.set(path, data.slice(0));
}
async read(path: string): Promise<ArrayBuffer> {
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<string, string>();
// Имитация отстающего metadataCache: если задано, list() возвращает это.
staleList: ListedNote[] | null = null;
listCalls = 0;
async exists(path: string): Promise<boolean> {
return this.files.has(path);
}
async create(path: string, content: string): Promise<void> {
if (this.files.has(path)) throw new Error(`already exists: ${path}`);
this.files.set(path, content);
}
async list(): Promise<ListedNote[]> {
this.listCalls++;
if (this.staleList) return this.staleList;
return [...this.files]
.map(([path, content]) => ({ path, frontmatter: readVoiceFields(content) as Record<string, unknown> }))
.filter((note) => 'voice-id' in note.frontmatter);
}
async process(path: string, fn: (content: string) => string): Promise<boolean> {
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<TranscribeResult>);
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<TranscribeResult> {
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);
}
}