User feedback after deep audit: voice modality reported as "F — nothing
works". The audit identified five independent fatal breaks; this commit
closes the first three under TDD discipline. B-4 (AudioGraphOwner +
LiveSpeechAnalyzerBridge) and B-5 (voice loop e2e test) are the next
session's work.
**B-1 — voice models bundled at the runtime path:**
- scripts/copy-voice-models.sh — new postBuildScript that copies
Resources/models/{openwakeword,silero}/ → Contents/Resources/Models/
{openWakeWord,silero}/. Maps lowercase canonical-on-disk to capital-M
runtime path AppDelegate.installVoice expects (was off-by-case +
no copy phase at all). Hard-fails on PLACEHOLDER_… sha256 in
MANIFEST.json so a forgotten fetch script is a build error, not a
silent runtime failure.
- scripts/test-copy-voice-models.sh — 6 shell-level tests for the
copy script (real-real bundle layout, PLACEHOLDER hard-fail,
missing manifest, soft-degrade, capital-M path matches AppDelegate).
All 6 pass.
- project.yml — adds the copy phase to Jarvis target's postBuildScripts.
- Resources/models/silero/MANIFEST.json — populated with real
sha256 hashes by running scripts/fetch-silero-models.sh.
**B-2 — WakeWordDAG.swift:93 production typo:**
- packages/Voice/Sources/Voice/WakeWord/WakeWordDAG.swift — was
`session.feedTest()` (test seam, always passes empty `[Float]` to
hysteresis). Production path now calls `session.feed(samples:)` with
the actual ring buffer slice. Without this, the wake word literally
could never fire in production — every frame hit the hysteresis with
zero samples regardless of mic input.
- packages/Voice/Tests/VoiceTests/WakeWordDAGTests.swift — 2 new
tests: WD-1 verifies the DAG forwards actual ring contents to the
session's classifier (RED on the bug; GREEN after the fix); WD-2
verifies an empty ring doesn't trigger wasted classifier calls.
**B-3 — tier-1 TTS engine wired:**
- packages/Voice/Sources/Voice/TTS/TTSEngineActor.swift — `orpheus`
parameter is now `OrpheusTTS?` (was non-optional). Production
callers can wire just AVSpeechSynthesizer without first downloading
Orpheus 3B weights from HuggingFace. Tier-2 requested with nil
Orpheus degrades to tier-1 transparently via a new resolvedTier
branch; defensive guard throws TTSError.sinkUnavailable if the
branch ever leaks through.
- packages/Voice/Tests/VoiceTests/TTSEngineActorTier1Tests.swift —
4 new tests: TET1-1 (construction with nil Orpheus), TET1-2 (real
AVSpeechSynthesizer emits .started → .firstAudio → .finished),
TET1-3 (.tier2 with nil Orpheus does NOT crash), TET1-4 (rapid-fire
cancels prior synthesis).
- App/Voice/VoiceOutputWiring.swift — new factory enum that constructs
the tier-1 engine. Lives outside AppDelegate.swift so the VISION-03
Layer 3 grep gate (no PresenceSignalBus + TTS engine in same file)
stays clean.
- App/AppDelegate.swift — `VoiceTTSAdapter(engine: nil)` becomes
`VoiceTTSAdapter(engine: VoiceOutputWiring.makeTier1Engine())`.
Voice subsystem now has a real synthesis path on every launch.
**Verification:**
- AgentCore: 215/215.
- Voice: WakeWordDAGTests 2/2, TTSEngineActorTier1Tests 4/4.
- Replay: 33/33.
- Bus: 57/58 (F-A2-01 pre-existing).
- webview/hud: 94/94.
- All 15 boundary grep gates: PASS (including check-presence-vision-
isolation.sh — VoiceOutputWiring.swift renamed from TTSEngineWiring
+ comment text scrubbed of PresenceSignalBus references so the
Layer 3 substring check no longer flags it).
- scripts/check-app-builds.sh: PASS.
- Models verified at bundle path Contents/Resources/Models/
{openWakeWord,silero}/ with real .onnx files.
**Carry-forward to next session:**
- B-4: construct AudioGraphOwner in installVoice; start AVAudioEngine;
route mic taps to wake-word DAG. Replace LiveSpeechAnalyzerBridge.feed
body (`_ = chunk`) with the macOS 26 SpeechAnalyzer wiring.
- B-5: voice loop e2e — feed a known WAV file at the audio graph,
assert STT result lands on the orchestrator.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes the wake-hysteresis deferral noted in 08-VERIFICATION.md by
wiring the production OpenWakeWordSession through the full corpus
loop and seeding labels.json with TTS-synthesized clips.
Voice package
-------------
Adds public `feed(samples: [Float])` overload on OpenWakeWordSession
alongside the existing `feed(_:UnsafeBufferPointer<Float>)` API. The
new overload exists specifically because Swift 6 disallows escaping
an unsafe buffer pointer from the sync `withUnsafeBufferPointer`
closure into an async-suspended actor hop — the harness runner's
`for clip in corpus.clips` loop hits exactly that pattern. Production
audio-tap callers keep the buffer-pointer path to avoid the per-frame
Array copy. Hysteresis logic is shared via `runHysteresis(pcmSamples:)`.
Harness runner
--------------
Replaces the `pipelineNotWired` diagnostic with the actual
mel→embedding→classifier feed loop:
- decode WAV via WAVDecoder (16 kHz mono Float32),
- slice into 1280-sample frames (80 ms @ 16 kHz, openWakeWord chunk),
- await session.feed(samples: frame) per frame,
- count TP/FN/FP/TN per clip label.
Computes farPerHour from totalDurationSeconds and frrPercent from the
positive set; runs the D-18 thresholds (PASS at FAR ≤ 1.0/hr AND
FRR ≤ 10%; WARN between published targets and the fail line).
Seed corpus
-----------
labels.json populated with 10 TTS-synthesized clips (5 positive
"Hey Jarvis" across Daniel/Samantha/Karen/Tom/Victoria voices at
varying rates 150–200 wpm, 5 negative phrases) generated via
macOS `say` + `afconvert` to 16 kHz mono PCM. Operators replace
these per-host per the README protocol; the corpus contract is
that BOTH labels are present, not the exact clip count.
MANIFEST.json
-------------
Pinned SHA-256 hashes for melspectrogram / embedding_model /
hey_jarvis_v0.1 (was placeholder). Locked weight versions:
ba2b0e0f... melspectrogram.onnx
70d16429... embedding_model.onnx
94a13cfe... hey_jarvis_v0.1.onnx
Tests
-----
- 60/60 swift-testing in 9 suites pass; renamed
`defaultCorpusIsEmpty` → `seedCorpusHasBothLabels` to reflect the
new ship-with-baseline contract.
- 17/17 Voice tests still pass after the public surface widening.
Latent Voice-package gap surfaced
---------------------------------
Running `jarvis-eval wake-corpus` end-to-end against the real ONNX
models reveals an ORT chain bug in OpenWakeWordSession.runORT that
predates Phase 8: the embedding model's input is named `input_1`
(not `input`), the classifier's input is named `x.1` (not `input`),
and the mel→embedding hop needs a 76-frame mel window with reshape
to [_, 76, 32, 1] before feeding the embedding stage. None of the
existing Voice tests exercise this path (they use the internal
`scriptedClassifier:` seam via `feedTest()`). Routing the fix to the
Voice phase since this is a P6 implementation gap, not a P8 harness
issue — the harness is now correctly surfacing the gap as a runtime
error instead of silently passing.
Closes deferral (e) from 08-VERIFICATION.md (operator-action portion);
flags the Voice ORT chain gap as a P6 follow-up via runtime advisory.