158 lines
5.7 KiB
TypeScript
158 lines
5.7 KiB
TypeScript
// Recording state store (Svelte 5 runes-friendly via a small class).
|
|
// Subscribes to recording/transcript events and exposes reactive state.
|
|
|
|
import { api, events, type TranscriptSegment, type MeetingId } from "../api";
|
|
import { settings } from "./settings.svelte";
|
|
import { SvelteMap } from "svelte/reactivity";
|
|
|
|
class RecordingStore {
|
|
meetingId = $state<MeetingId | null>(null);
|
|
state = $state<"idle" | "recording" | "paused">("idle");
|
|
elapsedMs = $state(0);
|
|
segments = $state<TranscriptSegment[]>([]);
|
|
/** Whether audio is being retained as .wav for the in-flight meeting (ADR-0009). */
|
|
retention = $state(false);
|
|
/** Live system/loopback level for the waveform/meter (FR-CAP-5); 0 when not recording. */
|
|
levelRms = $state(0);
|
|
levelPeak = $state(0);
|
|
/** Live microphone level, overlaid on the meter in a different colour
|
|
* (FR-CAP-7); stays 0 when the mic is disabled or not recording. */
|
|
levelRmsMic = $state(0);
|
|
levelPeakMic = $state(0);
|
|
/** Set while a capture-device reconnect is in progress; cleared on recovery (FR-CAP-6). */
|
|
deviceNotice = $state<string | null>(null);
|
|
/** Live notes redesign: freeform text typed in the Notes pane while recording. */
|
|
notesText = $state("");
|
|
/** anchor_ms (a segment's start_ms) -> note text, for moments annotated this recording. */
|
|
segmentNotes = new SvelteMap<number, string>();
|
|
private notesSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
async init() {
|
|
await events.onRecordingState((p) => {
|
|
const e = p as {
|
|
state: "recording" | "paused" | "stopped" | "cancelled";
|
|
elapsedMs: number;
|
|
};
|
|
const ended = e.state === "stopped" || e.state === "cancelled";
|
|
this.state = ended ? "idle" : (e.state as "recording" | "paused");
|
|
this.elapsedMs = e.elapsedMs ?? this.elapsedMs;
|
|
if (ended) {
|
|
this.levelRms = 0;
|
|
this.levelPeak = 0;
|
|
this.levelRmsMic = 0;
|
|
this.levelPeakMic = 0;
|
|
}
|
|
});
|
|
await events.onRetention((p) => {
|
|
this.retention = p.record;
|
|
});
|
|
await events.onSegment(({ segment }) => {
|
|
// Replace an interim segment with the same id, else append.
|
|
const i = this.segments.findIndex((s) => s.id === segment.id);
|
|
if (i >= 0) this.segments[i] = segment;
|
|
else this.segments.push(segment);
|
|
});
|
|
await events.onLevel(({ rms, peak, mic }) => {
|
|
if (mic) {
|
|
this.levelRmsMic = rms;
|
|
this.levelPeakMic = peak;
|
|
} else {
|
|
this.levelRms = rms;
|
|
this.levelPeak = peak;
|
|
}
|
|
});
|
|
await events.onDeviceChanged(({ recovered, message }) => {
|
|
this.deviceNotice = recovered ? null : message;
|
|
});
|
|
}
|
|
|
|
async start(title?: string, record = false, templateId?: string, calendarEventId?: string) {
|
|
this.segments = [];
|
|
this.retention = record;
|
|
this.deviceNotice = null;
|
|
this.notesText = "";
|
|
this.segmentNotes.clear();
|
|
// T8.7/FR-TRX-4: whatever language is currently configured in Settings
|
|
// becomes this meeting's requested language, persisted on its record.
|
|
const language = settings.settings.whisper_language ?? undefined;
|
|
this.meetingId = await api.startRecording(title, calendarEventId, record, templateId, language);
|
|
this.state = "recording";
|
|
}
|
|
|
|
async stop() {
|
|
// The debounced save below can lag up to 500ms behind typing — flush
|
|
// whatever's pending first so stop_recording's merge sees the latest text.
|
|
await this.flushNotes();
|
|
if (this.meetingId) await api.stopRecording(this.meetingId);
|
|
this.state = "idle";
|
|
this.levelRms = 0;
|
|
this.levelPeak = 0;
|
|
this.levelRmsMic = 0;
|
|
this.levelPeakMic = 0;
|
|
this.deviceNotice = null;
|
|
}
|
|
|
|
/** Abandon an accidental recording: stop capture and delete it entirely. */
|
|
async cancel() {
|
|
if (this.notesSaveTimer) {
|
|
clearTimeout(this.notesSaveTimer);
|
|
this.notesSaveTimer = null;
|
|
}
|
|
if (this.meetingId) await api.cancelRecording(this.meetingId);
|
|
this.meetingId = null;
|
|
this.segments = [];
|
|
this.state = "idle";
|
|
this.levelRms = 0;
|
|
this.levelPeak = 0;
|
|
this.levelRmsMic = 0;
|
|
this.levelPeakMic = 0;
|
|
this.deviceNotice = null;
|
|
this.notesText = "";
|
|
this.segmentNotes.clear();
|
|
}
|
|
|
|
/** Toggle audio retention mid-meeting (FR-REC-1); caller must have gated consent already. */
|
|
async setRetention(record: boolean) {
|
|
if (!this.meetingId) return;
|
|
await api.setRecordingRetention(this.meetingId, record);
|
|
this.retention = record;
|
|
}
|
|
|
|
/**
|
|
* Live notes redesign: freeform text typed in the Notes pane while
|
|
* recording. Debounced 500ms (same cadence as the post-finalize editor's
|
|
* `scheduleSave`) so every keystroke doesn't round-trip to the backend.
|
|
*/
|
|
setNotesText(text: string) {
|
|
this.notesText = text;
|
|
if (!this.meetingId) return;
|
|
const meetingId = this.meetingId;
|
|
if (this.notesSaveTimer) clearTimeout(this.notesSaveTimer);
|
|
this.notesSaveTimer = setTimeout(() => {
|
|
this.notesSaveTimer = null;
|
|
api.updateLiveNotes(meetingId, text).catch(() => {});
|
|
}, 500);
|
|
}
|
|
|
|
private async flushNotes() {
|
|
if (this.notesSaveTimer) {
|
|
clearTimeout(this.notesSaveTimer);
|
|
this.notesSaveTimer = null;
|
|
}
|
|
if (this.meetingId) {
|
|
await api.updateLiveNotes(this.meetingId, this.notesText).catch(() => {});
|
|
}
|
|
}
|
|
|
|
/** Attach (or clear, with `text: ""`) a note to a clicked transcript
|
|
* segment's moment (the "click a transcript line, add a note" feature). */
|
|
async setSegmentNote(anchorMs: number, text: string) {
|
|
if (!this.meetingId) return;
|
|
if (text.trim() === "") this.segmentNotes.delete(anchorMs);
|
|
else this.segmentNotes.set(anchorMs, text);
|
|
await api.setSegmentNote(this.meetingId, anchorMs, text);
|
|
}
|
|
}
|
|
|
|
export const recording = new RecordingStore();
|