Skip to content

Commit 7451fc8

Browse files
committed
Harden ExecuWhisper for internal release
Add production-readiness fixes for audio routing, formatter validation, helper signing, release verification, and support diagnostics. This keeps the existing app PR focused while making the DMG safer to dogfood across different Mac audio setups. Made-with: Cursor
1 parent 29caabb commit 7451fc8

25 files changed

Lines changed: 1013 additions & 112 deletions

ExecuWhisper/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# ExecuWhisper Changelog
2+
3+
## Internal DMG - 2026-04-30
4+
5+
- Added production-readiness hardening for audio capture, smart formatter validation, release signing, and internal DMG verification.
6+
- Lightweight DMG SHA256:
7+
`e01401325119cd76df3d32c172a78b610bd814244cb07dc42431bd64af862dfc`

ExecuWhisper/ExecuWhisper/Models/TranscriptStore.swift

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,12 @@ final class TranscriptStore {
5555
private let sessionsURL: URL
5656
private let textPipeline: TextPipeline?
5757
private let audioDecoder: any ImportedAudioDecoding
58+
private let maxRecordingDuration: TimeInterval
5859
private var recordingStartDate: Date?
5960
private var initialized = false
6061
private var warmupTask: Task<Void, Never>?
62+
private var recordingLimitTask: Task<Void, Never>?
63+
private var explicitlyUnloaded = false
6164

6265
init(
6366
preferences: Preferences,
@@ -66,7 +69,8 @@ final class TranscriptStore {
6669
textPipeline: TextPipeline? = nil,
6770
audioDecoder: any ImportedAudioDecoding = ImportedAudioDecoder(),
6871
recorder: AudioRecorder = AudioRecorder(),
69-
runner: any RunnerBridgeClient = RunnerBridge()
72+
runner: any RunnerBridgeClient = RunnerBridge(),
73+
maxRecordingDuration: TimeInterval = 30 * 60
7074
) {
7175
self.recorder = recorder
7276
self.runner = runner
@@ -75,6 +79,7 @@ final class TranscriptStore {
7579
self.sessionsURL = sessionsURL
7680
self.textPipeline = textPipeline
7781
self.audioDecoder = audioDecoder
82+
self.maxRecordingDuration = maxRecordingDuration
7883
loadSessions()
7984
}
8085

@@ -230,10 +235,12 @@ final class TranscriptStore {
230235
}
231236

232237
func preloadModel() async {
238+
explicitlyUnloaded = false
233239
await performHelperWarmupIfNeeded(updateStatusMessage: true)
234240
}
235241

236242
func unloadModel() async {
243+
explicitlyUnloaded = true
237244
warmupTask?.cancel()
238245
warmupTask = nil
239246
await runner.shutdown()
@@ -279,6 +286,9 @@ final class TranscriptStore {
279286
currentError = nil
280287
sessionState = .recording
281288
recordingStartDate = .now
289+
scheduleRecordingLimit {
290+
await self.stopRecordingAndTranscribe()
291+
}
282292

283293
do {
284294
try await recorder.startRecording(selectedMicrophoneID: preferences.selectedMicrophoneID) { [weak self] level in
@@ -288,9 +298,11 @@ final class TranscriptStore {
288298
}
289299
startBackgroundWarmupIfNeeded()
290300
} catch let error as RunnerError {
301+
cancelRecordingLimit()
291302
currentError = error
292303
sessionState = .idle
293304
} catch {
305+
cancelRecordingLimit()
294306
currentError = .launchFailed(description: error.localizedDescription)
295307
sessionState = .idle
296308
}
@@ -344,11 +356,13 @@ final class TranscriptStore {
344356
storeLog.info("Dictation capture started")
345357
return true
346358
} catch let error as RunnerError {
359+
cancelRecordingLimit()
347360
storeLog.error("Dictation capture failed to start: \(error.localizedDescription, privacy: .public)")
348361
currentError = error
349362
resetLiveState(status: "Ready")
350363
return false
351364
} catch {
365+
cancelRecordingLimit()
352366
storeLog.error("Dictation capture failed with unexpected error: \(error.localizedDescription, privacy: .public)")
353367
currentError = .launchFailed(description: error.localizedDescription)
354368
resetLiveState(status: "Ready")
@@ -365,6 +379,7 @@ final class TranscriptStore {
365379
sessionState = .transcribing
366380
statusMessage = "Transcribing..."
367381
audioLevel = 0
382+
cancelRecordingLimit()
368383
storeLog.info("Dictation capture stopping after duration=\(duration, format: .fixed(precision: 3))s")
369384

370385
do {
@@ -388,6 +403,7 @@ final class TranscriptStore {
388403
sessionState = .transcribing
389404
statusMessage = "Finalizing recording..."
390405
audioLevel = 0
406+
cancelRecordingLimit()
391407

392408
do {
393409
let pcmData = try await recorder.stopRecording()
@@ -634,7 +650,11 @@ final class TranscriptStore {
634650
case .completed(let result):
635651
finalResult = result
636652
storeLog.info("Runner completed event textLength=\(result.text.count) stdoutLength=\(result.stdout.count) stderrLength=\(result.stderr.count)")
637-
storeLog.info("Parakeet transcript: \(result.text, privacy: .public)")
653+
if DiagnosticLogging.shouldLogTranscriptsPublicly {
654+
storeLog.info("Parakeet transcript: \(result.text, privacy: .public)")
655+
} else {
656+
storeLog.info("Parakeet transcript: \(result.text, privacy: .private)")
657+
}
638658
if let runtimeProfile = result.runtimeProfile {
639659
storeLog.info("Runner runtime profile: \(runtimeProfile, privacy: .public)")
640660
}
@@ -659,6 +679,7 @@ final class TranscriptStore {
659679
}
660680

661681
private func autoPreloadModelIfReady() async {
682+
guard !explicitlyUnloaded else { return }
662683
guard resourcesReady else { return }
663684
guard helperState == .unloaded || helperState == .failed else { return }
664685
await performHelperWarmupIfNeeded(updateStatusMessage: false)
@@ -691,6 +712,7 @@ final class TranscriptStore {
691712
)
692713
helperState = .warm
693714
helperStatusMessage = "Model preloaded"
715+
logResidentMemory(context: "Parakeet helper preloaded")
694716
if updateStatusMessage && !hasActiveSession {
695717
statusMessage = "Ready"
696718
}
@@ -728,6 +750,15 @@ final class TranscriptStore {
728750
}
729751
}
730752

753+
private func logResidentMemory(context: String) {
754+
guard UserDefaults.standard.bool(forKey: DiagnosticLogging.transcriptDebugKey),
755+
let bytes = DiagnosticLogging.residentMemoryBytes()
756+
else {
757+
return
758+
}
759+
storeLog.info("\(context, privacy: .public) residentMemoryBytes=\(bytes)")
760+
}
761+
731762
@discardableResult
732763
private func processCompletedTranscription(
733764
rawText: String,
@@ -764,12 +795,30 @@ final class TranscriptStore {
764795
}
765796

766797
private func resetLiveState(status: String = "Ready") {
798+
cancelRecordingLimit()
767799
audioLevel = 0
768800
sessionState = .idle
769801
recordingStartDate = nil
770802
statusMessage = status
771803
}
772804

805+
private func scheduleRecordingLimit(onLimit: @escaping @MainActor () async -> Void) {
806+
cancelRecordingLimit()
807+
guard maxRecordingDuration > 0 else { return }
808+
recordingLimitTask = Task { @MainActor [weak self] in
809+
try? await Task.sleep(for: .seconds(maxRecordingDuration))
810+
guard let self, self.sessionState == .recording else { return }
811+
self.statusMessage = "Maximum recording duration reached"
812+
self.currentError = .transcriptionFailed(description: "Maximum recording duration reached.")
813+
await onLimit()
814+
}
815+
}
816+
817+
private func cancelRecordingLimit() {
818+
recordingLimitTask?.cancel()
819+
recordingLimitTask = nil
820+
}
821+
773822
private var formatterAssetsReady: Bool {
774823
let fm = FileManager.default
775824
return fm.fileExists(atPath: preferences.formatterModelPath)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
{
2+
"version": 1,
3+
"repositories": {
4+
"asr": "younghan-meta/Parakeet-TDT-ExecuTorch-Metal",
5+
"formatter": "younghan-meta/LFM2.5-ExecuTorch-MLX"
6+
},
7+
"assets": {
8+
"model.pte": {
9+
"bytes": 822365760,
10+
"sha256": "406c7625094faefd019932cceba317954c1fc60de068b0be372570bfe5509ef2"
11+
},
12+
"tokenizer.model": {
13+
"bytes": 360916,
14+
"sha256": "eacec2b0a77f336d4a2ca4a25a7047575d3c2b74de47e997f4c205126ed3135e"
15+
},
16+
"lfm2_5_350m_mlx_4w.pte": {
17+
"bytes": 322767616,
18+
"sha256": "a22641ff8364b813bc5808428bf13ca7525df9995b1721b58b414a35da8312c9"
19+
},
20+
"tokenizer.json": {
21+
"bytes": 4733383,
22+
"sha256": "2221c71b5dce048a8abae62843b92bd7deec13cc153f95fa6e3327a47b79a7da"
23+
},
24+
"tokenizer_config.json": {
25+
"bytes": 595,
26+
"sha256": "3701f370c70034d06e28947ff51c1063983393319f429e3ce58f06a45fae67cc"
27+
}
28+
}
29+
}

0 commit comments

Comments
 (0)