Skip to content

Commit df087a5

Browse files
initcore0claude
andcommitted
fix(audio): route WhisperKit capture via inputDeviceID (our fork), drop default-swap
Replaces the system-default capture-and-restore workaround for the WhisperKit streaming path with the clean upstream mechanism: pin WhisperKit to OUR fork (initcore0/argmax-oss-swift) at v1.0.0 + a single-file backport of upstream PR argmaxinc/argmax-oss-swift#503, which threads an optional `inputDeviceID` through `AudioStreamTranscriber` to `AudioProcessor.startRecordingLive(inputDeviceID:)`. Why fork instead of pinning the upstream PR branch: that branch is diverged from v1.0.0 (8 ahead / 1 behind, ~60 files incl. the CoreML AudioEncoder/TextDecoder/ FeatureExtractor paths the 1.0.0 pin + GPU-encoder choice deliberately hold stable on macOS 26) and lives on a personal fork we don't control. Our fork branch (openwhisp/v1.0.0-input-device) is v1.0.0 + ONLY the 4-line device patch, pinned by immutable commit. When #503 lands in a tagged upstream release we bump and delete the fork. Engine changes: - WhisperKitBridge.makeStreamHandle takes `inputDeviceID: AudioDeviceID?` and passes it to AudioStreamTranscriber(inputDeviceID:). - WhisperKitStreamingEngine resolves the selected UID to an AudioDeviceID and passes it through — deleting the DefaultInputOverride engage/poll/restore dance and the isCapturing() helper. Unresolved (incl. a resolve→nil TOCTOU) stays a hard error, never a silent default capture. - DefaultInputOverride remains in AudioInputRouter, now used ONLY by the legacy AVAudioRecorder path (which genuinely can only capture the system default). Verified on live hardware against the actual forked WhisperKit lib (linked as the app links it): AudioProcessor.startRecordingLive(inputDeviceID:) started the engine bound to a non-default device (id 243) while the system default (id 110) stayed UNCHANGED — direct per-device capture, no swap. Fresh-clone resolve pulls the fork at the pinned commit and builds; ./build.sh green; swift test 435/435. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 16fb3db commit df087a5

3 files changed

Lines changed: 47 additions & 90 deletions

File tree

OpenWhisp/Services/WhisperKitBridge.swift

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Foundation
2+
import CoreAudio // AudioDeviceID (the input-device id threaded to AudioStreamTranscriber)
23

34
/// Maps OpenWhisp's engine-facing language setting to WhisperKit decoding
45
/// options. Mirrors `WhisperTask` (used for whisper.cpp): the shared
@@ -212,10 +213,16 @@ enum WhisperKitBridge {
212213
/// "auto" we leave it nil and let WhisperKit detect; because the engine only
213214
/// surfaces CONFIRMED text as live partials, the per-window detection on the
214215
/// unconfirmed tail doesn't cause visible flapping.
216+
/// `inputDeviceID` (a CoreAudio `AudioDeviceID`) pins the input device the stream
217+
/// captures from; nil = system default input. Threaded straight into
218+
/// `AudioStreamTranscriber` (our WhisperKit fork backports upstream #503's
219+
/// `inputDeviceID` passthrough), which forwards it to `startRecordingLive` — so
220+
/// device routing needs NO system-default swap.
215221
static func makeStreamHandle(
216222
kit: WhisperKit,
217223
task: WhisperKitTaskMapper.Resolved,
218224
languageOverride: String?,
225+
inputDeviceID: AudioDeviceID? = nil,
219226
onState: @escaping (WhisperKitStreamState) -> Void
220227
) throws -> WhisperKitStreamHandle {
221228
guard let tokenizer = kit.tokenizer else {
@@ -249,6 +256,7 @@ enum WhisperKitBridge {
249256
audioProcessor: kit.audioProcessor,
250257
decodingOptions: options,
251258
useVAD: true, // skip silence — don't transcribe dead air
259+
inputDeviceID: inputDeviceID, // nil = system default (fork backport of #503)
252260
stateChangeCallback: { _, new in
253261
// Absolute-scale level for the silence VAD. `bufferEnergy` is
254262
// RELATIVE to WhisperKit's rolling 2s silence floor — right for a
@@ -354,14 +362,6 @@ final class WhisperKitStreamHandle {
354362
func start() async throws { try await transcriber?.startStreamTranscription() }
355363
func stop() async { await transcriber?.stopStreamTranscription() }
356364

357-
/// True once WhisperKit's `AudioProcessor` has built and started its capture
358-
/// engine — i.e. the input node is bound to the (currently-default) device.
359-
/// The streaming engine polls this to know when it's safe to restore a
360-
/// system-default-input override without losing the device binding.
361-
func isCapturing() -> Bool {
362-
(kit.audioProcessor as? AudioProcessor)?.audioEngine?.isRunning ?? false
363-
}
364-
365365
/// The full assembled transcript (confirmed + unconfirmed) as of the last state.
366366
func fullText() -> String {
367367
(latest?.fullText ?? "").trimmingCharacters(in: .whitespacesAndNewlines)

OpenWhisp/Services/WhisperKitStreamingEngine.swift

Lines changed: 22 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import Foundation
2+
import CoreAudio // AudioDeviceID (the input-device id threaded to WhisperKit)
23

34
/// Experimental real-time WhisperKit engine (streaming partials).
45
///
@@ -22,14 +23,10 @@ final class WhisperKitStreamingEngine: StreamingTranscriptionEngine {
2223
/// (multilingual EN+RU) — the same model the file engine uses.
2324
private let modelName: String
2425

25-
/// Pinned input-device UID for the next session ("" = system default).
26-
///
27-
/// Unlike Apple Speech, WhisperKit 1.0.0 owns the mic through
28-
/// `AudioStreamTranscriber` → `AudioProcessor.startRecordingLive()`, which is
29-
/// called with NO device (an internal API in a pinned remote dependency we can't
30-
/// patch). WhisperKit's engine setup binds whatever the SYSTEM DEFAULT input is
31-
/// at start. So the only way to route it to the selected device is to swap the
32-
/// system default around stream start and restore it — see `deviceOverride`.
26+
/// Pinned input-device UID for the next session ("" = system default). Resolved
27+
/// to a CoreAudio device in `runStart` and passed straight into WhisperKit's
28+
/// `AudioStreamTranscriber(inputDeviceID:)` (our fork backports upstream #503's
29+
/// passthrough), so capture targets the device directly — no system-default swap.
3330
private var selectedDeviceID = ""
3431

3532
func selectDevice(_ deviceID: String) {
@@ -49,11 +46,6 @@ final class WhisperKitStreamingEngine: StreamingTranscriptionEngine {
4946
// `handleState`. Touched only on the main actor (via the serialized chain).
5047
@MainActor private var transcriber: WhisperKitStreamHandle?
5148

52-
// Live system-default-input swap for the current session (nil when routing to the
53-
// system default). Engaged just before the stream starts and restored once the
54-
// engine is bound to the device (or on any teardown/error path). Main-actor only.
55-
@MainActor private var deviceOverride: AudioInputRouter.DefaultInputOverride?
56-
5749
// Last confirmed transcript we emitted as a partial — used so we only forward
5850
// forward-progress (monotonic confirmed text), avoiding paste churn from the
5951
// unconfirmed/hypothesis tail.
@@ -101,25 +93,27 @@ final class WhisperKitStreamingEngine: StreamingTranscriptionEngine {
10193
@MainActor
10294
private func runStart(task: WhisperKitTaskMapper.Resolved) async {
10395
do {
104-
// Route to the selected input device. WhisperKit binds the SYSTEM DEFAULT
105-
// input when its engine starts (no per-engine device seam in 1.0.0), so a
106-
// pinned device requires swapping the default around stream start and
107-
// restoring it once the engine is bound. An unresolved pinned device is a
108-
// hard error — never silently capture the default.
109-
//
110-
// RESOLVE here (fail fast before the possibly-slow model load) but ENGAGE
111-
// the swap only after ensureLoaded() below — the global default must not
112-
// stay changed for the whole model load (cold start can be seconds), only
113-
// for the brief window until the capture engine binds the device.
114-
let deviceToRoute: AudioDevice?
96+
// Resolve the selected input device. It's passed straight into
97+
// AudioStreamTranscriber(inputDeviceID:) (our WhisperKit fork backports
98+
// upstream #503), which forwards it to startRecordingLive — WhisperKit
99+
// captures the chosen device directly, no system-default swap needed. An
100+
// unresolved pinned device is a hard error, never a silent default capture.
101+
let inputDeviceID: AudioDeviceID?
115102
switch AudioInputRoutingPolicy.decide(
116103
microphoneID: selectedDeviceID,
117104
deviceResolved: AudioInputRouter.canResolve(uid: selectedDeviceID)
118105
) {
119106
case .systemDefault:
120-
deviceToRoute = nil
107+
inputDeviceID = nil
121108
case .useDevice(let uid):
122-
deviceToRoute = AudioInputRouter.resolve(uid: uid)
109+
// Resolve to the concrete device id. A nil here (device vanished
110+
// between canResolve and now) is treated as unresolved — a hard error,
111+
// NOT a silent nil that would fall back to the system default.
112+
guard let id = AudioInputRouter.resolve(uid: uid)?.deviceID else {
113+
onError?(AudioInputRoutingPolicy.unresolvedMessage(uid: uid))
114+
return
115+
}
116+
inputDeviceID = id
123117
case .unresolved(let uid):
124118
onError?(AudioInputRoutingPolicy.unresolvedMessage(uid: uid))
125119
return
@@ -131,22 +125,15 @@ final class WhisperKitStreamingEngine: StreamingTranscriptionEngine {
131125
let handle = try WhisperKitBridge.makeStreamHandle(
132126
kit: kit,
133127
task: task,
134-
languageOverride: nil
128+
languageOverride: nil,
129+
inputDeviceID: inputDeviceID
135130
) { [weak self] newState in
136131
Task { @MainActor in
137132
guard let self, self.generation == myGeneration else { return }
138133
self.handleState(newState)
139134
}
140135
}
141136
transcriber = handle
142-
143-
// Engage the default swap NOW (model loaded; the stream is about to grab
144-
// the mic). Restored once the engine binds the device (poll below) or on
145-
// any teardown/error path.
146-
if let device = deviceToRoute {
147-
let override = AudioInputRouter.DefaultInputOverride()
148-
if override.engage(device) { deviceOverride = override }
149-
}
150137
// `start()` runs the realtime loop until stopped; it returns when the
151138
// stream ends. Don't block the chain on it (a stop must be able to run),
152139
// so drive it in a detached child whose lifetime the stop tears down.
@@ -161,54 +148,16 @@ final class WhisperKitStreamingEngine: StreamingTranscriptionEngine {
161148
} catch {
162149
NSLog("[WhisperKitStream] stream error: %@", error.localizedDescription)
163150
guard let self, self.transcriber === handle, !self.didFinish else { return }
164-
self.restoreDeviceOverride() // startup failed; undo the default swap
165151
self.onError?("WhisperKit streaming failed: \(error.localizedDescription)")
166152
}
167153
}
168-
// Restore the system default promptly once WhisperKit's engine is bound to
169-
// the (now-default) device: the AudioUnit holds its CurrentDevice after
170-
// start, so the global default can go back with no effect on capture. This
171-
// keeps the user's default changed for a sub-second window, not the whole
172-
// session. Falls through to runStop's restore if the engine never binds.
173-
if deviceOverride != nil {
174-
restoreOverrideWhenCaptureLive(handle: handle, generation: myGeneration)
175-
}
176154
} catch {
177155
NSLog("[WhisperKitStream] start error: %@", error.localizedDescription)
178-
restoreDeviceOverride()
179156
guard !didFinish else { return }
180157
onError?("WhisperKit streaming failed: \(error.localizedDescription)")
181158
}
182159
}
183160

184-
/// Poll (bounded) for WhisperKit's capture engine to start, then restore the
185-
/// system-default-input override. The device binding survives the restore. If the
186-
/// engine never comes up within the budget the override is left for `runStop` to
187-
/// restore, so the default is never stranded.
188-
@MainActor
189-
private func restoreOverrideWhenCaptureLive(handle: WhisperKitStreamHandle, generation: Int) {
190-
Task { @MainActor [weak self] in
191-
// ~2s budget at 50ms granularity: engine start is normally tens of ms;
192-
// this only needs to outlast a slow cold start of the AVAudioEngine graph.
193-
for _ in 0..<40 {
194-
guard let self, self.generation == generation, self.transcriber === handle,
195-
!self.didFinish else { return }
196-
if handle.isCapturing() {
197-
self.restoreDeviceOverride()
198-
return
199-
}
200-
try? await Task.sleep(nanoseconds: 50_000_000)
201-
}
202-
}
203-
}
204-
205-
/// Restore the system default input if this session swapped it. Idempotent.
206-
@MainActor
207-
private func restoreDeviceOverride() {
208-
deviceOverride?.restore()
209-
deviceOverride = nil
210-
}
211-
212161
func stop(cancel: Bool) {
213162
// Synchronous main-actor enqueue (see start) so stop→start order is preserved.
214163
MainActor.assumeIsolated {
@@ -228,10 +177,6 @@ final class WhisperKitStreamingEngine: StreamingTranscriptionEngine {
228177
let handle = transcriber
229178
transcriber = nil
230179
didFinish = true
231-
// Safety net: if the stream ended before the capture-live poll restored the
232-
// system default (very short session, or the engine never bound), restore it
233-
// now so the user's default input is never left changed. Idempotent.
234-
restoreDeviceOverride()
235180
// Supersede the stream's generation so state callbacks it already
236181
// dispatched are dropped — they must not fire onPartial into whatever
237182
// session starts next. The final below is unaffected: it's computed

third_party/whisperkit-dep/Package.swift

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,27 @@ let package = Package(
1212
.library(name: "WhisperKitDep", type: .static, targets: ["WhisperKitDep"])
1313
],
1414
dependencies: [
15-
// Pinned to the current 1.0.0 release. macOS 26 model loading is handled
16-
// app-side via `modelFolder` + a GPU audio encoder (see docs/WHISPERKIT_PILOT.md),
17-
// not by the WhisperKit version.
18-
.package(url: "https://github.com/argmaxinc/WhisperKit.git", from: "1.0.0")
15+
// Pinned to OUR fork of WhisperKit (argmaxinc renamed the repo to
16+
// `argmax-oss-swift`), at the v1.0.0 release + a single-file backport of
17+
// upstream PR #503 (inputDeviceID passthrough on AudioStreamTranscriber) so
18+
// streaming/live capture can target a selected input device. We fork rather
19+
// than pin the contributor's branch because that branch also carries ~60
20+
// files of divergent macOS-26 CoreML/ANE churn; our branch is v1.0.0 + ONLY
21+
// that patch. Pinned by exact commit (immutable) — bump to an upstream
22+
// release once #503 lands there, then drop the fork. macOS 26 model loading
23+
// is still handled app-side via `modelFolder` + a GPU audio encoder (see
24+
// docs/WHISPERKIT_PILOT.md).
25+
//
26+
// Fork branch: openwhisp/v1.0.0-input-device (initcore0/argmax-oss-swift).
27+
.package(
28+
url: "https://github.com/initcore0/argmax-oss-swift.git",
29+
revision: "7e5f648249fde3eeabab02250529f63f16476e91"
30+
)
1931
],
2032
targets: [
2133
.target(
2234
name: "WhisperKitDep",
23-
dependencies: [.product(name: "WhisperKit", package: "WhisperKit")]
35+
dependencies: [.product(name: "WhisperKit", package: "argmax-oss-swift")]
2436
)
2537
]
2638
)

0 commit comments

Comments
 (0)