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.
Files changed (299) hide show
  1. package/.claude/commands/review-feedback.md +47 -0
  2. package/.eslintrc.json +35 -0
  3. package/.github/CODEOWNERS +16 -0
  4. package/.github/FUNDING.yml +1 -0
  5. package/.github/ISSUE_TEMPLATE/bug_report.md +56 -0
  6. package/.github/ISSUE_TEMPLATE/feature_request.md +54 -0
  7. package/.github/PULL_REQUEST_TEMPLATE.md +89 -0
  8. package/.github/dependabot.yml +70 -0
  9. package/.github/workflows/ci.yml +184 -0
  10. package/.github/workflows/deploy-landing.yml +134 -0
  11. package/.github/workflows/nightly.yml +288 -0
  12. package/.github/workflows/release.yml +318 -0
  13. package/CHANGELOG.md +127 -0
  14. package/CLAUDE.md +137 -0
  15. package/CODE_OF_CONDUCT.md +9 -0
  16. package/CONTRIBUTING.md +390 -0
  17. package/LICENSE +21 -0
  18. package/PRODUCT_VISION.md +277 -0
  19. package/README.md +517 -0
  20. package/SECURITY.md +51 -0
  21. package/SIGNING_INSTRUCTIONS.md +284 -0
  22. package/assets/DMG_BACKGROUND_INSTRUCTIONS.md +130 -0
  23. package/assets/svg-source/dmg-background.svg +70 -0
  24. package/assets/svg-source/icon.svg +20 -0
  25. package/assets/svg-source/tray-icon-processing.svg +7 -0
  26. package/assets/svg-source/tray-icon-recording.svg +7 -0
  27. package/assets/svg-source/tray-icon.svg +6 -0
  28. package/assets/tray-complete.png +0 -0
  29. package/assets/tray-complete@2x.png +0 -0
  30. package/assets/tray-completeTemplate.png +0 -0
  31. package/assets/tray-completeTemplate@2x.png +0 -0
  32. package/assets/tray-error.png +0 -0
  33. package/assets/tray-error@2x.png +0 -0
  34. package/assets/tray-errorTemplate.png +0 -0
  35. package/assets/tray-errorTemplate@2x.png +0 -0
  36. package/assets/tray-icon-processing.png +0 -0
  37. package/assets/tray-icon-processing@2x.png +0 -0
  38. package/assets/tray-icon-processingTemplate.png +0 -0
  39. package/assets/tray-icon-processingTemplate@2x.png +0 -0
  40. package/assets/tray-icon-recording.png +0 -0
  41. package/assets/tray-icon-recording@2x.png +0 -0
  42. package/assets/tray-icon-recordingTemplate.png +0 -0
  43. package/assets/tray-icon-recordingTemplate@2x.png +0 -0
  44. package/assets/tray-icon.png +0 -0
  45. package/assets/tray-icon@2x.png +0 -0
  46. package/assets/tray-iconTemplate.png +0 -0
  47. package/assets/tray-iconTemplate@2x.png +0 -0
  48. package/assets/tray-idle.png +0 -0
  49. package/assets/tray-idle@2x.png +0 -0
  50. package/assets/tray-idleTemplate.png +0 -0
  51. package/assets/tray-idleTemplate@2x.png +0 -0
  52. package/assets/tray-processing-0.png +0 -0
  53. package/assets/tray-processing-0@2x.png +0 -0
  54. package/assets/tray-processing-0Template.png +0 -0
  55. package/assets/tray-processing-0Template@2x.png +0 -0
  56. package/assets/tray-processing-1.png +0 -0
  57. package/assets/tray-processing-1@2x.png +0 -0
  58. package/assets/tray-processing-1Template.png +0 -0
  59. package/assets/tray-processing-1Template@2x.png +0 -0
  60. package/assets/tray-processing-2.png +0 -0
  61. package/assets/tray-processing-2@2x.png +0 -0
  62. package/assets/tray-processing-2Template.png +0 -0
  63. package/assets/tray-processing-2Template@2x.png +0 -0
  64. package/assets/tray-processing-3.png +0 -0
  65. package/assets/tray-processing-3@2x.png +0 -0
  66. package/assets/tray-processing-3Template.png +0 -0
  67. package/assets/tray-processing-3Template@2x.png +0 -0
  68. package/assets/tray-processing.png +0 -0
  69. package/assets/tray-processing@2x.png +0 -0
  70. package/assets/tray-processingTemplate.png +0 -0
  71. package/assets/tray-processingTemplate@2x.png +0 -0
  72. package/assets/tray-recording.png +0 -0
  73. package/assets/tray-recording@2x.png +0 -0
  74. package/assets/tray-recordingTemplate.png +0 -0
  75. package/assets/tray-recordingTemplate@2x.png +0 -0
  76. package/build/DMG_BACKGROUND_SPEC.md +50 -0
  77. package/build/dmg-background.png +0 -0
  78. package/build/dmg-background@2x.png +0 -0
  79. package/build/entitlements.mac.inherit.plist +27 -0
  80. package/build/entitlements.mac.plist +41 -0
  81. package/build/favicon-16.png +0 -0
  82. package/build/favicon-180.png +0 -0
  83. package/build/favicon-192.png +0 -0
  84. package/build/favicon-32.png +0 -0
  85. package/build/favicon-48.png +0 -0
  86. package/build/favicon-512.png +0 -0
  87. package/build/favicon-64.png +0 -0
  88. package/build/icon-128.png +0 -0
  89. package/build/icon-16.png +0 -0
  90. package/build/icon-24.png +0 -0
  91. package/build/icon-256.png +0 -0
  92. package/build/icon-32.png +0 -0
  93. package/build/icon-48.png +0 -0
  94. package/build/icon-64.png +0 -0
  95. package/build/icon.icns +0 -0
  96. package/build/icon.ico +0 -0
  97. package/build/icon.iconset/icon_128x128.png +0 -0
  98. package/build/icon.iconset/icon_128x128@2x.png +0 -0
  99. package/build/icon.iconset/icon_16x16.png +0 -0
  100. package/build/icon.iconset/icon_16x16@2x.png +0 -0
  101. package/build/icon.iconset/icon_256x256.png +0 -0
  102. package/build/icon.iconset/icon_256x256@2x.png +0 -0
  103. package/build/icon.iconset/icon_32x32.png +0 -0
  104. package/build/icon.iconset/icon_32x32@2x.png +0 -0
  105. package/build/icon.iconset/icon_512x512.png +0 -0
  106. package/build/icon.iconset/icon_512x512@2x.png +0 -0
  107. package/build/icon.png +0 -0
  108. package/build/installer-header.bmp +0 -0
  109. package/build/installer-header.png +0 -0
  110. package/build/installer-sidebar.bmp +0 -0
  111. package/build/installer-sidebar.png +0 -0
  112. package/build/installer.nsh +45 -0
  113. package/build/overlay-processing.png +0 -0
  114. package/build/overlay-recording.png +0 -0
  115. package/build/toolbar-record.png +0 -0
  116. package/build/toolbar-screenshot.png +0 -0
  117. package/build/toolbar-settings.png +0 -0
  118. package/build/toolbar-stop.png +0 -0
  119. package/dist/main/index.mjs +12612 -0
  120. package/dist/preload/index.mjs +907 -0
  121. package/dist/renderer/assets/index-CCmUjl9K.js +19495 -0
  122. package/dist/renderer/assets/index-CUqz_Gs6.css +2270 -0
  123. package/dist/renderer/index.html +27 -0
  124. package/docs/AI_AGENT_QUICKSTART.md +42 -0
  125. package/docs/AI_PIPELINE_DESIGN.md +595 -0
  126. package/docs/API.md +514 -0
  127. package/docs/ARCHITECTURE.md +460 -0
  128. package/docs/CONFIGURATION.md +336 -0
  129. package/docs/DEVELOPMENT.md +508 -0
  130. package/docs/EXPORT_FORMATS.md +451 -0
  131. package/docs/GETTING_STARTED.md +236 -0
  132. package/docs/KEYBOARD_SHORTCUTS.md +334 -0
  133. package/docs/TROUBLESHOOTING.md +418 -0
  134. package/docs/landing/index.html +672 -0
  135. package/docs/landing/script.js +342 -0
  136. package/docs/landing/styles.css +1543 -0
  137. package/electron-builder.yml +140 -0
  138. package/electron.vite.config.ts +63 -0
  139. package/package.json +108 -0
  140. package/railway.json +12 -0
  141. package/scripts/build.mjs +51 -0
  142. package/scripts/generate-icons.mjs +314 -0
  143. package/scripts/generate-installer-images.cjs +253 -0
  144. package/scripts/generate-tray-icons.mjs +258 -0
  145. package/scripts/notarize.cjs +180 -0
  146. package/scripts/one-click-clean-test.sh +147 -0
  147. package/scripts/postinstall.mjs +36 -0
  148. package/scripts/setup-markupr.sh +55 -0
  149. package/setup +17 -0
  150. package/site/index.html +1835 -0
  151. package/site/package.json +11 -0
  152. package/site/railway.json +12 -0
  153. package/site/server.js +31 -0
  154. package/src/main/AutoUpdater.ts +392 -0
  155. package/src/main/CrashRecovery.ts +655 -0
  156. package/src/main/ErrorHandler.ts +703 -0
  157. package/src/main/HotkeyManager.ts +399 -0
  158. package/src/main/MenuManager.ts +529 -0
  159. package/src/main/PermissionManager.ts +420 -0
  160. package/src/main/SessionController.ts +1465 -0
  161. package/src/main/TrayManager.ts +540 -0
  162. package/src/main/ai/AIPipelineManager.ts +199 -0
  163. package/src/main/ai/ClaudeAnalyzer.ts +339 -0
  164. package/src/main/ai/ImageOptimizer.ts +176 -0
  165. package/src/main/ai/StructuredMarkdownBuilder.ts +379 -0
  166. package/src/main/ai/index.ts +16 -0
  167. package/src/main/ai/types.ts +258 -0
  168. package/src/main/analysis/ClarificationGenerator.ts +385 -0
  169. package/src/main/analysis/FeedbackAnalyzer.ts +531 -0
  170. package/src/main/analysis/index.ts +19 -0
  171. package/src/main/audio/AudioCapture.ts +978 -0
  172. package/src/main/audio/audioUtils.ts +100 -0
  173. package/src/main/audio/index.ts +20 -0
  174. package/src/main/capture/index.ts +1 -0
  175. package/src/main/index.ts +1693 -0
  176. package/src/main/ipc/captureHandlers.ts +272 -0
  177. package/src/main/ipc/index.ts +45 -0
  178. package/src/main/ipc/outputHandlers.ts +302 -0
  179. package/src/main/ipc/sessionHandlers.ts +56 -0
  180. package/src/main/ipc/settingsHandlers.ts +471 -0
  181. package/src/main/ipc/types.ts +56 -0
  182. package/src/main/ipc/windowHandlers.ts +277 -0
  183. package/src/main/output/ClipboardService.ts +369 -0
  184. package/src/main/output/ExportService.ts +539 -0
  185. package/src/main/output/FileManager.ts +416 -0
  186. package/src/main/output/MarkdownGenerator.ts +791 -0
  187. package/src/main/output/MarkdownPatcher.ts +299 -0
  188. package/src/main/output/index.ts +186 -0
  189. package/src/main/output/sessionAdapter.ts +207 -0
  190. package/src/main/output/templates/html-template.ts +553 -0
  191. package/src/main/pipeline/FrameExtractor.ts +330 -0
  192. package/src/main/pipeline/PostProcessor.ts +399 -0
  193. package/src/main/pipeline/TranscriptAnalyzer.ts +226 -0
  194. package/src/main/pipeline/index.ts +36 -0
  195. package/src/main/platform/WindowsTaskbar.ts +600 -0
  196. package/src/main/platform/index.ts +16 -0
  197. package/src/main/settings/SettingsManager.ts +730 -0
  198. package/src/main/settings/index.ts +19 -0
  199. package/src/main/transcription/ModelDownloadManager.ts +494 -0
  200. package/src/main/transcription/TierManager.ts +219 -0
  201. package/src/main/transcription/TranscriptionRecoveryService.ts +340 -0
  202. package/src/main/transcription/WhisperService.ts +748 -0
  203. package/src/main/transcription/index.ts +56 -0
  204. package/src/main/transcription/types.ts +135 -0
  205. package/src/main/windows/PopoverManager.ts +284 -0
  206. package/src/main/windows/TaskbarIntegration.ts +452 -0
  207. package/src/main/windows/index.ts +23 -0
  208. package/src/preload/index.ts +1047 -0
  209. package/src/renderer/App.tsx +515 -0
  210. package/src/renderer/AppWrapper.tsx +28 -0
  211. package/src/renderer/assets/logo-dark.svg +7 -0
  212. package/src/renderer/assets/logo.svg +7 -0
  213. package/src/renderer/audio/AudioCaptureRenderer.ts +454 -0
  214. package/src/renderer/capture/ScreenRecordingRenderer.ts +492 -0
  215. package/src/renderer/components/AnnotationOverlay.tsx +836 -0
  216. package/src/renderer/components/AudioWaveform.tsx +811 -0
  217. package/src/renderer/components/ClarificationQuestions.tsx +656 -0
  218. package/src/renderer/components/CountdownTimer.tsx +495 -0
  219. package/src/renderer/components/CrashRecoveryDialog.tsx +632 -0
  220. package/src/renderer/components/DonateButton.tsx +127 -0
  221. package/src/renderer/components/ErrorBoundary.tsx +308 -0
  222. package/src/renderer/components/ExportDialog.tsx +872 -0
  223. package/src/renderer/components/HotkeyHint.tsx +261 -0
  224. package/src/renderer/components/KeyboardShortcuts.tsx +787 -0
  225. package/src/renderer/components/ModelDownloadDialog.tsx +844 -0
  226. package/src/renderer/components/Onboarding.tsx +1830 -0
  227. package/src/renderer/components/ProcessingOverlay.tsx +157 -0
  228. package/src/renderer/components/RecordingOverlay.tsx +423 -0
  229. package/src/renderer/components/SessionHistory.tsx +1746 -0
  230. package/src/renderer/components/SessionReview.tsx +1321 -0
  231. package/src/renderer/components/SettingsPanel.tsx +217 -0
  232. package/src/renderer/components/Skeleton.tsx +347 -0
  233. package/src/renderer/components/StatusIndicator.tsx +86 -0
  234. package/src/renderer/components/ThemeProvider.tsx +429 -0
  235. package/src/renderer/components/Tooltip.tsx +370 -0
  236. package/src/renderer/components/TranscriptionPreview.tsx +183 -0
  237. package/src/renderer/components/TranscriptionTierSelector.tsx +640 -0
  238. package/src/renderer/components/UpdateNotification.tsx +377 -0
  239. package/src/renderer/components/WindowSelector.tsx +947 -0
  240. package/src/renderer/components/index.ts +99 -0
  241. package/src/renderer/components/primitives/ApiKeyInput.tsx +98 -0
  242. package/src/renderer/components/primitives/ColorPicker.tsx +65 -0
  243. package/src/renderer/components/primitives/DangerButton.tsx +45 -0
  244. package/src/renderer/components/primitives/DirectoryPicker.tsx +41 -0
  245. package/src/renderer/components/primitives/Dropdown.tsx +34 -0
  246. package/src/renderer/components/primitives/KeyRecorder.tsx +117 -0
  247. package/src/renderer/components/primitives/SettingsSection.tsx +32 -0
  248. package/src/renderer/components/primitives/Slider.tsx +43 -0
  249. package/src/renderer/components/primitives/Toggle.tsx +36 -0
  250. package/src/renderer/components/primitives/index.ts +10 -0
  251. package/src/renderer/components/settings/AdvancedTab.tsx +174 -0
  252. package/src/renderer/components/settings/AppearanceTab.tsx +77 -0
  253. package/src/renderer/components/settings/GeneralTab.tsx +40 -0
  254. package/src/renderer/components/settings/HotkeysTab.tsx +79 -0
  255. package/src/renderer/components/settings/RecordingTab.tsx +84 -0
  256. package/src/renderer/components/settings/index.ts +9 -0
  257. package/src/renderer/components/settings/settingsStyles.ts +673 -0
  258. package/src/renderer/components/settings/tabConfig.tsx +85 -0
  259. package/src/renderer/components/settings/useSettingsPanel.ts +447 -0
  260. package/src/renderer/contexts/ProcessingContext.tsx +227 -0
  261. package/src/renderer/contexts/RecordingContext.tsx +683 -0
  262. package/src/renderer/contexts/UIContext.tsx +326 -0
  263. package/src/renderer/contexts/index.ts +24 -0
  264. package/src/renderer/donateMessages.ts +69 -0
  265. package/src/renderer/hooks/index.ts +75 -0
  266. package/src/renderer/hooks/useAnimation.tsx +544 -0
  267. package/src/renderer/hooks/useTheme.ts +313 -0
  268. package/src/renderer/index.html +26 -0
  269. package/src/renderer/main.tsx +52 -0
  270. package/src/renderer/styles/animations.css +1093 -0
  271. package/src/renderer/styles/app-shell.css +662 -0
  272. package/src/renderer/styles/globals.css +515 -0
  273. package/src/renderer/styles/theme.ts +578 -0
  274. package/src/renderer/types/electron.d.ts +385 -0
  275. package/src/shared/hotkeys.ts +283 -0
  276. package/src/shared/types.ts +809 -0
  277. package/tests/clipboard.test.ts +228 -0
  278. package/tests/e2e/criticalPaths.test.ts +594 -0
  279. package/tests/feedbackAnalyzer.test.ts +303 -0
  280. package/tests/integration/sessionFlow.test.ts +583 -0
  281. package/tests/markdownGenerator.test.ts +418 -0
  282. package/tests/output.test.ts +96 -0
  283. package/tests/setup.ts +486 -0
  284. package/tests/unit/appIntegration.test.ts +676 -0
  285. package/tests/unit/appViewState.test.ts +281 -0
  286. package/tests/unit/audioIpcChannels.test.ts +17 -0
  287. package/tests/unit/exportService.test.ts +492 -0
  288. package/tests/unit/hotkeys.test.ts +92 -0
  289. package/tests/unit/navigationPreload.test.ts +94 -0
  290. package/tests/unit/onboardingFlow.test.ts +345 -0
  291. package/tests/unit/permissionManager.test.ts +175 -0
  292. package/tests/unit/permissionManagerExpanded.test.ts +296 -0
  293. package/tests/unit/screenRecordingRenderer.test.ts +368 -0
  294. package/tests/unit/sessionController.test.ts +515 -0
  295. package/tests/unit/tierManager.test.ts +61 -0
  296. package/tests/unit/tierManagerExpanded.test.ts +142 -0
  297. package/tests/unit/transcriptAnalyzer.test.ts +64 -0
  298. package/tsconfig.json +25 -0
  299. package/vitest.config.ts +46 -0
@@ -0,0 +1,454 @@
1
+ /**
2
+ * AudioCaptureRenderer.ts - Browser-side Audio Capture for markupr
3
+ *
4
+ * Uses getUserMedia + MediaRecorder to avoid fragile WebAudio graphs in
5
+ * packaged macOS builds. Chunks are streamed to main process for persistence
6
+ * and post-session transcription.
7
+ */
8
+
9
+ interface CaptureConfig {
10
+ deviceId: string | null;
11
+ sampleRate: number;
12
+ channels: number;
13
+ chunkDurationMs: number;
14
+ }
15
+
16
+ interface AudioDeviceInfo {
17
+ id: string;
18
+ name: string;
19
+ isDefault: boolean;
20
+ }
21
+
22
+ class AudioCaptureRenderer {
23
+ private mediaStream: MediaStream | null = null;
24
+ private mediaRecorder: MediaRecorder | null = null;
25
+ private recorderMimeType = 'audio/webm';
26
+ private capturing = false;
27
+ private stopping = false;
28
+ private inFlightChunkReads: Set<Promise<void>> = new Set();
29
+ private audioContext: AudioContext | null = null;
30
+ private analyserNode: AnalyserNode | null = null;
31
+ private sourceNode: MediaStreamAudioSourceNode | null = null;
32
+ private analyserData: Float32Array | null = null;
33
+ private levelMonitorFrame: number | null = null;
34
+ private latestRms = 0;
35
+ private latestLevel = 0;
36
+ private config: CaptureConfig = {
37
+ deviceId: null,
38
+ sampleRate: 16000,
39
+ channels: 1,
40
+ chunkDurationMs: 250,
41
+ };
42
+ private cleanupFunctions: Array<() => void> = [];
43
+
44
+ constructor() {
45
+ this.setupIPCListeners();
46
+ }
47
+
48
+ private setupIPCListeners(): void {
49
+ const api = window.markupr;
50
+ if (!api?.audio) {
51
+ console.error('[AudioCaptureRenderer] markupr.audio API not available');
52
+ return;
53
+ }
54
+
55
+ const cleanupDevices = api.audio.onRequestDevices(async () => {
56
+ try {
57
+ const devices = await this.getDevices();
58
+ api.audio.sendDevices(devices);
59
+ } catch (error) {
60
+ api.audio.sendCaptureError((error as Error).message);
61
+ }
62
+ });
63
+ this.cleanupFunctions.push(cleanupDevices);
64
+
65
+ const cleanupStart = api.audio.onStartCapture(async (config) => {
66
+ try {
67
+ this.config = { ...this.config, ...config };
68
+ await this.startCapture();
69
+ api.audio.notifyCaptureStarted();
70
+ } catch (error) {
71
+ api.audio.sendCaptureError((error as Error).message);
72
+ }
73
+ });
74
+ this.cleanupFunctions.push(cleanupStart);
75
+
76
+ const cleanupStop = api.audio.onStopCapture(async () => {
77
+ await this.stopCapture();
78
+ api.audio.notifyCaptureStopped();
79
+ });
80
+ this.cleanupFunctions.push(cleanupStop);
81
+
82
+ const cleanupDevice = api.audio.onSetDevice(async (deviceId) => {
83
+ this.config.deviceId = deviceId;
84
+ if (this.capturing) {
85
+ await this.stopCapture();
86
+ try {
87
+ await this.startCapture();
88
+ api.audio.notifyCaptureStarted();
89
+ } catch (error) {
90
+ api.audio.sendCaptureError((error as Error).message);
91
+ }
92
+ }
93
+ });
94
+ this.cleanupFunctions.push(cleanupDevice);
95
+
96
+ console.log('[AudioCaptureRenderer] IPC listeners initialized');
97
+ }
98
+
99
+ destroy(): void {
100
+ void this.stopCapture();
101
+ void this.stopLevelMonitor();
102
+ this.cleanupFunctions.forEach((fn) => fn());
103
+ this.cleanupFunctions = [];
104
+ }
105
+
106
+ async getDevices(): Promise<AudioDeviceInfo[]> {
107
+ try {
108
+ const tempStream = await navigator.mediaDevices.getUserMedia({ audio: true });
109
+ tempStream.getTracks().forEach((track) => track.stop());
110
+ } catch {
111
+ console.warn('[AudioCaptureRenderer] Could not get permission to enumerate devices');
112
+ }
113
+
114
+ const devices = await navigator.mediaDevices.enumerateDevices();
115
+ const audioInputs = devices.filter((d) => d.kind === 'audioinput');
116
+
117
+ return audioInputs.map((device, index) => ({
118
+ id: device.deviceId,
119
+ name: device.label || `Microphone ${index + 1}`,
120
+ isDefault: device.deviceId === 'default',
121
+ }));
122
+ }
123
+
124
+ async startCapture(): Promise<void> {
125
+ if (this.capturing) {
126
+ console.log('[AudioCaptureRenderer] Already capturing');
127
+ return;
128
+ }
129
+
130
+ const constraints: MediaStreamConstraints = {
131
+ audio: {
132
+ deviceId: this.config.deviceId ? { exact: this.config.deviceId } : undefined,
133
+ sampleRate: { ideal: this.config.sampleRate },
134
+ channelCount: { ideal: 1 },
135
+ echoCancellation: { ideal: true },
136
+ noiseSuppression: { ideal: true },
137
+ autoGainControl: { ideal: true },
138
+ },
139
+ video: false,
140
+ };
141
+
142
+ try {
143
+ this.mediaStream = await navigator.mediaDevices.getUserMedia(constraints);
144
+ } catch (error) {
145
+ throw new Error(`Failed to access microphone: ${(error as Error).message}`);
146
+ }
147
+
148
+ this.recorderMimeType = this.resolveRecorderMimeType();
149
+
150
+ try {
151
+ const options: MediaRecorderOptions = this.recorderMimeType
152
+ ? { mimeType: this.recorderMimeType, audioBitsPerSecond: 128_000 }
153
+ : { audioBitsPerSecond: 128_000 };
154
+ this.mediaRecorder = new MediaRecorder(this.mediaStream, options);
155
+ } catch (error) {
156
+ await this.stopCapture();
157
+ throw new Error(`Failed to initialize media recorder: ${(error as Error).message}`);
158
+ }
159
+
160
+ await this.startLevelMonitor().catch((error) => {
161
+ console.warn('[AudioCaptureRenderer] Mic level monitor unavailable:', error);
162
+ this.latestRms = 0;
163
+ this.latestLevel = 0;
164
+ });
165
+
166
+ this.mediaRecorder.ondataavailable = (event: BlobEvent) => {
167
+ if ((!this.capturing && !this.stopping) || !event.data || event.data.size === 0) {
168
+ return;
169
+ }
170
+
171
+ const timestamp = performance.now();
172
+ const duration = this.config.chunkDurationMs;
173
+ const mimeType = event.data.type || this.mediaRecorder?.mimeType || this.recorderMimeType;
174
+ const chunkPromise = event.data
175
+ .arrayBuffer()
176
+ .then((buffer) => {
177
+ this.sendEncodedChunkToMain(new Uint8Array(buffer), timestamp, duration, mimeType);
178
+ })
179
+ .catch((error) => {
180
+ const api = window.markupr;
181
+ api?.audio?.sendCaptureError(`Failed to process audio chunk: ${(error as Error).message}`);
182
+ })
183
+ .finally(() => {
184
+ this.inFlightChunkReads.delete(chunkPromise);
185
+ });
186
+ this.inFlightChunkReads.add(chunkPromise);
187
+ };
188
+
189
+ this.mediaRecorder.onerror = (event: Event) => {
190
+ const recorderError = (event as ErrorEvent).error;
191
+ const message = recorderError instanceof Error ? recorderError.message : 'Unknown recorder error';
192
+ const api = window.markupr;
193
+ api?.audio?.sendCaptureError(`Audio recorder error: ${message}`);
194
+ };
195
+
196
+ try {
197
+ this.mediaRecorder.start(this.config.chunkDurationMs);
198
+ this.capturing = true;
199
+ this.stopping = false;
200
+ console.log(
201
+ `[AudioCaptureRenderer] Capture started with MediaRecorder (${this.mediaRecorder.mimeType || this.recorderMimeType})`
202
+ );
203
+ } catch (error) {
204
+ await this.stopCapture();
205
+ throw new Error(`Failed to start media recorder: ${(error as Error).message}`);
206
+ }
207
+ }
208
+
209
+ private resolveRecorderMimeType(): string {
210
+ const candidates = [
211
+ 'audio/webm;codecs=opus',
212
+ 'audio/webm',
213
+ 'audio/mp4',
214
+ 'audio/ogg;codecs=opus',
215
+ '',
216
+ ];
217
+
218
+ for (const candidate of candidates) {
219
+ if (!candidate) {
220
+ return '';
221
+ }
222
+ if (MediaRecorder.isTypeSupported(candidate)) {
223
+ return candidate;
224
+ }
225
+ }
226
+
227
+ return '';
228
+ }
229
+
230
+ private sendEncodedChunkToMain(
231
+ encodedChunk: Uint8Array,
232
+ timestamp: number,
233
+ duration: number,
234
+ mimeType: string
235
+ ): void {
236
+ if (!this.capturing) {
237
+ return;
238
+ }
239
+
240
+ const api = window.markupr;
241
+ if (!api?.audio) {
242
+ console.error('[AudioCaptureRenderer] markupr.audio API not available');
243
+ return;
244
+ }
245
+
246
+ api.audio.sendAudioChunk({
247
+ encodedChunk,
248
+ timestamp,
249
+ duration,
250
+ mimeType,
251
+ audioLevel: this.latestLevel,
252
+ rms: this.latestRms,
253
+ });
254
+ }
255
+
256
+ private async startLevelMonitor(): Promise<void> {
257
+ if (!this.mediaStream) {
258
+ return;
259
+ }
260
+
261
+ const AudioContextCtor =
262
+ window.AudioContext ||
263
+ (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
264
+ if (!AudioContextCtor) {
265
+ return;
266
+ }
267
+
268
+ await this.stopLevelMonitor();
269
+
270
+ const context = new AudioContextCtor();
271
+ if (context.state === 'suspended') {
272
+ await context.resume().catch(() => {
273
+ // Best effort.
274
+ });
275
+ }
276
+
277
+ const source = context.createMediaStreamSource(this.mediaStream);
278
+ const analyser = context.createAnalyser();
279
+ analyser.fftSize = 1024;
280
+ analyser.smoothingTimeConstant = 0.15;
281
+
282
+ source.connect(analyser);
283
+
284
+ this.audioContext = context;
285
+ this.sourceNode = source;
286
+ this.analyserNode = analyser;
287
+ this.analyserData = new Float32Array(analyser.fftSize);
288
+ this.latestRms = 0;
289
+ this.latestLevel = 0;
290
+
291
+ const tick = () => {
292
+ if (!this.analyserNode || !this.analyserData) {
293
+ return;
294
+ }
295
+
296
+ this.analyserNode.getFloatTimeDomainData(
297
+ this.analyserData as unknown as Float32Array<ArrayBuffer>
298
+ );
299
+ let sumSquares = 0;
300
+ for (let i = 0; i < this.analyserData.length; i += 1) {
301
+ const sample = this.analyserData[i];
302
+ sumSquares += sample * sample;
303
+ }
304
+ const rms = Math.sqrt(sumSquares / this.analyserData.length);
305
+
306
+ // Normalize voice energy while suppressing idle noise.
307
+ const noiseFloor = 0.004;
308
+ const normalized = Math.max(0, Math.min(1, (rms - noiseFloor) / 0.045));
309
+ const attack = 0.45;
310
+ const release = 0.18;
311
+ const smoothing = normalized > this.latestLevel ? attack : release;
312
+
313
+ this.latestRms = rms;
314
+ this.latestLevel += (normalized - this.latestLevel) * smoothing;
315
+ this.levelMonitorFrame = requestAnimationFrame(tick);
316
+ };
317
+
318
+ this.levelMonitorFrame = requestAnimationFrame(tick);
319
+ }
320
+
321
+ private async stopLevelMonitor(): Promise<void> {
322
+ if (this.levelMonitorFrame !== null) {
323
+ cancelAnimationFrame(this.levelMonitorFrame);
324
+ this.levelMonitorFrame = null;
325
+ }
326
+
327
+ if (this.sourceNode) {
328
+ try {
329
+ this.sourceNode.disconnect();
330
+ } catch {
331
+ // Best effort.
332
+ }
333
+ this.sourceNode = null;
334
+ }
335
+
336
+ if (this.analyserNode) {
337
+ try {
338
+ this.analyserNode.disconnect();
339
+ } catch {
340
+ // Best effort.
341
+ }
342
+ this.analyserNode = null;
343
+ }
344
+
345
+ if (this.audioContext) {
346
+ try {
347
+ await this.audioContext.close();
348
+ } catch {
349
+ // Best effort.
350
+ }
351
+ this.audioContext = null;
352
+ }
353
+
354
+ this.analyserData = null;
355
+ this.latestRms = 0;
356
+ this.latestLevel = 0;
357
+ }
358
+
359
+ async stopCapture(): Promise<void> {
360
+ if (!this.capturing && !this.mediaStream && !this.mediaRecorder) {
361
+ return;
362
+ }
363
+
364
+ const wasCapturing = this.capturing;
365
+ this.stopping = wasCapturing || this.stopping;
366
+
367
+ if (this.mediaRecorder) {
368
+ const recorder = this.mediaRecorder;
369
+ this.mediaRecorder = null;
370
+
371
+ if (recorder.state !== 'inactive') {
372
+ await new Promise<void>((resolve) => {
373
+ const timeout = setTimeout(resolve, 1000);
374
+ recorder.onstop = () => {
375
+ clearTimeout(timeout);
376
+ resolve();
377
+ };
378
+
379
+ try {
380
+ if (recorder.state === 'recording') {
381
+ try {
382
+ recorder.requestData();
383
+ } catch {
384
+ // Best effort; some runtimes may throw while stopping.
385
+ }
386
+ }
387
+ recorder.stop();
388
+ } catch {
389
+ clearTimeout(timeout);
390
+ resolve();
391
+ }
392
+ });
393
+ }
394
+
395
+ // Drain any in-flight chunk reads from the final MediaRecorder events.
396
+ if (this.inFlightChunkReads.size > 0) {
397
+ await Promise.allSettled(Array.from(this.inFlightChunkReads));
398
+ }
399
+ this.inFlightChunkReads.clear();
400
+
401
+ recorder.ondataavailable = null;
402
+ recorder.onerror = null;
403
+ recorder.onstop = null;
404
+ }
405
+
406
+ this.capturing = false;
407
+ this.stopping = false;
408
+ await this.stopLevelMonitor();
409
+
410
+ if (this.mediaStream) {
411
+ try {
412
+ this.mediaStream.getTracks().forEach((track) => track.stop());
413
+ } catch {
414
+ // Best effort
415
+ }
416
+ this.mediaStream = null;
417
+ }
418
+
419
+ if (wasCapturing) {
420
+ console.log('[AudioCaptureRenderer] Capture stopped');
421
+ }
422
+ }
423
+
424
+ isCapturing(): boolean {
425
+ return this.capturing;
426
+ }
427
+ }
428
+
429
+ // ============================================================================
430
+ // Module Initialization
431
+ // ============================================================================
432
+
433
+ let audioCaptureRenderer: AudioCaptureRenderer | null = null;
434
+
435
+ export function initAudioCapture(): AudioCaptureRenderer {
436
+ if (!audioCaptureRenderer) {
437
+ audioCaptureRenderer = new AudioCaptureRenderer();
438
+ }
439
+ return audioCaptureRenderer;
440
+ }
441
+
442
+ export function getAudioCapture(): AudioCaptureRenderer | null {
443
+ return audioCaptureRenderer;
444
+ }
445
+
446
+ export function destroyAudioCapture(): void {
447
+ if (audioCaptureRenderer) {
448
+ audioCaptureRenderer.destroy();
449
+ audioCaptureRenderer = null;
450
+ }
451
+ }
452
+
453
+ export { AudioCaptureRenderer };
454
+ export default initAudioCapture;