Skip to content

Commit abf47f0

Browse files
minor changes
1 parent 3fc02a6 commit abf47f0

8 files changed

Lines changed: 369 additions & 10 deletions

File tree

Playground/YapRun/YapRun/Core/AppTypes.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import SwiftUI
1111

1212
enum DictationPhase: Equatable {
1313
case idle
14+
case loadingModel
1415
case recording
1516
case transcribing
1617
case inserting

Playground/YapRun/YapRun/Core/ModelRegistry.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,14 @@ enum ModelRegistry {
2020
let sizeBytes: Int64
2121
}
2222

23-
/// Default model used during onboarding.
23+
/// Default model used during onboarding and auto-load.
24+
/// macOS uses the larger "Accurate" model (Neural Engine handles it easily).
25+
/// iOS uses the smaller "Fast" model to conserve battery/memory.
26+
#if os(macOS)
27+
static let defaultModelId = "whisperkit-base.en"
28+
#else
2429
static let defaultModelId = "whisperkit-tiny.en"
30+
#endif
2531

2632
/// Curated ASR models with consumer-friendly names (tar.gz for fast native gzip extraction on iOS/macOS).
2733
static let asrModels: [ASRModel] = [

Playground/YapRun/YapRun/Features/VoiceKeyboard/FlowSessionManager.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,18 @@ final class FlowSessionManager: ObservableObject {
102102
transition(to: .idle)
103103
}
104104

105+
/// Hard kill: tears down everything and immediately removes all live activities.
106+
func killSession() async {
107+
logger.info("Flow session killing — immediate teardown")
108+
109+
elapsedTask?.cancel()
110+
audioCapture.stopRecording()
111+
112+
if #available(iOS 16.1, *) { await endAllLiveActivitiesImmediately() }
113+
SharedDataBridge.shared.clearSession()
114+
transition(to: .idle)
115+
}
116+
105117
// MARK: - Session Activation
106118

107119
private func activateSession() async {
@@ -295,6 +307,13 @@ final class FlowSessionManager: ObservableObject {
295307
logger.info("Live Activities not enabled — skipping")
296308
return
297309
}
310+
311+
// End any orphaned live activities from previous sessions to prevent stacking
312+
for orphan in Activity<DictationActivityAttributes>.activities {
313+
logger.info("Ending orphaned Live Activity: \(orphan.id)")
314+
Task { await orphan.end(nil, dismissalPolicy: .immediate) }
315+
}
316+
298317
let attributes = DictationActivityAttributes(sessionId: UUID().uuidString)
299318
let state = DictationActivityAttributes.ContentState(
300319
phase: "ready", elapsedSeconds: 0, transcript: "", wordCount: 0
@@ -333,6 +352,21 @@ final class FlowSessionManager: ObservableObject {
333352
logger.info("Live Activity ended")
334353
}
335354

355+
/// Immediately ends the tracked live activity AND any orphaned activities.
356+
@available(iOS 16.1, *)
357+
private func endAllLiveActivitiesImmediately() async {
358+
// End the tracked activity
359+
if let activity = liveActivity {
360+
await activity.end(nil, dismissalPolicy: .immediate)
361+
liveActivity = nil
362+
}
363+
// Also sweep any orphans (e.g. from a previous crash)
364+
for activity in Activity<DictationActivityAttributes>.activities {
365+
await activity.end(nil, dismissalPolicy: .immediate)
366+
}
367+
logger.info("All Live Activities ended immediately")
368+
}
369+
336370
// MARK: - Elapsed Timer + Heartbeat
337371

338372
private func startElapsedTimer() {

Playground/YapRun/YapRun/YapRunApp.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,10 +106,9 @@ struct YapRunApp: App {
106106
showFlowActivation = true
107107
Task { await flowSession.handleStartFlow() }
108108
case "kill":
109-
logger.info("Received kill deep link — ending session and terminating")
109+
logger.info("Received kill deep link — killing session and terminating")
110110
Task {
111-
await flowSession.endSession()
112-
try? await Task.sleep(nanoseconds: 300_000_000)
111+
await flowSession.killSession()
113112
exit(0)
114113
}
115114
case "playground":

Playground/YapRun/YapRun/macOS/Features/FlowBarView.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ struct FlowBarView: View {
2121
switch dictation.phase {
2222
case .idle:
2323
idleContent
24+
case .loadingModel:
25+
loadingModelContent
2426
case .recording:
2527
recordingContent
2628
case .transcribing:
@@ -51,6 +53,16 @@ struct FlowBarView: View {
5153

5254
// MARK: - States
5355

56+
private var loadingModelContent: some View {
57+
HStack(spacing: 6) {
58+
ProgressView()
59+
.scaleEffect(0.6)
60+
Text("Loading model...")
61+
.font(.system(size: 12, weight: .medium))
62+
.foregroundStyle(.white.opacity(0.7))
63+
}
64+
}
65+
5466
private var idleContent: some View {
5567
HStack(spacing: 6) {
5668
Image(systemName: "waveform")

Playground/YapRun/YapRun/macOS/MacAppDelegate.swift

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,32 @@ final class MacAppDelegate: NSObject, NSApplicationDelegate {
7777
if discovered > 0 {
7878
logger.info("Discovered \(discovered) previously downloaded models")
7979
}
80+
81+
// Auto-load preferred model so dictation is ready immediately
82+
await autoLoadPreferredModel()
8083
} catch {
8184
logger.error("SDK initialization failed: \(error.localizedDescription)")
8285
}
8386
}
8487

88+
private func autoLoadPreferredModel() async {
89+
let preferredId = UserDefaults.standard.string(forKey: "preferredSTTModelId")
90+
?? ModelRegistry.defaultModelId
91+
92+
guard let allModels = try? await RunAnywhere.availableModels(),
93+
let model = allModels.first(where: { $0.id == preferredId }),
94+
model.localPath != nil else {
95+
return
96+
}
97+
98+
do {
99+
try await RunAnywhere.loadSTTModel(preferredId)
100+
logger.info("Auto-loaded STT model: \(preferredId)")
101+
} catch {
102+
logger.error("Auto-load STT model failed: \(error.localizedDescription)")
103+
}
104+
}
105+
85106
// MARK: - Hub Window
86107

87108
func showHub() {

Playground/YapRun/YapRun/macOS/Services/MacDictationService.swift

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ final class MacDictationService {
3030
private let audioCapture = AudioCaptureManager()
3131
private var audioBuffer = Foundation.Data()
3232
private var timerTask: Task<Void, Never>?
33+
private var modelLoadTask: Task<Void, Never>?
34+
private var hotkeyIsDown = false
3335
private var cancellables = Set<AnyCancellable>()
3436
private let logger = Logger(subsystem: "com.runanywhere.yaprun", category: "Dictation")
3537

@@ -45,6 +47,7 @@ final class MacDictationService {
4547
.receive(on: DispatchQueue.main)
4648
.sink { [weak self] in
4749
guard let self else { return }
50+
self.hotkeyIsDown = true
4851
Task { await self.beginRecording() }
4952
}
5053
.store(in: &cancellables)
@@ -53,6 +56,7 @@ final class MacDictationService {
5356
.receive(on: DispatchQueue.main)
5457
.sink { [weak self] in
5558
guard let self else { return }
59+
self.hotkeyIsDown = false
5660
Task { await self.finishRecordingAndTranscribe() }
5761
}
5862
.store(in: &cancellables)
@@ -70,9 +74,13 @@ final class MacDictationService {
7074
func toggleFromFlowBar() async {
7175
switch phase {
7276
case .idle:
77+
hotkeyIsDown = true
7378
await beginRecording()
7479
case .recording:
80+
hotkeyIsDown = false
7581
await finishRecordingAndTranscribe()
82+
case .loadingModel:
83+
cancelModelLoad()
7684
default:
7785
break
7886
}
@@ -83,19 +91,76 @@ final class MacDictationService {
8391
private func beginRecording() async {
8492
guard phase == .idle else { return }
8593

86-
guard await RunAnywhere.currentSTTModel != nil else {
87-
phase = .error("No STT model loaded")
94+
// If no model loaded, auto-download/load the default
95+
if await RunAnywhere.currentSTTModel == nil {
96+
await ensureModelLoaded()
97+
// If model load failed or user released the key, stop here
98+
guard phase == .loadingModel else { return }
99+
// Model is now loaded — continue to recording
100+
}
101+
102+
await startMicAndRecord()
103+
}
104+
105+
/// Downloads (if needed) and loads the preferred STT model.
106+
/// Sets phase to `.loadingModel` during the process.
107+
private func ensureModelLoaded() async {
108+
phase = .loadingModel
109+
logger.info("No STT model loaded — auto-loading default")
110+
111+
let modelId = UserDefaults.standard.string(forKey: "preferredSTTModelId")
112+
?? ModelRegistry.defaultModelId
113+
114+
do {
115+
let allModels = try await RunAnywhere.availableModels()
116+
guard let model = allModels.first(where: { $0.id == modelId }) else {
117+
phase = .error("Model not found")
118+
resetAfterDelay()
119+
return
120+
}
121+
122+
// Download if not already on disk
123+
if model.localPath == nil {
124+
logger.info("Downloading model: \(modelId)")
125+
let stream = try await RunAnywhere.downloadModel(modelId)
126+
for await progress in stream {
127+
// Bail out if user released hotkey during download
128+
guard phase == .loadingModel else { return }
129+
if progress.stage == .completed { break }
130+
}
131+
}
132+
133+
// Bail out if user released hotkey during download
134+
guard phase == .loadingModel else { return }
135+
136+
// Load the model
137+
logger.info("Loading model: \(modelId)")
138+
try await RunAnywhere.loadSTTModel(modelId)
139+
UserDefaults.standard.set(modelId, forKey: "preferredSTTModelId")
140+
logger.info("Model \(modelId) auto-loaded successfully")
141+
} catch {
142+
guard phase == .loadingModel else { return }
143+
phase = .error("Model load failed")
144+
logger.error("Auto-load failed: \(error.localizedDescription)")
88145
resetAfterDelay()
89-
return
90146
}
147+
}
91148

149+
/// Starts the microphone and transitions to the recording phase.
150+
private func startMicAndRecord() async {
92151
let permitted = await audioCapture.requestPermission()
93152
guard permitted else {
94153
phase = .error("Microphone access required")
95154
resetAfterDelay()
96155
return
97156
}
98157

158+
// Check if user released key while we were requesting permission
159+
guard hotkeyIsDown || phase == .loadingModel else {
160+
phase = .idle
161+
return
162+
}
163+
99164
audioBuffer = Foundation.Data()
100165
elapsedSeconds = 0
101166

@@ -117,6 +182,12 @@ final class MacDictationService {
117182
}
118183

119184
private func finishRecordingAndTranscribe() async {
185+
// If still loading model, cancel the load and go idle
186+
if phase == .loadingModel {
187+
cancelModelLoad()
188+
return
189+
}
190+
120191
guard phase == .recording else { return }
121192

122193
audioCapture.stopRecording()
@@ -153,11 +224,20 @@ final class MacDictationService {
153224
}
154225
}
155226

227+
private func cancelModelLoad() {
228+
modelLoadTask?.cancel()
229+
modelLoadTask = nil
230+
phase = .idle
231+
logger.info("Model load cancelled")
232+
}
233+
156234
private func cancelRecording() {
157235
audioCapture.stopRecording()
158236
audioLevel = 0
159237
timerTask?.cancel()
160238
timerTask = nil
239+
modelLoadTask?.cancel()
240+
modelLoadTask = nil
161241
audioBuffer = Foundation.Data()
162242
phase = .idle
163243
}

0 commit comments

Comments
 (0)