markupr 2.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/commands/review-feedback.md +47 -0
- package/.eslintrc.json +35 -0
- package/.github/CODEOWNERS +16 -0
- package/.github/FUNDING.yml +1 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +56 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +54 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +89 -0
- package/.github/dependabot.yml +70 -0
- package/.github/workflows/ci.yml +184 -0
- package/.github/workflows/deploy-landing.yml +134 -0
- package/.github/workflows/nightly.yml +288 -0
- package/.github/workflows/release.yml +318 -0
- package/CHANGELOG.md +127 -0
- package/CLAUDE.md +137 -0
- package/CODE_OF_CONDUCT.md +9 -0
- package/CONTRIBUTING.md +390 -0
- package/LICENSE +21 -0
- package/PRODUCT_VISION.md +277 -0
- package/README.md +517 -0
- package/SECURITY.md +51 -0
- package/SIGNING_INSTRUCTIONS.md +284 -0
- package/assets/DMG_BACKGROUND_INSTRUCTIONS.md +130 -0
- package/assets/svg-source/dmg-background.svg +70 -0
- package/assets/svg-source/icon.svg +20 -0
- package/assets/svg-source/tray-icon-processing.svg +7 -0
- package/assets/svg-source/tray-icon-recording.svg +7 -0
- package/assets/svg-source/tray-icon.svg +6 -0
- package/assets/tray-complete.png +0 -0
- package/assets/tray-complete@2x.png +0 -0
- package/assets/tray-completeTemplate.png +0 -0
- package/assets/tray-completeTemplate@2x.png +0 -0
- package/assets/tray-error.png +0 -0
- package/assets/tray-error@2x.png +0 -0
- package/assets/tray-errorTemplate.png +0 -0
- package/assets/tray-errorTemplate@2x.png +0 -0
- package/assets/tray-icon-processing.png +0 -0
- package/assets/tray-icon-processing@2x.png +0 -0
- package/assets/tray-icon-processingTemplate.png +0 -0
- package/assets/tray-icon-processingTemplate@2x.png +0 -0
- package/assets/tray-icon-recording.png +0 -0
- package/assets/tray-icon-recording@2x.png +0 -0
- package/assets/tray-icon-recordingTemplate.png +0 -0
- package/assets/tray-icon-recordingTemplate@2x.png +0 -0
- package/assets/tray-icon.png +0 -0
- package/assets/tray-icon@2x.png +0 -0
- package/assets/tray-iconTemplate.png +0 -0
- package/assets/tray-iconTemplate@2x.png +0 -0
- package/assets/tray-idle.png +0 -0
- package/assets/tray-idle@2x.png +0 -0
- package/assets/tray-idleTemplate.png +0 -0
- package/assets/tray-idleTemplate@2x.png +0 -0
- package/assets/tray-processing-0.png +0 -0
- package/assets/tray-processing-0@2x.png +0 -0
- package/assets/tray-processing-0Template.png +0 -0
- package/assets/tray-processing-0Template@2x.png +0 -0
- package/assets/tray-processing-1.png +0 -0
- package/assets/tray-processing-1@2x.png +0 -0
- package/assets/tray-processing-1Template.png +0 -0
- package/assets/tray-processing-1Template@2x.png +0 -0
- package/assets/tray-processing-2.png +0 -0
- package/assets/tray-processing-2@2x.png +0 -0
- package/assets/tray-processing-2Template.png +0 -0
- package/assets/tray-processing-2Template@2x.png +0 -0
- package/assets/tray-processing-3.png +0 -0
- package/assets/tray-processing-3@2x.png +0 -0
- package/assets/tray-processing-3Template.png +0 -0
- package/assets/tray-processing-3Template@2x.png +0 -0
- package/assets/tray-processing.png +0 -0
- package/assets/tray-processing@2x.png +0 -0
- package/assets/tray-processingTemplate.png +0 -0
- package/assets/tray-processingTemplate@2x.png +0 -0
- package/assets/tray-recording.png +0 -0
- package/assets/tray-recording@2x.png +0 -0
- package/assets/tray-recordingTemplate.png +0 -0
- package/assets/tray-recordingTemplate@2x.png +0 -0
- package/build/DMG_BACKGROUND_SPEC.md +50 -0
- package/build/dmg-background.png +0 -0
- package/build/dmg-background@2x.png +0 -0
- package/build/entitlements.mac.inherit.plist +27 -0
- package/build/entitlements.mac.plist +41 -0
- package/build/favicon-16.png +0 -0
- package/build/favicon-180.png +0 -0
- package/build/favicon-192.png +0 -0
- package/build/favicon-32.png +0 -0
- package/build/favicon-48.png +0 -0
- package/build/favicon-512.png +0 -0
- package/build/favicon-64.png +0 -0
- package/build/icon-128.png +0 -0
- package/build/icon-16.png +0 -0
- package/build/icon-24.png +0 -0
- package/build/icon-256.png +0 -0
- package/build/icon-32.png +0 -0
- package/build/icon-48.png +0 -0
- package/build/icon-64.png +0 -0
- package/build/icon.icns +0 -0
- package/build/icon.ico +0 -0
- package/build/icon.iconset/icon_128x128.png +0 -0
- package/build/icon.iconset/icon_128x128@2x.png +0 -0
- package/build/icon.iconset/icon_16x16.png +0 -0
- package/build/icon.iconset/icon_16x16@2x.png +0 -0
- package/build/icon.iconset/icon_256x256.png +0 -0
- package/build/icon.iconset/icon_256x256@2x.png +0 -0
- package/build/icon.iconset/icon_32x32.png +0 -0
- package/build/icon.iconset/icon_32x32@2x.png +0 -0
- package/build/icon.iconset/icon_512x512.png +0 -0
- package/build/icon.iconset/icon_512x512@2x.png +0 -0
- package/build/icon.png +0 -0
- package/build/installer-header.bmp +0 -0
- package/build/installer-header.png +0 -0
- package/build/installer-sidebar.bmp +0 -0
- package/build/installer-sidebar.png +0 -0
- package/build/installer.nsh +45 -0
- package/build/overlay-processing.png +0 -0
- package/build/overlay-recording.png +0 -0
- package/build/toolbar-record.png +0 -0
- package/build/toolbar-screenshot.png +0 -0
- package/build/toolbar-settings.png +0 -0
- package/build/toolbar-stop.png +0 -0
- package/dist/main/index.mjs +12612 -0
- package/dist/preload/index.mjs +907 -0
- package/dist/renderer/assets/index-CCmUjl9K.js +19495 -0
- package/dist/renderer/assets/index-CUqz_Gs6.css +2270 -0
- package/dist/renderer/index.html +27 -0
- package/docs/AI_AGENT_QUICKSTART.md +42 -0
- package/docs/AI_PIPELINE_DESIGN.md +595 -0
- package/docs/API.md +514 -0
- package/docs/ARCHITECTURE.md +460 -0
- package/docs/CONFIGURATION.md +336 -0
- package/docs/DEVELOPMENT.md +508 -0
- package/docs/EXPORT_FORMATS.md +451 -0
- package/docs/GETTING_STARTED.md +236 -0
- package/docs/KEYBOARD_SHORTCUTS.md +334 -0
- package/docs/TROUBLESHOOTING.md +418 -0
- package/docs/landing/index.html +672 -0
- package/docs/landing/script.js +342 -0
- package/docs/landing/styles.css +1543 -0
- package/electron-builder.yml +140 -0
- package/electron.vite.config.ts +63 -0
- package/package.json +108 -0
- package/railway.json +12 -0
- package/scripts/build.mjs +51 -0
- package/scripts/generate-icons.mjs +314 -0
- package/scripts/generate-installer-images.cjs +253 -0
- package/scripts/generate-tray-icons.mjs +258 -0
- package/scripts/notarize.cjs +180 -0
- package/scripts/one-click-clean-test.sh +147 -0
- package/scripts/postinstall.mjs +36 -0
- package/scripts/setup-markupr.sh +55 -0
- package/setup +17 -0
- package/site/index.html +1835 -0
- package/site/package.json +11 -0
- package/site/railway.json +12 -0
- package/site/server.js +31 -0
- package/src/main/AutoUpdater.ts +392 -0
- package/src/main/CrashRecovery.ts +655 -0
- package/src/main/ErrorHandler.ts +703 -0
- package/src/main/HotkeyManager.ts +399 -0
- package/src/main/MenuManager.ts +529 -0
- package/src/main/PermissionManager.ts +420 -0
- package/src/main/SessionController.ts +1465 -0
- package/src/main/TrayManager.ts +540 -0
- package/src/main/ai/AIPipelineManager.ts +199 -0
- package/src/main/ai/ClaudeAnalyzer.ts +339 -0
- package/src/main/ai/ImageOptimizer.ts +176 -0
- package/src/main/ai/StructuredMarkdownBuilder.ts +379 -0
- package/src/main/ai/index.ts +16 -0
- package/src/main/ai/types.ts +258 -0
- package/src/main/analysis/ClarificationGenerator.ts +385 -0
- package/src/main/analysis/FeedbackAnalyzer.ts +531 -0
- package/src/main/analysis/index.ts +19 -0
- package/src/main/audio/AudioCapture.ts +978 -0
- package/src/main/audio/audioUtils.ts +100 -0
- package/src/main/audio/index.ts +20 -0
- package/src/main/capture/index.ts +1 -0
- package/src/main/index.ts +1693 -0
- package/src/main/ipc/captureHandlers.ts +272 -0
- package/src/main/ipc/index.ts +45 -0
- package/src/main/ipc/outputHandlers.ts +302 -0
- package/src/main/ipc/sessionHandlers.ts +56 -0
- package/src/main/ipc/settingsHandlers.ts +471 -0
- package/src/main/ipc/types.ts +56 -0
- package/src/main/ipc/windowHandlers.ts +277 -0
- package/src/main/output/ClipboardService.ts +369 -0
- package/src/main/output/ExportService.ts +539 -0
- package/src/main/output/FileManager.ts +416 -0
- package/src/main/output/MarkdownGenerator.ts +791 -0
- package/src/main/output/MarkdownPatcher.ts +299 -0
- package/src/main/output/index.ts +186 -0
- package/src/main/output/sessionAdapter.ts +207 -0
- package/src/main/output/templates/html-template.ts +553 -0
- package/src/main/pipeline/FrameExtractor.ts +330 -0
- package/src/main/pipeline/PostProcessor.ts +399 -0
- package/src/main/pipeline/TranscriptAnalyzer.ts +226 -0
- package/src/main/pipeline/index.ts +36 -0
- package/src/main/platform/WindowsTaskbar.ts +600 -0
- package/src/main/platform/index.ts +16 -0
- package/src/main/settings/SettingsManager.ts +730 -0
- package/src/main/settings/index.ts +19 -0
- package/src/main/transcription/ModelDownloadManager.ts +494 -0
- package/src/main/transcription/TierManager.ts +219 -0
- package/src/main/transcription/TranscriptionRecoveryService.ts +340 -0
- package/src/main/transcription/WhisperService.ts +748 -0
- package/src/main/transcription/index.ts +56 -0
- package/src/main/transcription/types.ts +135 -0
- package/src/main/windows/PopoverManager.ts +284 -0
- package/src/main/windows/TaskbarIntegration.ts +452 -0
- package/src/main/windows/index.ts +23 -0
- package/src/preload/index.ts +1047 -0
- package/src/renderer/App.tsx +515 -0
- package/src/renderer/AppWrapper.tsx +28 -0
- package/src/renderer/assets/logo-dark.svg +7 -0
- package/src/renderer/assets/logo.svg +7 -0
- package/src/renderer/audio/AudioCaptureRenderer.ts +454 -0
- package/src/renderer/capture/ScreenRecordingRenderer.ts +492 -0
- package/src/renderer/components/AnnotationOverlay.tsx +836 -0
- package/src/renderer/components/AudioWaveform.tsx +811 -0
- package/src/renderer/components/ClarificationQuestions.tsx +656 -0
- package/src/renderer/components/CountdownTimer.tsx +495 -0
- package/src/renderer/components/CrashRecoveryDialog.tsx +632 -0
- package/src/renderer/components/DonateButton.tsx +127 -0
- package/src/renderer/components/ErrorBoundary.tsx +308 -0
- package/src/renderer/components/ExportDialog.tsx +872 -0
- package/src/renderer/components/HotkeyHint.tsx +261 -0
- package/src/renderer/components/KeyboardShortcuts.tsx +787 -0
- package/src/renderer/components/ModelDownloadDialog.tsx +844 -0
- package/src/renderer/components/Onboarding.tsx +1830 -0
- package/src/renderer/components/ProcessingOverlay.tsx +157 -0
- package/src/renderer/components/RecordingOverlay.tsx +423 -0
- package/src/renderer/components/SessionHistory.tsx +1746 -0
- package/src/renderer/components/SessionReview.tsx +1321 -0
- package/src/renderer/components/SettingsPanel.tsx +217 -0
- package/src/renderer/components/Skeleton.tsx +347 -0
- package/src/renderer/components/StatusIndicator.tsx +86 -0
- package/src/renderer/components/ThemeProvider.tsx +429 -0
- package/src/renderer/components/Tooltip.tsx +370 -0
- package/src/renderer/components/TranscriptionPreview.tsx +183 -0
- package/src/renderer/components/TranscriptionTierSelector.tsx +640 -0
- package/src/renderer/components/UpdateNotification.tsx +377 -0
- package/src/renderer/components/WindowSelector.tsx +947 -0
- package/src/renderer/components/index.ts +99 -0
- package/src/renderer/components/primitives/ApiKeyInput.tsx +98 -0
- package/src/renderer/components/primitives/ColorPicker.tsx +65 -0
- package/src/renderer/components/primitives/DangerButton.tsx +45 -0
- package/src/renderer/components/primitives/DirectoryPicker.tsx +41 -0
- package/src/renderer/components/primitives/Dropdown.tsx +34 -0
- package/src/renderer/components/primitives/KeyRecorder.tsx +117 -0
- package/src/renderer/components/primitives/SettingsSection.tsx +32 -0
- package/src/renderer/components/primitives/Slider.tsx +43 -0
- package/src/renderer/components/primitives/Toggle.tsx +36 -0
- package/src/renderer/components/primitives/index.ts +10 -0
- package/src/renderer/components/settings/AdvancedTab.tsx +174 -0
- package/src/renderer/components/settings/AppearanceTab.tsx +77 -0
- package/src/renderer/components/settings/GeneralTab.tsx +40 -0
- package/src/renderer/components/settings/HotkeysTab.tsx +79 -0
- package/src/renderer/components/settings/RecordingTab.tsx +84 -0
- package/src/renderer/components/settings/index.ts +9 -0
- package/src/renderer/components/settings/settingsStyles.ts +673 -0
- package/src/renderer/components/settings/tabConfig.tsx +85 -0
- package/src/renderer/components/settings/useSettingsPanel.ts +447 -0
- package/src/renderer/contexts/ProcessingContext.tsx +227 -0
- package/src/renderer/contexts/RecordingContext.tsx +683 -0
- package/src/renderer/contexts/UIContext.tsx +326 -0
- package/src/renderer/contexts/index.ts +24 -0
- package/src/renderer/donateMessages.ts +69 -0
- package/src/renderer/hooks/index.ts +75 -0
- package/src/renderer/hooks/useAnimation.tsx +544 -0
- package/src/renderer/hooks/useTheme.ts +313 -0
- package/src/renderer/index.html +26 -0
- package/src/renderer/main.tsx +52 -0
- package/src/renderer/styles/animations.css +1093 -0
- package/src/renderer/styles/app-shell.css +662 -0
- package/src/renderer/styles/globals.css +515 -0
- package/src/renderer/styles/theme.ts +578 -0
- package/src/renderer/types/electron.d.ts +385 -0
- package/src/shared/hotkeys.ts +283 -0
- package/src/shared/types.ts +809 -0
- package/tests/clipboard.test.ts +228 -0
- package/tests/e2e/criticalPaths.test.ts +594 -0
- package/tests/feedbackAnalyzer.test.ts +303 -0
- package/tests/integration/sessionFlow.test.ts +583 -0
- package/tests/markdownGenerator.test.ts +418 -0
- package/tests/output.test.ts +96 -0
- package/tests/setup.ts +486 -0
- package/tests/unit/appIntegration.test.ts +676 -0
- package/tests/unit/appViewState.test.ts +281 -0
- package/tests/unit/audioIpcChannels.test.ts +17 -0
- package/tests/unit/exportService.test.ts +492 -0
- package/tests/unit/hotkeys.test.ts +92 -0
- package/tests/unit/navigationPreload.test.ts +94 -0
- package/tests/unit/onboardingFlow.test.ts +345 -0
- package/tests/unit/permissionManager.test.ts +175 -0
- package/tests/unit/permissionManagerExpanded.test.ts +296 -0
- package/tests/unit/screenRecordingRenderer.test.ts +368 -0
- package/tests/unit/sessionController.test.ts +515 -0
- package/tests/unit/tierManager.test.ts +61 -0
- package/tests/unit/tierManagerExpanded.test.ts +142 -0
- package/tests/unit/transcriptAnalyzer.test.ts +64 -0
- package/tsconfig.json +25 -0
- package/vitest.config.ts +46 -0
|
@@ -0,0 +1,1465 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionController - Core Orchestrator for markupr
|
|
3
|
+
*
|
|
4
|
+
* Implements a bulletproof finite state machine for session lifecycle:
|
|
5
|
+
* idle -> starting -> recording -> stopping -> processing -> complete
|
|
6
|
+
*
|
|
7
|
+
* Responsibilities:
|
|
8
|
+
* - Coordinate all services (audio, video recording)
|
|
9
|
+
* - Manage session state (crash recovery delegated to CrashRecoveryManager)
|
|
10
|
+
* - Watchdog timer to prevent stuck states
|
|
11
|
+
* - Run PostProcessor pipeline after recording stops
|
|
12
|
+
* - Emit state changes and processing progress to renderer via IPC
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import Store from 'electron-store';
|
|
16
|
+
import { BrowserWindow } from 'electron';
|
|
17
|
+
import { randomUUID } from 'crypto';
|
|
18
|
+
import { audioCapture, type AudioChunk } from './audio/AudioCapture';
|
|
19
|
+
import type { TranscriptEvent } from './transcription/types';
|
|
20
|
+
import { recoverTranscript, normalizeTranscriptTimestamp } from './transcription/TranscriptionRecoveryService';
|
|
21
|
+
import { IPC_CHANNELS, type SessionState, type SessionMetadata } from '../shared/types';
|
|
22
|
+
import { errorHandler } from './ErrorHandler';
|
|
23
|
+
import { type PostProcessResult, type PostProcessProgress } from './pipeline';
|
|
24
|
+
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// Types - Bulletproof State Machine
|
|
27
|
+
// =============================================================================
|
|
28
|
+
|
|
29
|
+
// SessionState is imported from '../shared/types' (single source of truth)
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* State timeout configuration (in milliseconds).
|
|
33
|
+
* Every state except 'idle' has a maximum duration.
|
|
34
|
+
*/
|
|
35
|
+
export const STATE_TIMEOUTS: Record<SessionState, number | null> = {
|
|
36
|
+
idle: null, // Infinite - waits for user action
|
|
37
|
+
starting: 5_000, // 5 seconds to initialize
|
|
38
|
+
recording: 30 * 60_000, // 30 minutes max recording
|
|
39
|
+
stopping: 3_000, // 3 seconds to stop services
|
|
40
|
+
processing: 5 * 60_000, // 5 minutes for post-processing pipeline
|
|
41
|
+
complete: 5 * 60_000, // 5 minutes for post-processing to finish
|
|
42
|
+
error: 5_000, // 5 seconds to show error
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Recording duration warnings and limits
|
|
47
|
+
*/
|
|
48
|
+
export const RECORDING_LIMITS = {
|
|
49
|
+
WARNING_DURATION_MS: 25 * 60_000, // 25 minutes - show warning
|
|
50
|
+
MAX_DURATION_MS: 30 * 60_000, // 30 minutes - force stop
|
|
51
|
+
} as const;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @deprecated Screenshot capture during recording has been removed.
|
|
55
|
+
* Frame extraction now happens in the post-processing pipeline.
|
|
56
|
+
* This interface is kept for backward compatibility with downstream consumers.
|
|
57
|
+
*/
|
|
58
|
+
export interface Screenshot {
|
|
59
|
+
id: string;
|
|
60
|
+
timestamp: number;
|
|
61
|
+
buffer: Buffer;
|
|
62
|
+
width: number;
|
|
63
|
+
height: number;
|
|
64
|
+
base64?: string;
|
|
65
|
+
trigger?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface FeedbackItem {
|
|
69
|
+
id: string;
|
|
70
|
+
timestamp: number;
|
|
71
|
+
text: string;
|
|
72
|
+
confidence: number;
|
|
73
|
+
/**
|
|
74
|
+
* @deprecated Screenshots are no longer captured during recording.
|
|
75
|
+
* Extracted frames are available via PostProcessResult after processing.
|
|
76
|
+
*/
|
|
77
|
+
screenshot?: Screenshot;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// SessionMetadata is imported from '../shared/types' (single source of truth)
|
|
81
|
+
|
|
82
|
+
export interface Session {
|
|
83
|
+
id: string;
|
|
84
|
+
startTime: number;
|
|
85
|
+
endTime?: number;
|
|
86
|
+
state: SessionState;
|
|
87
|
+
sourceId: string;
|
|
88
|
+
feedbackItems: FeedbackItem[];
|
|
89
|
+
transcriptBuffer: TranscriptEvent[];
|
|
90
|
+
/**
|
|
91
|
+
* @deprecated Screenshots are no longer captured during recording.
|
|
92
|
+
* Kept for backward compatibility; always an empty array.
|
|
93
|
+
*/
|
|
94
|
+
screenshotBuffer: Screenshot[];
|
|
95
|
+
metadata: SessionMetadata;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface SessionStatus {
|
|
99
|
+
state: SessionState;
|
|
100
|
+
duration: number;
|
|
101
|
+
feedbackCount: number;
|
|
102
|
+
screenshotCount: number;
|
|
103
|
+
isPaused: boolean;
|
|
104
|
+
/** Post-processing progress (only set when state === 'processing') */
|
|
105
|
+
processingProgress?: PostProcessProgress;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface SessionControllerEvents {
|
|
109
|
+
onStateChange: (state: SessionState, session: Session | null) => void;
|
|
110
|
+
onFeedbackItem: (item: FeedbackItem) => void;
|
|
111
|
+
onError: (error: Error) => void;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Valid state transitions.
|
|
116
|
+
* Every state has a path back to 'idle' for recovery.
|
|
117
|
+
*/
|
|
118
|
+
const STATE_TRANSITIONS: Record<SessionState, SessionState[]> = {
|
|
119
|
+
idle: ['starting'],
|
|
120
|
+
starting: ['recording', 'error', 'idle'], // success, error, or cancel
|
|
121
|
+
recording: ['stopping', 'error', 'idle'], // stop, error, or cancel
|
|
122
|
+
stopping: ['processing', 'error', 'idle'], // success, error, or cancel
|
|
123
|
+
processing: ['complete', 'error', 'idle'], // success, error, or timeout
|
|
124
|
+
complete: ['idle'], // only back to idle
|
|
125
|
+
error: ['idle'], // only back to idle
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// =============================================================================
|
|
129
|
+
// Persistence Store Schema
|
|
130
|
+
// =============================================================================
|
|
131
|
+
|
|
132
|
+
// Session data for persistence (without Buffer objects)
|
|
133
|
+
interface PersistedSession {
|
|
134
|
+
id: string;
|
|
135
|
+
startTime: number;
|
|
136
|
+
endTime?: number;
|
|
137
|
+
state: SessionState;
|
|
138
|
+
sourceId: string;
|
|
139
|
+
feedbackItemCount: number;
|
|
140
|
+
metadata: SessionMetadata;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
interface StoreSchema {
|
|
144
|
+
currentSession: PersistedSession | null;
|
|
145
|
+
recentSessions: PersistedSession[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const store = new Store<StoreSchema>({
|
|
149
|
+
name: 'markupr-sessions',
|
|
150
|
+
defaults: {
|
|
151
|
+
currentSession: null,
|
|
152
|
+
recentSessions: [],
|
|
153
|
+
},
|
|
154
|
+
// Clear on corruption
|
|
155
|
+
clearInvalidConfig: true,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// =============================================================================
|
|
159
|
+
// SessionController Class
|
|
160
|
+
// =============================================================================
|
|
161
|
+
|
|
162
|
+
export class SessionController {
|
|
163
|
+
// Core state
|
|
164
|
+
private state: SessionState = 'idle';
|
|
165
|
+
private isPaused = false;
|
|
166
|
+
private pausedAtMs: number | null = null;
|
|
167
|
+
private accumulatedPausedMs = 0;
|
|
168
|
+
private captureCount = 0;
|
|
169
|
+
private session: Session | null = null;
|
|
170
|
+
private events: SessionControllerEvents | null = null;
|
|
171
|
+
private mainWindow: BrowserWindow | null = null;
|
|
172
|
+
|
|
173
|
+
// Service references (using actual implementations)
|
|
174
|
+
private audioCaptureService: typeof audioCapture;
|
|
175
|
+
|
|
176
|
+
// Cleanup functions for event subscriptions
|
|
177
|
+
private cleanupFunctions: Array<() => void> = [];
|
|
178
|
+
|
|
179
|
+
// Timers
|
|
180
|
+
private autoSaveTimer: NodeJS.Timeout | null = null;
|
|
181
|
+
private durationTimer: NodeJS.Timeout | null = null;
|
|
182
|
+
private watchdogTimer: NodeJS.Timeout | null = null;
|
|
183
|
+
|
|
184
|
+
// Watchdog state
|
|
185
|
+
private stateEnteredAt: number = Date.now();
|
|
186
|
+
private recordingWarningShown: boolean = false;
|
|
187
|
+
|
|
188
|
+
// Post-processing result (available after processing completes)
|
|
189
|
+
private postProcessResult: PostProcessResult | null = null;
|
|
190
|
+
|
|
191
|
+
// Current processing progress (for status reporting)
|
|
192
|
+
private currentProcessingProgress: PostProcessProgress | null = null;
|
|
193
|
+
|
|
194
|
+
// Configuration constants
|
|
195
|
+
private readonly AUTO_SAVE_INTERVAL_MS = 5000; // 5 seconds (per spec)
|
|
196
|
+
private readonly WATCHDOG_CHECK_INTERVAL_MS = 1000; // 1 second
|
|
197
|
+
private readonly MAX_RECENT_SESSIONS = 10;
|
|
198
|
+
private readonly MAX_TRANSCRIPT_BUFFER_EVENTS = 2000;
|
|
199
|
+
|
|
200
|
+
private getActiveDurationMs(nowMs: number = Date.now()): number {
|
|
201
|
+
if (!this.session) {
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const activePauseWindowMs =
|
|
206
|
+
this.isPaused && this.pausedAtMs !== null
|
|
207
|
+
? Math.max(0, nowMs - this.pausedAtMs)
|
|
208
|
+
: 0;
|
|
209
|
+
|
|
210
|
+
return Math.max(
|
|
211
|
+
0,
|
|
212
|
+
nowMs - this.session.startTime - this.accumulatedPausedMs - activePauseWindowMs
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
private closeActivePauseWindow(nowMs: number = Date.now()): void {
|
|
217
|
+
if (this.pausedAtMs === null) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
this.accumulatedPausedMs += Math.max(0, nowMs - this.pausedAtMs);
|
|
221
|
+
this.pausedAtMs = null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private resetSessionRuntimeState(): void {
|
|
225
|
+
this.isPaused = false;
|
|
226
|
+
this.pausedAtMs = null;
|
|
227
|
+
this.accumulatedPausedMs = 0;
|
|
228
|
+
this.captureCount = 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
constructor() {
|
|
232
|
+
// Use singleton instances
|
|
233
|
+
this.audioCaptureService = audioCapture;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ===========================================================================
|
|
237
|
+
// Timeout Utilities
|
|
238
|
+
// ===========================================================================
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Wraps an async operation with a timeout.
|
|
242
|
+
* If the operation takes longer than timeoutMs, returns the fallback value.
|
|
243
|
+
*
|
|
244
|
+
* @param operation - The promise to wrap
|
|
245
|
+
* @param timeoutMs - Maximum time to wait in milliseconds
|
|
246
|
+
* @param fallback - Value to return if timeout occurs
|
|
247
|
+
* @param operationName - Name for logging purposes
|
|
248
|
+
* @returns Promise resolving to either the operation result or the fallback
|
|
249
|
+
*/
|
|
250
|
+
private async withTimeout<T>(
|
|
251
|
+
operation: Promise<T>,
|
|
252
|
+
timeoutMs: number,
|
|
253
|
+
fallback: T,
|
|
254
|
+
operationName: string = 'operation'
|
|
255
|
+
): Promise<T> {
|
|
256
|
+
let timeoutId: NodeJS.Timeout | undefined;
|
|
257
|
+
|
|
258
|
+
const timeoutPromise = new Promise<T>((_, reject) => {
|
|
259
|
+
timeoutId = setTimeout(() => {
|
|
260
|
+
reject(new Error(`${operationName} timed out after ${timeoutMs}ms`));
|
|
261
|
+
}, timeoutMs);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
const result = await Promise.race([operation, timeoutPromise]);
|
|
266
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
267
|
+
return result;
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (timeoutId) clearTimeout(timeoutId);
|
|
270
|
+
console.warn(`[SessionController] ${operationName} timeout/error, using fallback:`, error);
|
|
271
|
+
return fallback;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Wraps a synchronous function that may block, converting it to async with timeout.
|
|
277
|
+
* Useful for wrapping service.stop() calls that don't return promises.
|
|
278
|
+
*/
|
|
279
|
+
private async withTimeoutSync(
|
|
280
|
+
fn: () => void,
|
|
281
|
+
timeoutMs: number,
|
|
282
|
+
operationName: string = 'operation'
|
|
283
|
+
): Promise<void> {
|
|
284
|
+
return this.withTimeout(
|
|
285
|
+
new Promise<void>((resolve) => {
|
|
286
|
+
try {
|
|
287
|
+
fn();
|
|
288
|
+
resolve();
|
|
289
|
+
} catch (error) {
|
|
290
|
+
console.warn(`[SessionController] ${operationName} threw:`, error);
|
|
291
|
+
resolve(); // Still resolve - we don't want to block
|
|
292
|
+
}
|
|
293
|
+
}),
|
|
294
|
+
timeoutMs,
|
|
295
|
+
undefined,
|
|
296
|
+
operationName
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Initialize the SessionController.
|
|
302
|
+
* Crash recovery is handled by CrashRecoveryManager (single authority).
|
|
303
|
+
*/
|
|
304
|
+
async initialize(): Promise<void> {
|
|
305
|
+
console.log('[SessionController] Initializing...');
|
|
306
|
+
|
|
307
|
+
// Start watchdog immediately
|
|
308
|
+
this.startWatchdog();
|
|
309
|
+
|
|
310
|
+
console.log('[SessionController] Initialization complete');
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Set the main window for IPC communication
|
|
315
|
+
*/
|
|
316
|
+
setMainWindow(window: BrowserWindow): void {
|
|
317
|
+
this.mainWindow = window;
|
|
318
|
+
// Also set on audio capture service (it needs window for renderer communication)
|
|
319
|
+
this.audioCaptureService.setMainWindow(window);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Set event callbacks
|
|
324
|
+
*/
|
|
325
|
+
setEventCallbacks(events: SessionControllerEvents): void {
|
|
326
|
+
this.events = events;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Configure transcription service with API key
|
|
331
|
+
* Note: TierManager handles API key configuration internally via settings
|
|
332
|
+
* This method is kept for backward compatibility but is now a no-op
|
|
333
|
+
*/
|
|
334
|
+
configureTranscription(_apiKey: string): void {
|
|
335
|
+
// TierManager reads API key from settings automatically
|
|
336
|
+
// No action needed here
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ===========================================================================
|
|
340
|
+
// State Machine
|
|
341
|
+
// ===========================================================================
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Transition to a new state with validation.
|
|
345
|
+
* Updates state entry time for watchdog tracking.
|
|
346
|
+
*/
|
|
347
|
+
private transition(newState: SessionState): boolean {
|
|
348
|
+
const validTransitions = STATE_TRANSITIONS[this.state];
|
|
349
|
+
|
|
350
|
+
if (!validTransitions.includes(newState)) {
|
|
351
|
+
console.error(
|
|
352
|
+
`[SessionController] Invalid state transition: ${this.state} -> ${newState}`
|
|
353
|
+
);
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const oldState = this.state;
|
|
358
|
+
this.state = newState;
|
|
359
|
+
this.stateEnteredAt = Date.now();
|
|
360
|
+
this.recordingWarningShown = false; // Reset for new state
|
|
361
|
+
|
|
362
|
+
// Update session state if exists
|
|
363
|
+
if (this.session) {
|
|
364
|
+
this.session.state = newState;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
console.log(`[SessionController] State: ${oldState} -> ${newState}`);
|
|
368
|
+
|
|
369
|
+
// Notify listeners
|
|
370
|
+
this.emitStateChange();
|
|
371
|
+
|
|
372
|
+
return true;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Get current state
|
|
377
|
+
*/
|
|
378
|
+
getState(): SessionState {
|
|
379
|
+
return this.state;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ===========================================================================
|
|
383
|
+
// Session Lifecycle
|
|
384
|
+
// ===========================================================================
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Start a new recording session.
|
|
388
|
+
* Transitions: idle -> starting -> recording
|
|
389
|
+
*/
|
|
390
|
+
async start(sourceId: string, sourceName?: string): Promise<void> {
|
|
391
|
+
if (this.state !== 'idle') {
|
|
392
|
+
throw new Error(`Cannot start session from state: ${this.state}`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
console.log(`[SessionController] Starting session for source: ${sourceId}`);
|
|
396
|
+
|
|
397
|
+
// Transition to starting state FIRST
|
|
398
|
+
if (!this.transition('starting')) {
|
|
399
|
+
throw new Error('Failed to transition to starting state');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Reset counters
|
|
403
|
+
this.audioChunkCount = 0;
|
|
404
|
+
this.resetSessionRuntimeState();
|
|
405
|
+
this.postProcessResult = null;
|
|
406
|
+
this.currentProcessingProgress = null;
|
|
407
|
+
|
|
408
|
+
// Create new session
|
|
409
|
+
this.session = {
|
|
410
|
+
id: randomUUID(),
|
|
411
|
+
startTime: Date.now(),
|
|
412
|
+
state: 'starting',
|
|
413
|
+
sourceId,
|
|
414
|
+
feedbackItems: [],
|
|
415
|
+
transcriptBuffer: [],
|
|
416
|
+
screenshotBuffer: [],
|
|
417
|
+
metadata: {
|
|
418
|
+
sourceId,
|
|
419
|
+
sourceName,
|
|
420
|
+
},
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
// Initialize services with timeout protection
|
|
425
|
+
await this.initializeServicesWithTimeout();
|
|
426
|
+
|
|
427
|
+
// Transition to recording
|
|
428
|
+
if (!this.transition('recording')) {
|
|
429
|
+
throw new Error('Failed to transition to recording state');
|
|
430
|
+
}
|
|
431
|
+
this.session.state = 'recording';
|
|
432
|
+
|
|
433
|
+
// Start timers
|
|
434
|
+
this.startAutoSave();
|
|
435
|
+
this.startDurationTimer();
|
|
436
|
+
|
|
437
|
+
console.log(`[SessionController] Session started: ${this.session.id}`);
|
|
438
|
+
} catch (error) {
|
|
439
|
+
console.error('[SessionController] Failed to start services:', error);
|
|
440
|
+
|
|
441
|
+
// Cleanup on failure
|
|
442
|
+
await this.cleanupServicesAsync();
|
|
443
|
+
this.session = null;
|
|
444
|
+
|
|
445
|
+
// Transition to error state (which will auto-recover to idle)
|
|
446
|
+
try {
|
|
447
|
+
this.transition('error');
|
|
448
|
+
this.emitToRenderer(IPC_CHANNELS.SESSION_ERROR, {
|
|
449
|
+
type: 'startError',
|
|
450
|
+
message: error instanceof Error ? error.message : 'Failed to start session',
|
|
451
|
+
});
|
|
452
|
+
} catch {
|
|
453
|
+
// If transition fails, force to idle
|
|
454
|
+
this.transitionForced('idle');
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
throw error;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Initialize all services with timeout protection.
|
|
463
|
+
* Total timeout: 5 seconds (starting state timeout).
|
|
464
|
+
*/
|
|
465
|
+
private async initializeServicesWithTimeout(): Promise<void> {
|
|
466
|
+
const totalTimeout = STATE_TIMEOUTS.starting!;
|
|
467
|
+
const startTime = Date.now();
|
|
468
|
+
|
|
469
|
+
// Subscribe to audio events
|
|
470
|
+
const unsubAudioChunk = this.audioCaptureService.onAudioChunk((chunk) =>
|
|
471
|
+
this.handleAudioChunk(chunk)
|
|
472
|
+
);
|
|
473
|
+
const unsubVoiceActivity = this.audioCaptureService.onVoiceActivity((active) =>
|
|
474
|
+
this.handleVoiceActivity(active)
|
|
475
|
+
);
|
|
476
|
+
const unsubAudioError = this.audioCaptureService.onError((error) =>
|
|
477
|
+
this.handleServiceError('audio', error)
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
this.cleanupFunctions.push(unsubAudioChunk, unsubVoiceActivity, unsubAudioError);
|
|
481
|
+
|
|
482
|
+
console.log(
|
|
483
|
+
'[SessionController] Live transcription disabled for this workflow; using post-session transcription from captured audio.'
|
|
484
|
+
);
|
|
485
|
+
|
|
486
|
+
// Check if we still have time
|
|
487
|
+
const elapsed2 = Date.now() - startTime;
|
|
488
|
+
const remaining2 = Math.max(totalTimeout - elapsed2, 500);
|
|
489
|
+
|
|
490
|
+
// Start audio capture with timeout
|
|
491
|
+
const audioStarted = await this.withTimeout(
|
|
492
|
+
this.audioCaptureService.start().then(() => true),
|
|
493
|
+
remaining2,
|
|
494
|
+
false,
|
|
495
|
+
'AudioCapture.start()'
|
|
496
|
+
);
|
|
497
|
+
|
|
498
|
+
if (!audioStarted) {
|
|
499
|
+
throw new Error(
|
|
500
|
+
'Microphone capture failed to start. Check microphone permission and selected input device, then retry.'
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Stop the current session and run the post-processing pipeline.
|
|
507
|
+
* Transitions: recording -> stopping -> processing -> complete
|
|
508
|
+
*/
|
|
509
|
+
async stop(): Promise<Session | null> {
|
|
510
|
+
if (this.state !== 'recording') {
|
|
511
|
+
console.warn(`[SessionController] Cannot stop from state: ${this.state}`);
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (!this.session) {
|
|
516
|
+
console.warn('[SessionController] No active session to stop');
|
|
517
|
+
return null;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
console.log(`[SessionController] Stopping session: ${this.session.id}`);
|
|
521
|
+
|
|
522
|
+
// Transition to stopping state
|
|
523
|
+
if (!this.transition('stopping')) {
|
|
524
|
+
console.error('[SessionController] Failed to transition to stopping state');
|
|
525
|
+
return null;
|
|
526
|
+
}
|
|
527
|
+
this.session.state = 'stopping';
|
|
528
|
+
|
|
529
|
+
// Stop services with timeout protection
|
|
530
|
+
await this.withTimeout(
|
|
531
|
+
this.cleanupServicesAsync(),
|
|
532
|
+
STATE_TIMEOUTS.stopping!,
|
|
533
|
+
undefined,
|
|
534
|
+
'cleanupServices'
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
// Transition to processing
|
|
538
|
+
if (!this.transition('processing')) {
|
|
539
|
+
console.error('[SessionController] Failed to transition to processing state');
|
|
540
|
+
// Force complete with partial data
|
|
541
|
+
this.transitionForced('complete');
|
|
542
|
+
}
|
|
543
|
+
this.session.state = 'processing';
|
|
544
|
+
|
|
545
|
+
// NOTE: The full PostProcessor pipeline (transcribe -> analyze -> extract frames)
|
|
546
|
+
// is NOT run here because recordingPath and audioPath are not yet available on
|
|
547
|
+
// session.metadata at this point. Those paths are set later in stopSession()
|
|
548
|
+
// (src/main/index.ts) after the recording and audio files are finalized and
|
|
549
|
+
// written to disk. The real PostProcessor call happens there with the actual
|
|
550
|
+
// file paths. Here we only attempt transcript recovery from the in-memory
|
|
551
|
+
// captured audio buffer as a fallback for when stop() is called directly
|
|
552
|
+
// (e.g., by the watchdog timer) without going through stopSession().
|
|
553
|
+
await this.withTimeout(
|
|
554
|
+
this.recoverTranscriptFromCapturedAudio(),
|
|
555
|
+
Math.floor(STATE_TIMEOUTS.processing! * 0.8),
|
|
556
|
+
undefined,
|
|
557
|
+
'recoverTranscriptFromCapturedAudio'
|
|
558
|
+
);
|
|
559
|
+
|
|
560
|
+
// Set end time
|
|
561
|
+
this.session.endTime = Date.now();
|
|
562
|
+
this.closeActivePauseWindow(this.session.endTime);
|
|
563
|
+
this.isPaused = false;
|
|
564
|
+
this.currentProcessingProgress = null;
|
|
565
|
+
|
|
566
|
+
// Transition to complete
|
|
567
|
+
if (!this.transition('complete')) {
|
|
568
|
+
this.transitionForced('complete');
|
|
569
|
+
}
|
|
570
|
+
this.session.state = 'complete';
|
|
571
|
+
|
|
572
|
+
// Move to recent sessions
|
|
573
|
+
const completedSession = { ...this.session };
|
|
574
|
+
this.addToRecentSessions(completedSession);
|
|
575
|
+
|
|
576
|
+
// Clear current session from store
|
|
577
|
+
store.set('currentSession', null);
|
|
578
|
+
|
|
579
|
+
console.log(
|
|
580
|
+
`[SessionController] Session completed: ${completedSession.id}, ` +
|
|
581
|
+
`${completedSession.feedbackItems.length} feedback items`
|
|
582
|
+
);
|
|
583
|
+
|
|
584
|
+
return completedSession;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Cancel the current session without processing
|
|
589
|
+
*/
|
|
590
|
+
cancel(): void {
|
|
591
|
+
if (this.state !== 'recording' && this.state !== 'processing' && this.state !== 'starting') {
|
|
592
|
+
console.warn(`[SessionController] Cannot cancel from state: ${this.state}`);
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
console.log(`[SessionController] Cancelling session: ${this.session?.id}`);
|
|
597
|
+
|
|
598
|
+
// Force cleanup (don't wait for async)
|
|
599
|
+
this.cleanupServicesForced();
|
|
600
|
+
this.audioCaptureService.clearCapturedAudio();
|
|
601
|
+
|
|
602
|
+
// Clear session
|
|
603
|
+
this.session = null;
|
|
604
|
+
this.resetSessionRuntimeState();
|
|
605
|
+
store.set('currentSession', null);
|
|
606
|
+
|
|
607
|
+
// Force transition to idle (bypass normal validation since we're cancelling)
|
|
608
|
+
this.transitionForced('idle');
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Reset controller to idle state
|
|
613
|
+
*/
|
|
614
|
+
reset(): void {
|
|
615
|
+
this.cleanupServices();
|
|
616
|
+
this.session = null;
|
|
617
|
+
this.postProcessResult = null;
|
|
618
|
+
this.currentProcessingProgress = null;
|
|
619
|
+
this.resetSessionRuntimeState();
|
|
620
|
+
this.state = 'idle';
|
|
621
|
+
this.emitStateChange();
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// ===========================================================================
|
|
625
|
+
// Status & Data Access
|
|
626
|
+
// ===========================================================================
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Get current session status
|
|
630
|
+
*/
|
|
631
|
+
getStatus(): SessionStatus {
|
|
632
|
+
const duration = this.getActiveDurationMs();
|
|
633
|
+
|
|
634
|
+
const status: SessionStatus = {
|
|
635
|
+
state: this.state,
|
|
636
|
+
duration,
|
|
637
|
+
feedbackCount: this.session?.feedbackItems.length ?? 0,
|
|
638
|
+
screenshotCount: this.session ? this.captureCount : 0,
|
|
639
|
+
isPaused: this.state === 'recording' && this.isPaused,
|
|
640
|
+
};
|
|
641
|
+
|
|
642
|
+
if (this.state === 'processing' && this.currentProcessingProgress) {
|
|
643
|
+
status.processingProgress = this.currentProcessingProgress;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return status;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
isSessionPaused(): boolean {
|
|
650
|
+
return this.state === 'recording' && this.isPaused;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
pause(): boolean {
|
|
654
|
+
if (this.state !== 'recording' || this.isPaused) {
|
|
655
|
+
return false;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
this.isPaused = true;
|
|
659
|
+
this.pausedAtMs = Date.now();
|
|
660
|
+
this.audioCaptureService.setPaused(true);
|
|
661
|
+
this.emitToRenderer(IPC_CHANNELS.SESSION_VOICE_ACTIVITY, { active: false });
|
|
662
|
+
this.emitStatus();
|
|
663
|
+
return true;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
resume(): boolean {
|
|
667
|
+
if (this.state !== 'recording' || !this.isPaused) {
|
|
668
|
+
return false;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
this.closeActivePauseWindow();
|
|
672
|
+
this.isPaused = false;
|
|
673
|
+
this.audioCaptureService.setPaused(false);
|
|
674
|
+
this.emitStatus();
|
|
675
|
+
return true;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
registerCaptureCue(
|
|
679
|
+
trigger: 'pause' | 'manual' | 'voice-command' = 'manual'
|
|
680
|
+
): { id: string; timestamp: number; count: number; trigger: 'pause' | 'manual' | 'voice-command' } | null {
|
|
681
|
+
if (this.state !== 'recording' || this.isPaused || !this.session) {
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
this.captureCount += 1;
|
|
686
|
+
const payload = {
|
|
687
|
+
id: randomUUID(),
|
|
688
|
+
timestamp: Date.now(),
|
|
689
|
+
count: this.captureCount,
|
|
690
|
+
trigger,
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
this.emitToRenderer(IPC_CHANNELS.SCREENSHOT_CAPTURED, payload);
|
|
694
|
+
this.emitStatus();
|
|
695
|
+
return payload;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* Get current session
|
|
700
|
+
*/
|
|
701
|
+
getSession(): Session | null {
|
|
702
|
+
return this.session ? { ...this.session } : null;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* Update metadata fields on the active session.
|
|
707
|
+
*/
|
|
708
|
+
setSessionMetadata(updates: Partial<SessionMetadata>): boolean {
|
|
709
|
+
if (!this.session) {
|
|
710
|
+
return false;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
this.session.metadata = {
|
|
714
|
+
...this.session.metadata,
|
|
715
|
+
...updates,
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
this.persistSession();
|
|
719
|
+
return true;
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Export the captured microphone audio for the most recent session.
|
|
724
|
+
*/
|
|
725
|
+
async exportCapturedAudio(
|
|
726
|
+
outputPathBase: string,
|
|
727
|
+
): Promise<{ path: string; bytesWritten: number; durationMs: number; mimeType: string } | null> {
|
|
728
|
+
const exported = await this.audioCaptureService.exportCapturedAudio(outputPathBase);
|
|
729
|
+
if (!exported) {
|
|
730
|
+
return null;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
return exported;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
clearCapturedAudio(): void {
|
|
737
|
+
this.audioCaptureService.clearCapturedAudio();
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Get recent completed sessions (persisted metadata only)
|
|
742
|
+
*/
|
|
743
|
+
getRecentSessions(): PersistedSession[] {
|
|
744
|
+
return store.get('recentSessions') || [];
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Get the post-processing result from the most recent session.
|
|
749
|
+
* Available after the processing state completes.
|
|
750
|
+
*/
|
|
751
|
+
getPostProcessResult(): PostProcessResult | null {
|
|
752
|
+
return this.postProcessResult;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// ===========================================================================
|
|
756
|
+
// Feedback Item Management
|
|
757
|
+
// ===========================================================================
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Add a feedback item manually
|
|
761
|
+
*/
|
|
762
|
+
addFeedbackItem(item: Partial<FeedbackItem>): FeedbackItem {
|
|
763
|
+
if (!this.session) {
|
|
764
|
+
throw new Error('No active session');
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const feedbackItem: FeedbackItem = {
|
|
768
|
+
id: randomUUID(),
|
|
769
|
+
timestamp: Date.now(),
|
|
770
|
+
text: item.text || '',
|
|
771
|
+
confidence: item.confidence ?? 1.0,
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
this.session.feedbackItems.push(feedbackItem);
|
|
775
|
+
this.emitFeedbackItem(feedbackItem);
|
|
776
|
+
this.persistSession();
|
|
777
|
+
|
|
778
|
+
return feedbackItem;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* Delete a feedback item
|
|
783
|
+
*/
|
|
784
|
+
deleteFeedbackItem(id: string): boolean {
|
|
785
|
+
if (!this.session) {
|
|
786
|
+
return false;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const index = this.session.feedbackItems.findIndex((item) => item.id === id);
|
|
790
|
+
if (index === -1) {
|
|
791
|
+
return false;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
this.session.feedbackItems.splice(index, 1);
|
|
795
|
+
this.persistSession();
|
|
796
|
+
this.emitStateChange();
|
|
797
|
+
|
|
798
|
+
return true;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Update a feedback item
|
|
803
|
+
*/
|
|
804
|
+
updateFeedbackItem(id: string, updates: Partial<FeedbackItem>): FeedbackItem | null {
|
|
805
|
+
if (!this.session) {
|
|
806
|
+
return null;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const item = this.session.feedbackItems.find((item) => item.id === id);
|
|
810
|
+
if (!item) {
|
|
811
|
+
return null;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
Object.assign(item, updates, { id }); // Preserve ID
|
|
815
|
+
this.persistSession();
|
|
816
|
+
|
|
817
|
+
return item;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// ===========================================================================
|
|
821
|
+
// Service Event Handlers
|
|
822
|
+
// ===========================================================================
|
|
823
|
+
|
|
824
|
+
// Audio chunk counter for debug logging
|
|
825
|
+
private audioChunkCount: number = 0;
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* Handle audio chunk from microphone
|
|
829
|
+
*/
|
|
830
|
+
private handleAudioChunk(_chunk: AudioChunk): void {
|
|
831
|
+
if (this.state !== 'recording') {
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (this.isPaused) {
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Log every 100 chunks (roughly every 10 seconds at 100ms chunks)
|
|
840
|
+
this.audioChunkCount++;
|
|
841
|
+
if (this.audioChunkCount % 100 === 0) {
|
|
842
|
+
console.log(
|
|
843
|
+
`[SessionController] Audio captured: ${this.audioChunkCount} chunks, ${Math.round(
|
|
844
|
+
this.audioChunkCount * 0.1
|
|
845
|
+
)}s of audio`
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Handle voice activity changes (forward to renderer for UI feedback).
|
|
852
|
+
* Screenshots are no longer captured during recording; frame extraction
|
|
853
|
+
* happens in the post-processing pipeline after recording stops.
|
|
854
|
+
*/
|
|
855
|
+
private handleVoiceActivity(active: boolean): void {
|
|
856
|
+
if (this.isPaused) {
|
|
857
|
+
this.emitToRenderer(IPC_CHANNELS.SESSION_VOICE_ACTIVITY, { active: false });
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
this.emitToRenderer(IPC_CHANNELS.SESSION_VOICE_ACTIVITY, { active });
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Run a post-session transcription recovery pass when live transcription produced no final output.
|
|
866
|
+
* Delegates to TranscriptionRecoveryService for the actual recovery strategies.
|
|
867
|
+
*/
|
|
868
|
+
private async recoverTranscriptFromCapturedAudio(): Promise<void> {
|
|
869
|
+
if (!this.session) {
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
const hasFinalTranscript = this.session.transcriptBuffer.some(
|
|
874
|
+
(entry) => entry.isFinal && entry.text.trim().length > 0,
|
|
875
|
+
);
|
|
876
|
+
if (hasFinalTranscript) {
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const sessionStartSec = this.session.startTime / 1000;
|
|
881
|
+
const recoveredEvents = await recoverTranscript(sessionStartSec, {
|
|
882
|
+
capturedAudioAsset: this.audioCaptureService.getCapturedAudioAsset(),
|
|
883
|
+
capturedAudioBuffer: this.audioCaptureService.getCapturedAudioBuffer(),
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
if (recoveredEvents.length > 0) {
|
|
887
|
+
this.session.transcriptBuffer.push(...recoveredEvents);
|
|
888
|
+
this.session.transcriptBuffer.sort((a, b) => a.timestamp - b.timestamp);
|
|
889
|
+
if (this.session.transcriptBuffer.length > this.MAX_TRANSCRIPT_BUFFER_EVENTS) {
|
|
890
|
+
this.session.transcriptBuffer.splice(
|
|
891
|
+
0,
|
|
892
|
+
this.session.transcriptBuffer.length - this.MAX_TRANSCRIPT_BUFFER_EVENTS,
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Handle transcript result from the active transcription source.
|
|
900
|
+
* Note: Live transcription is currently disabled in favor of post-processing,
|
|
901
|
+
* but this handler is retained for future use if streaming transcription returns.
|
|
902
|
+
*/
|
|
903
|
+
private handleTranscriptResult(event: TranscriptEvent): void {
|
|
904
|
+
if (!this.session) {
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
const sessionStartSec = this.session.startTime / 1000;
|
|
909
|
+
const normalizedTimestamp = normalizeTranscriptTimestamp(event.timestamp, sessionStartSec);
|
|
910
|
+
const normalizedEvent: TranscriptEvent = {
|
|
911
|
+
...event,
|
|
912
|
+
timestamp: normalizedTimestamp,
|
|
913
|
+
};
|
|
914
|
+
|
|
915
|
+
const text = normalizedEvent.text.trim();
|
|
916
|
+
if (!text) {
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Add to buffer
|
|
921
|
+
this.session.transcriptBuffer.push(normalizedEvent);
|
|
922
|
+
if (this.session.transcriptBuffer.length > this.MAX_TRANSCRIPT_BUFFER_EVENTS) {
|
|
923
|
+
this.session.transcriptBuffer.splice(
|
|
924
|
+
0,
|
|
925
|
+
this.session.transcriptBuffer.length - this.MAX_TRANSCRIPT_BUFFER_EVENTS
|
|
926
|
+
);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// Emit to renderer
|
|
930
|
+
if (normalizedEvent.isFinal) {
|
|
931
|
+
console.log(`[SessionController] Final transcript (${normalizedEvent.tier}): "${text}"`);
|
|
932
|
+
this.emitToRenderer(IPC_CHANNELS.TRANSCRIPTION_FINAL, {
|
|
933
|
+
text,
|
|
934
|
+
confidence: normalizedEvent.confidence,
|
|
935
|
+
timestamp: normalizedEvent.timestamp,
|
|
936
|
+
tier: normalizedEvent.tier,
|
|
937
|
+
});
|
|
938
|
+
} else {
|
|
939
|
+
this.emitToRenderer(IPC_CHANNELS.TRANSCRIPTION_UPDATE, {
|
|
940
|
+
text,
|
|
941
|
+
confidence: normalizedEvent.confidence,
|
|
942
|
+
timestamp: normalizedEvent.timestamp,
|
|
943
|
+
isFinal: false,
|
|
944
|
+
tier: normalizedEvent.tier,
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* Handle service errors with categorized error handling
|
|
953
|
+
*/
|
|
954
|
+
private handleServiceError(service: string, error: Error): void {
|
|
955
|
+
const context = {
|
|
956
|
+
component: 'SessionController',
|
|
957
|
+
operation: `${service}Error`,
|
|
958
|
+
data: { service, sessionId: this.session?.id },
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
// Log the error
|
|
962
|
+
errorHandler.log('error', `${service} error`, {
|
|
963
|
+
...context,
|
|
964
|
+
error: error.message,
|
|
965
|
+
});
|
|
966
|
+
|
|
967
|
+
// Handle based on service type
|
|
968
|
+
switch (service) {
|
|
969
|
+
case 'audio':
|
|
970
|
+
errorHandler.handleAudioError(error, context);
|
|
971
|
+
break;
|
|
972
|
+
case 'transcription':
|
|
973
|
+
errorHandler.handleTranscriptionError(error, context);
|
|
974
|
+
break;
|
|
975
|
+
case 'capture':
|
|
976
|
+
errorHandler.handleCaptureError(error, context);
|
|
977
|
+
break;
|
|
978
|
+
default:
|
|
979
|
+
// Generic error handling
|
|
980
|
+
errorHandler.log('error', `Unknown service error: ${service}`, context);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// Notify event callbacks
|
|
984
|
+
this.events?.onError(error);
|
|
985
|
+
|
|
986
|
+
// Emit to renderer for UI feedback
|
|
987
|
+
this.emitToRenderer(IPC_CHANNELS.SESSION_ERROR, {
|
|
988
|
+
service,
|
|
989
|
+
message: error.message,
|
|
990
|
+
category: errorHandler.categorizeError(error),
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// ===========================================================================
|
|
995
|
+
// Persistence
|
|
996
|
+
// ===========================================================================
|
|
997
|
+
|
|
998
|
+
/**
|
|
999
|
+
* Start auto-save timer
|
|
1000
|
+
*/
|
|
1001
|
+
private startAutoSave(): void {
|
|
1002
|
+
this.stopAutoSave();
|
|
1003
|
+
|
|
1004
|
+
this.autoSaveTimer = setInterval(() => {
|
|
1005
|
+
if (this.session) {
|
|
1006
|
+
this.persistSession();
|
|
1007
|
+
console.log('[SessionController] Auto-saved session');
|
|
1008
|
+
}
|
|
1009
|
+
}, this.AUTO_SAVE_INTERVAL_MS);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Stop auto-save timer
|
|
1014
|
+
*/
|
|
1015
|
+
private stopAutoSave(): void {
|
|
1016
|
+
if (this.autoSaveTimer) {
|
|
1017
|
+
clearInterval(this.autoSaveTimer);
|
|
1018
|
+
this.autoSaveTimer = null;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Persist current session metadata to disk
|
|
1024
|
+
* Note: We don't persist full session with buffers, just metadata
|
|
1025
|
+
*/
|
|
1026
|
+
private persistSession(): void {
|
|
1027
|
+
if (this.session) {
|
|
1028
|
+
const persisted: PersistedSession = {
|
|
1029
|
+
id: this.session.id,
|
|
1030
|
+
startTime: this.session.startTime,
|
|
1031
|
+
endTime: this.session.endTime,
|
|
1032
|
+
state: this.session.state,
|
|
1033
|
+
sourceId: this.session.sourceId,
|
|
1034
|
+
feedbackItemCount: this.session.feedbackItems.length,
|
|
1035
|
+
metadata: this.session.metadata,
|
|
1036
|
+
};
|
|
1037
|
+
store.set('currentSession', persisted);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
// ===========================================================================
|
|
1042
|
+
// Watchdog Timer - Monitors state age and forces recovery
|
|
1043
|
+
// ===========================================================================
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Start the watchdog timer.
|
|
1047
|
+
* Monitors state age and triggers recovery if a state exceeds its timeout.
|
|
1048
|
+
* Also handles recording duration warnings and limits.
|
|
1049
|
+
*/
|
|
1050
|
+
private startWatchdog(): void {
|
|
1051
|
+
this.stopWatchdog();
|
|
1052
|
+
this.stateEnteredAt = Date.now();
|
|
1053
|
+
this.recordingWarningShown = false;
|
|
1054
|
+
|
|
1055
|
+
this.watchdogTimer = setInterval(() => {
|
|
1056
|
+
this.watchdogCheck();
|
|
1057
|
+
}, this.WATCHDOG_CHECK_INTERVAL_MS);
|
|
1058
|
+
|
|
1059
|
+
console.log('[SessionController] Watchdog started');
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* Stop the watchdog timer.
|
|
1064
|
+
*/
|
|
1065
|
+
private stopWatchdog(): void {
|
|
1066
|
+
if (this.watchdogTimer) {
|
|
1067
|
+
clearInterval(this.watchdogTimer);
|
|
1068
|
+
this.watchdogTimer = null;
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
/**
|
|
1073
|
+
* Watchdog check - runs every second.
|
|
1074
|
+
* Monitors state timeouts and recording duration.
|
|
1075
|
+
*/
|
|
1076
|
+
private watchdogCheck(): void {
|
|
1077
|
+
const elapsed = Date.now() - this.stateEnteredAt;
|
|
1078
|
+
const timeout = STATE_TIMEOUTS[this.state];
|
|
1079
|
+
|
|
1080
|
+
// Check state timeout (recording uses active-duration checks below)
|
|
1081
|
+
if (this.state !== 'recording' && timeout !== null && elapsed > timeout) {
|
|
1082
|
+
console.error(
|
|
1083
|
+
`[SessionController] WATCHDOG: State '${this.state}' exceeded ${timeout}ms timeout (elapsed: ${elapsed}ms). Forcing recovery.`
|
|
1084
|
+
);
|
|
1085
|
+
this.forceRecovery();
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// Check recording-specific limits
|
|
1090
|
+
if (this.state === 'recording') {
|
|
1091
|
+
this.checkRecordingDuration();
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* Check recording duration and emit warnings/force stop.
|
|
1097
|
+
*/
|
|
1098
|
+
private checkRecordingDuration(): void {
|
|
1099
|
+
const elapsed = this.getActiveDurationMs();
|
|
1100
|
+
|
|
1101
|
+
// Warning at 25 minutes
|
|
1102
|
+
if (!this.recordingWarningShown && elapsed >= RECORDING_LIMITS.WARNING_DURATION_MS) {
|
|
1103
|
+
this.recordingWarningShown = true;
|
|
1104
|
+
const remainingMinutes = Math.ceil(
|
|
1105
|
+
(RECORDING_LIMITS.MAX_DURATION_MS - elapsed) / 60_000
|
|
1106
|
+
);
|
|
1107
|
+
|
|
1108
|
+
console.log(`[SessionController] Recording warning: ${remainingMinutes} minutes remaining`);
|
|
1109
|
+
|
|
1110
|
+
this.emitToRenderer(IPC_CHANNELS.SESSION_WARNING, {
|
|
1111
|
+
type: 'duration',
|
|
1112
|
+
message: `Recording will auto-stop in ${remainingMinutes} minutes`,
|
|
1113
|
+
remainingMs: RECORDING_LIMITS.MAX_DURATION_MS - elapsed,
|
|
1114
|
+
});
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// Force stop at 30 minutes
|
|
1118
|
+
if (elapsed >= RECORDING_LIMITS.MAX_DURATION_MS) {
|
|
1119
|
+
console.log('[SessionController] Recording max duration reached, auto-stopping');
|
|
1120
|
+
this.emitToRenderer(IPC_CHANNELS.SESSION_WARNING, {
|
|
1121
|
+
type: 'maxDuration',
|
|
1122
|
+
message: 'Maximum recording duration reached. Stopping automatically.',
|
|
1123
|
+
});
|
|
1124
|
+
this.stop(); // Will be wrapped with its own timeouts
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Force recovery from a stuck state.
|
|
1130
|
+
* Called by watchdog when a state exceeds its timeout.
|
|
1131
|
+
*/
|
|
1132
|
+
private forceRecovery(): void {
|
|
1133
|
+
console.log(`[SessionController] Force recovery from state: ${this.state}`);
|
|
1134
|
+
|
|
1135
|
+
switch (this.state) {
|
|
1136
|
+
case 'starting':
|
|
1137
|
+
// Starting timed out - abort to idle
|
|
1138
|
+
this.handleTimeoutError('Service initialization timed out');
|
|
1139
|
+
this.cleanupServicesForced();
|
|
1140
|
+
this.transitionForced('idle');
|
|
1141
|
+
break;
|
|
1142
|
+
|
|
1143
|
+
case 'recording':
|
|
1144
|
+
// Recording hit 30 minute limit - force stop
|
|
1145
|
+
this.stop().catch((error) => {
|
|
1146
|
+
console.error('[SessionController] Force stop failed:', error);
|
|
1147
|
+
this.handleTimeoutError('Recording auto-stop failed');
|
|
1148
|
+
this.cleanupServicesForced();
|
|
1149
|
+
this.transitionForced('error');
|
|
1150
|
+
});
|
|
1151
|
+
break;
|
|
1152
|
+
|
|
1153
|
+
case 'stopping':
|
|
1154
|
+
// Stopping timed out - force to processing anyway
|
|
1155
|
+
console.warn('[SessionController] Stopping timeout, forcing to processing');
|
|
1156
|
+
this.cleanupServicesForced();
|
|
1157
|
+
this.transitionForced('processing');
|
|
1158
|
+
// Reset state entry time for processing timeout
|
|
1159
|
+
this.stateEnteredAt = Date.now();
|
|
1160
|
+
break;
|
|
1161
|
+
|
|
1162
|
+
case 'processing':
|
|
1163
|
+
// Processing timed out - complete with partial data
|
|
1164
|
+
console.warn('[SessionController] Processing timeout, completing with partial data');
|
|
1165
|
+
if (this.session) {
|
|
1166
|
+
this.session.endTime = this.session.endTime || Date.now();
|
|
1167
|
+
this.session.state = 'complete';
|
|
1168
|
+
this.addToRecentSessions(this.session);
|
|
1169
|
+
store.set('currentSession', null);
|
|
1170
|
+
}
|
|
1171
|
+
this.transitionForced('complete');
|
|
1172
|
+
this.stateEnteredAt = Date.now();
|
|
1173
|
+
break;
|
|
1174
|
+
|
|
1175
|
+
case 'complete':
|
|
1176
|
+
// Complete state timeout - reset to idle
|
|
1177
|
+
console.log('[SessionController] Complete timeout, resetting to idle');
|
|
1178
|
+
this.session = null;
|
|
1179
|
+
this.transitionForced('idle');
|
|
1180
|
+
break;
|
|
1181
|
+
|
|
1182
|
+
case 'error':
|
|
1183
|
+
// Error state timeout - reset to idle
|
|
1184
|
+
console.log('[SessionController] Error timeout, resetting to idle');
|
|
1185
|
+
this.session = null;
|
|
1186
|
+
this.transitionForced('idle');
|
|
1187
|
+
break;
|
|
1188
|
+
|
|
1189
|
+
case 'idle':
|
|
1190
|
+
// Should never happen (idle has no timeout)
|
|
1191
|
+
console.warn('[SessionController] Unexpected watchdog trigger in idle state');
|
|
1192
|
+
break;
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* Force a state transition without validation.
|
|
1198
|
+
* ONLY used by watchdog recovery - bypasses normal transition checks.
|
|
1199
|
+
*/
|
|
1200
|
+
private transitionForced(newState: SessionState): void {
|
|
1201
|
+
const oldState = this.state;
|
|
1202
|
+
this.state = newState;
|
|
1203
|
+
this.stateEnteredAt = Date.now();
|
|
1204
|
+
|
|
1205
|
+
if (this.session) {
|
|
1206
|
+
this.session.state = newState;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
console.log(`[SessionController] Forced transition: ${oldState} -> ${newState}`);
|
|
1210
|
+
this.emitStateChange();
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
/**
|
|
1214
|
+
* Handle timeout errors - emit to renderer and log.
|
|
1215
|
+
*/
|
|
1216
|
+
private handleTimeoutError(message: string): void {
|
|
1217
|
+
const error = new Error(message);
|
|
1218
|
+
|
|
1219
|
+
console.error(`[SessionController] Timeout error: ${message}`);
|
|
1220
|
+
|
|
1221
|
+
this.emitToRenderer(IPC_CHANNELS.SESSION_ERROR, {
|
|
1222
|
+
type: 'timeout',
|
|
1223
|
+
message,
|
|
1224
|
+
state: this.state,
|
|
1225
|
+
timestamp: Date.now(),
|
|
1226
|
+
});
|
|
1227
|
+
|
|
1228
|
+
this.events?.onError(error);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Force cleanup without waiting for services.
|
|
1233
|
+
* Used by watchdog when cleanup itself times out.
|
|
1234
|
+
*/
|
|
1235
|
+
private cleanupServicesForced(): void {
|
|
1236
|
+
console.log('[SessionController] FORCED service cleanup');
|
|
1237
|
+
this.isPaused = false;
|
|
1238
|
+
|
|
1239
|
+
// Stop timers
|
|
1240
|
+
this.stopAutoSave();
|
|
1241
|
+
this.stopDurationTimer();
|
|
1242
|
+
|
|
1243
|
+
// Unsubscribe without waiting
|
|
1244
|
+
for (const cleanup of this.cleanupFunctions) {
|
|
1245
|
+
try {
|
|
1246
|
+
cleanup();
|
|
1247
|
+
} catch {
|
|
1248
|
+
// Ignore errors in forced cleanup
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
this.cleanupFunctions = [];
|
|
1252
|
+
|
|
1253
|
+
// Try to stop services but don't wait
|
|
1254
|
+
try {
|
|
1255
|
+
void this.audioCaptureService.stop();
|
|
1256
|
+
} catch {
|
|
1257
|
+
// Ignore
|
|
1258
|
+
}
|
|
1259
|
+
// No-op: transcription recovery is post-session and does not run as a live service.
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* Add session to recent sessions list (full session)
|
|
1264
|
+
*/
|
|
1265
|
+
private addToRecentSessions(session: Session): void {
|
|
1266
|
+
const persisted: PersistedSession = {
|
|
1267
|
+
id: session.id,
|
|
1268
|
+
startTime: session.startTime,
|
|
1269
|
+
endTime: session.endTime,
|
|
1270
|
+
state: session.state,
|
|
1271
|
+
sourceId: session.sourceId,
|
|
1272
|
+
feedbackItemCount: session.feedbackItems.length,
|
|
1273
|
+
metadata: session.metadata,
|
|
1274
|
+
};
|
|
1275
|
+
this.addToRecentSessionsPersisted(persisted);
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* Add persisted session to recent sessions list
|
|
1280
|
+
*/
|
|
1281
|
+
private addToRecentSessionsPersisted(session: PersistedSession): void {
|
|
1282
|
+
const recent = store.get('recentSessions') || [];
|
|
1283
|
+
|
|
1284
|
+
// Add to front
|
|
1285
|
+
recent.unshift(session);
|
|
1286
|
+
|
|
1287
|
+
// Limit size
|
|
1288
|
+
if (recent.length > this.MAX_RECENT_SESSIONS) {
|
|
1289
|
+
recent.splice(this.MAX_RECENT_SESSIONS);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
store.set('recentSessions', recent);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// ===========================================================================
|
|
1296
|
+
// Duration Timer
|
|
1297
|
+
// ===========================================================================
|
|
1298
|
+
|
|
1299
|
+
/**
|
|
1300
|
+
* Start duration timer (updates UI)
|
|
1301
|
+
*/
|
|
1302
|
+
private startDurationTimer(): void {
|
|
1303
|
+
this.stopDurationTimer();
|
|
1304
|
+
|
|
1305
|
+
this.durationTimer = setInterval(() => {
|
|
1306
|
+
this.emitToRenderer(IPC_CHANNELS.SESSION_STATUS, this.getStatus());
|
|
1307
|
+
}, 1000);
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
/**
|
|
1311
|
+
* Stop duration timer
|
|
1312
|
+
*/
|
|
1313
|
+
private stopDurationTimer(): void {
|
|
1314
|
+
if (this.durationTimer) {
|
|
1315
|
+
clearInterval(this.durationTimer);
|
|
1316
|
+
this.durationTimer = null;
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// ===========================================================================
|
|
1321
|
+
// Event Emission
|
|
1322
|
+
// ===========================================================================
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Emit state change to listeners
|
|
1326
|
+
*/
|
|
1327
|
+
private emitStateChange(): void {
|
|
1328
|
+
this.events?.onStateChange(this.state, this.session);
|
|
1329
|
+
this.emitStatus();
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
private emitStatus(): void {
|
|
1333
|
+
this.emitToRenderer(IPC_CHANNELS.SESSION_STATUS, this.getStatus());
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* Emit feedback item to listeners
|
|
1338
|
+
*/
|
|
1339
|
+
private emitFeedbackItem(item: FeedbackItem): void {
|
|
1340
|
+
this.events?.onFeedbackItem(item);
|
|
1341
|
+
this.emitToRenderer(IPC_CHANNELS.SESSION_FEEDBACK_ITEM, {
|
|
1342
|
+
id: item.id,
|
|
1343
|
+
timestamp: item.timestamp,
|
|
1344
|
+
text: item.text,
|
|
1345
|
+
confidence: item.confidence,
|
|
1346
|
+
});
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* Send event to renderer via IPC
|
|
1351
|
+
*/
|
|
1352
|
+
private emitToRenderer(channel: string, data: unknown): void {
|
|
1353
|
+
if (this.mainWindow && !this.mainWindow.isDestroyed()) {
|
|
1354
|
+
this.mainWindow.webContents.send(channel, data);
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// ===========================================================================
|
|
1359
|
+
// Cleanup
|
|
1360
|
+
// ===========================================================================
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* Clean up all services and timers (synchronous version for backwards compatibility)
|
|
1364
|
+
*/
|
|
1365
|
+
private cleanupServices(): void {
|
|
1366
|
+
this.closeActivePauseWindow();
|
|
1367
|
+
this.isPaused = false;
|
|
1368
|
+
// Stop timers
|
|
1369
|
+
this.stopAutoSave();
|
|
1370
|
+
this.stopDurationTimer();
|
|
1371
|
+
// Unsubscribe from all events
|
|
1372
|
+
for (const cleanup of this.cleanupFunctions) {
|
|
1373
|
+
try {
|
|
1374
|
+
cleanup();
|
|
1375
|
+
} catch (error) {
|
|
1376
|
+
console.warn('[SessionController] Cleanup callback error:', error);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
this.cleanupFunctions = [];
|
|
1380
|
+
|
|
1381
|
+
// Stop services
|
|
1382
|
+
void this.audioCaptureService.stop();
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* Clean up all services and timers with timeout protection.
|
|
1387
|
+
* Never blocks for more than 2 seconds per service.
|
|
1388
|
+
*/
|
|
1389
|
+
private async cleanupServicesAsync(): Promise<void> {
|
|
1390
|
+
console.log('[SessionController] Cleaning up services...');
|
|
1391
|
+
this.closeActivePauseWindow();
|
|
1392
|
+
this.isPaused = false;
|
|
1393
|
+
|
|
1394
|
+
// Stop timers first (fast, non-blocking)
|
|
1395
|
+
this.stopAutoSave();
|
|
1396
|
+
this.stopDurationTimer();
|
|
1397
|
+
|
|
1398
|
+
// Unsubscribe from all events (fast, non-blocking)
|
|
1399
|
+
for (const cleanup of this.cleanupFunctions) {
|
|
1400
|
+
try {
|
|
1401
|
+
cleanup();
|
|
1402
|
+
} catch (error) {
|
|
1403
|
+
console.warn('[SessionController] Cleanup callback error:', error);
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
this.cleanupFunctions = [];
|
|
1407
|
+
|
|
1408
|
+
// Stop services with timeout protection (may block)
|
|
1409
|
+
const serviceTimeout = 2000; // 2 seconds per service
|
|
1410
|
+
|
|
1411
|
+
const [audioResult] = await Promise.allSettled([
|
|
1412
|
+
this.withTimeout(
|
|
1413
|
+
this.audioCaptureService.stop(),
|
|
1414
|
+
serviceTimeout,
|
|
1415
|
+
undefined,
|
|
1416
|
+
'AudioCapture.stop()'
|
|
1417
|
+
),
|
|
1418
|
+
]);
|
|
1419
|
+
|
|
1420
|
+
// Log any failures
|
|
1421
|
+
if (audioResult.status === 'rejected') {
|
|
1422
|
+
console.warn('[SessionController] Audio cleanup failed:', audioResult.reason);
|
|
1423
|
+
}
|
|
1424
|
+
console.log('[SessionController] Service cleanup complete');
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/**
|
|
1428
|
+
* Full cleanup for app shutdown.
|
|
1429
|
+
* Mark clean exit for crash recovery.
|
|
1430
|
+
*/
|
|
1431
|
+
destroy(): void {
|
|
1432
|
+
console.log('[SessionController] Destroying...');
|
|
1433
|
+
|
|
1434
|
+
// Save any active session
|
|
1435
|
+
if (this.session && (this.state === 'recording' || this.state === 'starting')) {
|
|
1436
|
+
this.session.state = 'complete';
|
|
1437
|
+
this.session.endTime = Date.now();
|
|
1438
|
+
this.addToRecentSessions(this.session);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
// Stop all timers
|
|
1442
|
+
this.stopWatchdog();
|
|
1443
|
+
this.cleanupServicesForced();
|
|
1444
|
+
|
|
1445
|
+
// Clear state
|
|
1446
|
+
this.session = null;
|
|
1447
|
+
this.resetSessionRuntimeState();
|
|
1448
|
+
this.postProcessResult = null;
|
|
1449
|
+
this.currentProcessingProgress = null;
|
|
1450
|
+
this.events = null;
|
|
1451
|
+
this.mainWindow = null;
|
|
1452
|
+
|
|
1453
|
+
console.log('[SessionController] Destroy complete');
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
// =============================================================================
|
|
1458
|
+
// Singleton Export
|
|
1459
|
+
// =============================================================================
|
|
1460
|
+
|
|
1461
|
+
export const sessionController = new SessionController();
|
|
1462
|
+
export default SessionController;
|
|
1463
|
+
|
|
1464
|
+
// Re-export types from shared/types for downstream consumers
|
|
1465
|
+
export type { SessionState, SessionMetadata } from '../shared/types';
|