Phases 2-6: detection, visual timeline, backend hand-off, voiceprints
Phase 2 (call detection): CallDetector using CoreAudio per-process mic attribution (anarlog technique) — robust start+stop for Zoom/Teams/Signal/Meet, ignoring our own recording; auto-record toggle. Built; pending live multi-app confirmation by the user. Phase 3 (visual timeline foundation): AppAdapter protocol + SpeakerObservation, TimelineBuilder (hysteresis/overlap/self-merge/aliases), VisualTimeline (schema 1.1), TextRecognizer (Vision OCR), FrameSampler + GridCallAnalyzer (name OCR + saturated-highlight active-speaker attribution), SignalAdapter, VisualObserver (window capture; frames released, never saved; minimized->visual_gap, idle != gap). Synthetic-frame tested; adapter geometry pending real Signal fixtures + live VisualObserver validation. Phase 5 (backend hand-off): SparkControlClient (multipart label-merge, sequential, TLS-skip, 503 Retry-After/413), SessionPackager (chunk plan + WAV slice + timeline slice/rebase), TranscriptAssembler + SpeakersFile, TranscriptPipeline. Validated END-TO-END against the live backend (chunk -> label-merge -> speakers.json). Phase 6 (voiceprints): VoiceprintStore (known_voiceprints, persist named fingerprints, skip Unknown). Wired: 'Send to backend' button + transcript status, auto-send toggle (default off) + self-name setting. All adversarial-review findings fixed. App + XCTest suite build; tests pass.
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import CoreAudio
|
||||
import Foundation
|
||||
|
||||
/// Watches whether *any* app is using the default input device (the system-wide
|
||||
/// "mic is live" signal), via CoreAudio property listeners. Re-binds when the
|
||||
/// default input device changes (e.g. you plug in a headset mid-call).
|
||||
///
|
||||
/// Threading: ALL CoreAudio state (deviceID, listener blocks, `started`) and all
|
||||
/// Add/Remove calls are confined to the serial `queue`. `isRunning` is written
|
||||
/// and read only on the main thread (via `deliver`). `onChange` fires on main.
|
||||
final class MicActivityMonitor {
|
||||
private(set) var isRunning = false // main-thread only
|
||||
var onChange: ((Bool) -> Void)?
|
||||
|
||||
private let queue = DispatchQueue(label: "xyz.ten31.micmonitor")
|
||||
|
||||
// queue-confined:
|
||||
private var deviceID = AudioObjectID(kAudioObjectUnknown)
|
||||
private var runningBlock: AudioObjectPropertyListenerBlock?
|
||||
private var defaultDeviceBlock: AudioObjectPropertyListenerBlock?
|
||||
private var started = false
|
||||
|
||||
private static let runningAddr = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioDevicePropertyDeviceIsRunningSomewhere,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
|
||||
private static let defaultDeviceAddr = AudioObjectPropertyAddress(
|
||||
mSelector: kAudioHardwarePropertyDefaultInputDevice,
|
||||
mScope: kAudioObjectPropertyScopeGlobal,
|
||||
mElement: kAudioObjectPropertyElementMain)
|
||||
|
||||
func start() { queue.async { self.begin() } }
|
||||
|
||||
/// Called on the main thread (by the @MainActor CallDetector). Resets
|
||||
/// `isRunning` so a subsequent enable()'s synchronous evaluation can't read a
|
||||
/// stale `true` before the fresh reading arrives.
|
||||
func stop() {
|
||||
queue.sync { self.end() }
|
||||
isRunning = false
|
||||
}
|
||||
|
||||
// MARK: - queue-confined
|
||||
|
||||
private func begin() {
|
||||
guard !started else { return }
|
||||
started = true
|
||||
var addr = Self.defaultDeviceAddr
|
||||
let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in
|
||||
self?.rebindRunning() // delivered on `queue`
|
||||
}
|
||||
defaultDeviceBlock = block
|
||||
AudioObjectAddPropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject), &addr, queue, block)
|
||||
bindRunning()
|
||||
}
|
||||
|
||||
private func end() {
|
||||
started = false
|
||||
if let block = defaultDeviceBlock {
|
||||
var addr = Self.defaultDeviceAddr
|
||||
AudioObjectRemovePropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject), &addr, queue, block)
|
||||
defaultDeviceBlock = nil
|
||||
}
|
||||
unbindRunning()
|
||||
}
|
||||
|
||||
private func bindRunning() {
|
||||
guard started else { return }
|
||||
deviceID = Self.defaultInputDevice()
|
||||
guard deviceID != AudioObjectID(kAudioObjectUnknown) else { return }
|
||||
var addr = Self.runningAddr
|
||||
let block: AudioObjectPropertyListenerBlock = { [weak self] _, _ in
|
||||
guard let self else { return }
|
||||
self.deliver(Self.isDeviceRunning(self.deviceID)) // on `queue`
|
||||
}
|
||||
runningBlock = block
|
||||
// Install the listener BEFORE the initial read so a flip during setup is
|
||||
// caught (either by the now-installed listener or the post-install read).
|
||||
AudioObjectAddPropertyListenerBlock(deviceID, &addr, queue, block)
|
||||
deliver(Self.isDeviceRunning(deviceID))
|
||||
}
|
||||
|
||||
private func unbindRunning() {
|
||||
if deviceID != AudioObjectID(kAudioObjectUnknown), let block = runningBlock {
|
||||
var addr = Self.runningAddr
|
||||
AudioObjectRemovePropertyListenerBlock(deviceID, &addr, queue, block)
|
||||
}
|
||||
runningBlock = nil
|
||||
deviceID = AudioObjectID(kAudioObjectUnknown)
|
||||
}
|
||||
|
||||
private func rebindRunning() {
|
||||
guard started else { return }
|
||||
unbindRunning()
|
||||
bindRunning()
|
||||
}
|
||||
|
||||
private func deliver(_ running: Bool) {
|
||||
DispatchQueue.main.async {
|
||||
let changed = running != self.isRunning
|
||||
self.isRunning = running
|
||||
if changed { self.onChange?(running) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CoreAudio reads (use local address copies)
|
||||
|
||||
private static func defaultInputDevice() -> AudioObjectID {
|
||||
var addr = defaultDeviceAddr
|
||||
var device = AudioObjectID(kAudioObjectUnknown)
|
||||
var size = UInt32(MemoryLayout<AudioObjectID>.size)
|
||||
let status = AudioObjectGetPropertyData(
|
||||
AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &size, &device)
|
||||
return status == noErr ? device : AudioObjectID(kAudioObjectUnknown)
|
||||
}
|
||||
|
||||
private static func isDeviceRunning(_ device: AudioObjectID) -> Bool {
|
||||
guard device != AudioObjectID(kAudioObjectUnknown) else { return false }
|
||||
var addr = runningAddr
|
||||
var value: UInt32 = 0
|
||||
var size = UInt32(MemoryLayout<UInt32>.size)
|
||||
let status = AudioObjectGetPropertyData(device, &addr, 0, nil, &size, &value)
|
||||
return status == noErr && value != 0
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user