Compare commits

...
Author SHA1 Message Date
james.maesandClaude Opus 4.7 45f77277b4 wip: voice-log menu plumbing — archival from stash@{4} 2026-05-12
Archival snapshot of Voice + MenuBar work-in-progress that was sitting
in git stash from earlier multi-agent sessions. Identified during the
2026-05-12 PR/dependency triage (Lane H' on PR #155) as having
salvageable scope for v1.1 issue #52 (B-07 Voice Log menu).

This branch is intentionally based on the pre-v0.1 develop commit
d8983af where the stash was created. Reapplying onto current develop
(post-v0.1 Voice CRIT cluster at #158) will require manual rebase since
WakeWordDAG, VoiceController, and AudioGraphOwner all moved
significantly in #158. Treat this as reference / starting point, not a
mergeable patch.

Files touched (119 insertions, 7 deletions):
  - App/MenuBar/MenuBarContextMenu.swift
  - App/Tests/AppTests/MenuBarIconControllerTests.swift
  - packages/Voice/Sources/Voice/AudioGraph/AudioGraphOwner.swift
  - packages/Voice/Sources/Voice/VoiceController.swift
  - packages/Voice/Sources/Voice/VoiceInterfaces.swift
  - packages/Voice/Sources/Voice/WakeWord/WakeWordDAG.swift

Refs #52.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-13 06:49:33 -05:00
6 changed files with 119 additions and 7 deletions

No files matched your search

+6
View File
@@ -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