feat: core recording lifecycle (#1) #2
50
src/core/capture.ts
Normal file
50
src/core/capture.ts
Normal 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
80
tests/capture.test.ts
Normal 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
90
tests/fakes.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user