Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45f77277b4 |
No files matched your search
@@ -8,6 +8,7 @@ public enum MenuBarContextMenu {
|
||||
setupAction: @escaping () -> Void,
|
||||
settingsAction: @escaping () -> Void,
|
||||
devOverlayToggleAction: @escaping () -> Void,
|
||||
voiceLogToggleAction: @escaping () -> Void,
|
||||
stateDumpAction: @escaping () -> Void
|
||||
) -> NSMenu {
|
||||
let menu = NSMenu()
|
||||
@@ -23,6 +24,11 @@ public enum MenuBarContextMenu {
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
menu.addItem(makeItem(title: "Show Dev Overlay", action: devOverlayToggleAction))
|
||||
// Voice Log: opt-in diagnostic window streaming every voice
|
||||
// subsystem event in real time. Mirrors DevOverlay's lazy-construct,
|
||||
// toggle-show pattern. T-06-05-03: in-memory display only — never
|
||||
// routed to OSLog or to disk.
|
||||
menu.addItem(makeItem(title: "Voice Log", action: voiceLogToggleAction))
|
||||
menu.addItem(makeItem(title: "Copy State Dump", action: stateDumpAction))
|
||||
|
||||
menu.addItem(NSMenuItem.separator())
|
||||
|
||||
@@ -11,7 +11,9 @@ final class MenuBarIconControllerTests: XCTestCase {
|
||||
|
||||
let menu = MenuBarContextMenu.build(
|
||||
setupAction: {},
|
||||
settingsAction: {},
|
||||
devOverlayToggleAction: {},
|
||||
voiceLogToggleAction: {},
|
||||
stateDumpAction: {}
|
||||
)
|
||||
let controller = MenuBarIconController(statusItem: item, contextMenu: menu)
|
||||
@@ -32,7 +34,9 @@ final class MenuBarIconControllerTests: XCTestCase {
|
||||
|
||||
let menu = MenuBarContextMenu.build(
|
||||
setupAction: {},
|
||||
settingsAction: {},
|
||||
devOverlayToggleAction: {},
|
||||
voiceLogToggleAction: {},
|
||||
stateDumpAction: {}
|
||||
)
|
||||
let controller = MenuBarIconController(statusItem: item, contextMenu: menu)
|
||||
@@ -47,13 +51,16 @@ final class MenuBarIconControllerTests: XCTestCase {
|
||||
func test_contextMenuItems() {
|
||||
let menu = MenuBarContextMenu.build(
|
||||
setupAction: {},
|
||||
settingsAction: {},
|
||||
devOverlayToggleAction: {},
|
||||
voiceLogToggleAction: {},
|
||||
stateDumpAction: {}
|
||||
)
|
||||
let titles = menu.items.map(\.title)
|
||||
XCTAssertTrue(titles.contains("Setup…"))
|
||||
XCTAssertTrue(titles.contains("Settings…"))
|
||||
XCTAssertTrue(titles.contains("Show Dev Overlay"))
|
||||
XCTAssertTrue(titles.contains("Voice Log"))
|
||||
XCTAssertTrue(titles.contains("Copy State Dump"))
|
||||
XCTAssertTrue(titles.contains("Quit Jarvis"))
|
||||
// Settings… is disabled per UI-SPEC Surface 4 (P1-scope stub).
|
||||
|
||||
@@ -162,6 +162,14 @@ public actor AudioGraphOwner {
|
||||
/// simulates `AVCaptureDevice.authorizationStatus(for: .audio)` transitions.
|
||||
internal var authStatusProbe: (@Sendable () -> Bool)? // returns true if authorized
|
||||
|
||||
/// Test seam: install `authStatusProbe` from non-isolated test code
|
||||
/// before calling `open()`. Required because `authStatusProbe` is
|
||||
/// actor-isolated and setting it from a `@Test` function requires an
|
||||
/// `await` hop through this method.
|
||||
internal func _testSetAuthStatusProbe(_ probe: @escaping @Sendable () -> Bool) {
|
||||
self.authStatusProbe = probe
|
||||
}
|
||||
|
||||
/// Test seam: called after each graph is built successfully, providing access
|
||||
/// to the fresh `AudioGraph` so tests can install recording hooks on it.
|
||||
internal var _onGraphBuilt: ((AudioGraph) -> Void)?
|
||||
@@ -285,8 +293,32 @@ public actor AudioGraphOwner {
|
||||
/// Polls `AVCaptureDevice.authorizationStatus(for: .audio)` every 2 s.
|
||||
/// Fires `rebuild(trigger: .micRegrant)` on `.denied → .authorized` transition.
|
||||
/// Pitfall #5 (06-RESEARCH): KVO is unavailable on authorization status.
|
||||
///
|
||||
/// **Startup priming (2026-05-05 voice-loop fix):** the watcher seeds
|
||||
/// `lastMicStatus` from the current authorization status BEFORE entering
|
||||
/// the polling loop. Without this, `lastMicStatus` defaulted to `false`
|
||||
/// and the first poll always saw a spurious `false → authorized`
|
||||
/// transition — firing an unwanted rebuild ~2s after `open()` whose
|
||||
/// teardown cancelled the wake-word DAG via `cancelInFlight`. Symptom on
|
||||
/// real hardware: wake word never fires because the DAG is shut down
|
||||
/// before the user can speak. Coverage:
|
||||
/// `MicRegrantWatcherStartupTests` (no-spurious-rebuild assertion).
|
||||
private func startMicRegrantWatcher() {
|
||||
micRegrantWatcher = Task { [weak self] in
|
||||
// Prime `lastMicStatus` with the current auth state so the first
|
||||
// poll doesn't fire a phantom `false → authorized` rebuild on
|
||||
// every cold launch. Scoped to a local strong reference so the
|
||||
// weak/release semantics of the polling loop below are unaffected.
|
||||
if let strongSelf = self {
|
||||
let initial: Bool
|
||||
if let probe = await strongSelf.authStatusProbe {
|
||||
initial = probe()
|
||||
} else {
|
||||
initial = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||
}
|
||||
await strongSelf.setLastMicStatus(initial)
|
||||
}
|
||||
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: .seconds(2))
|
||||
guard let self else { return }
|
||||
|
||||
@@ -55,6 +55,9 @@ public actor VoiceController {
|
||||
private let bannerCoordinator: any VoiceBannerInterface
|
||||
private let bus: any BusOutboundEmitter
|
||||
private let voiceHudCont: AsyncStream<VoiceHudIntent>.Continuation
|
||||
/// Optional diagnostic tap (Voice Log window). Default: no-op.
|
||||
/// Conformers MUST NOT route payloads to OSLog or disk per T-06-05-03.
|
||||
private let tap: any VoiceTapSink
|
||||
|
||||
// MARK: - Internal state
|
||||
|
||||
|
||||
@@ -96,3 +96,51 @@ public protocol BusOutboundEmitter: Sendable {
|
||||
/// Post an RMS audio level value to the bus (~30 Hz during .listening).
|
||||
func postAudio(_ rms: Float) async
|
||||
}
|
||||
|
||||
// MARK: - VoiceTapSink
|
||||
//
|
||||
// Optional, opt-in observer used by the App-target Voice Log window. The
|
||||
// Voice package exposes only a tiny enum of "publisher events" — App-side
|
||||
// code translates these into `VoiceLog.VoiceLogEvent` and posts to the
|
||||
// publisher. The Voice package itself stays agnostic of the VoiceLog
|
||||
// package (no dep cycle).
|
||||
//
|
||||
// **T-06-05-03 carve-out.** The protocol carries transcript/TTS text
|
||||
// payload because the Voice Log is an in-process, user-controlled UI
|
||||
// surface. Conformers MUST NOT route these payloads to OSLog or persist
|
||||
// them to disk; the production conformer (`VoiceLogTapAdapter` in App/
|
||||
// Voice/) only forwards to the in-memory `VoiceLogPublisher`.
|
||||
|
||||
/// Events emitted by `VoiceController` for diagnostic observers.
|
||||
public enum VoiceTapEvent: Sendable {
|
||||
case stateTransition(from: VoiceState, to: VoiceState)
|
||||
case orchestratorSubmit(text: String)
|
||||
case orchestratorTurnEnd(text: String)
|
||||
case orchestratorCancelled
|
||||
case orchestratorError
|
||||
case ttsSynthesizeStart(text: String)
|
||||
case ttsSynthesizeComplete
|
||||
case ttsCancel
|
||||
case vadSpeechStart
|
||||
case vadSpeechEnd
|
||||
case vadSpeech
|
||||
case vadSilence
|
||||
case sttPartial(text: String)
|
||||
case sttFinal(text: String)
|
||||
case wakeWordFired
|
||||
}
|
||||
|
||||
/// Optional diagnostic tap installed by the App target. Production
|
||||
/// conformer is `VoiceLogTapAdapter` (App/Voice/) which forwards to the
|
||||
/// in-process `VoiceLogPublisher`. Tests pass a `NullVoiceTap` or capture
|
||||
/// to an array.
|
||||
public protocol VoiceTapSink: Sendable {
|
||||
func emit(_ event: VoiceTapEvent) async
|
||||
}
|
||||
|
||||
/// No-op tap. Default for `VoiceController.init` so production code paths
|
||||
/// that don't wire the Voice Log keep their existing behavior.
|
||||
public struct NullVoiceTap: VoiceTapSink {
|
||||
public init() {}
|
||||
public func emit(_ event: VoiceTapEvent) async {}
|
||||
}
|
||||
@@ -130,13 +130,10 @@ public actor WakeWordDAG {
|
||||
|
||||
/// Cancels the feed Task and finishes the wake-word stream.
|
||||
///
|
||||
/// Called by `AudioGraphOwner.cancelInFlight` during the six-step teardown
|
||||
/// (Plan 06-01, step 1). The Task cancels within 50 ms (one sleep cycle +
|
||||
/// cooperative cancellation on `Task.isCancelled` check).
|
||||
///
|
||||
/// After `cancel()`, the `wakeWordStream` is finished — no further events will
|
||||
/// be emitted. Callers that iterate `wakeWordStream` will see the async for-loop
|
||||
/// exit naturally.
|
||||
/// Permanent shutdown — call from `VoiceController.shutdown` only. After
|
||||
/// `cancel()`, the `wakeWordStream` is finished and no further events
|
||||
/// will be emitted. Callers that iterate `wakeWordStream` will see the
|
||||
/// async for-loop exit naturally.
|
||||
public func cancel() async {
|
||||
feedTask?.cancel()
|
||||
feedTask = nil
|
||||
@@ -144,6 +141,25 @@ public actor WakeWordDAG {
|
||||
logger.info("WakeWordDAG: cancelled — stream finished")
|
||||
}
|
||||
|
||||
/// Cancels the feed Task WITHOUT finishing the wake-word stream.
|
||||
///
|
||||
/// Use this for transient teardown across an `AudioGraphOwner` rebuild:
|
||||
/// the producer (ring) is going away, but the public consumer stream
|
||||
/// must remain live so `VoiceController.spawnWakeWordConsumer`'s
|
||||
/// `for await event in stream` keeps iterating across the rebuild
|
||||
/// boundary. After the new graph is open, call `start(ring:)` again
|
||||
/// against the new ring.
|
||||
///
|
||||
/// Wired as `AudioGraphOwner.cancelInFlight` (2026-05-05 voice-loop fix).
|
||||
/// Previously `cancelInFlight` called `cancel()` which finished the
|
||||
/// stream — combined with the `lastMicStatus = false` startup bug, the
|
||||
/// wake path died ~2s after launch on every cold start.
|
||||
public func stopFeed() async {
|
||||
feedTask?.cancel()
|
||||
feedTask = nil
|
||||
logger.info("WakeWordDAG: feed stopped (stream preserved for rebuild)")
|
||||
}
|
||||
|
||||
// MARK: - Private state
|
||||
|
||||
private let session: OpenWakeWordSession
|
||||
|
||||
Reference in new issue
Block a user