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>
85 lines
2.8 KiB
Bash
Executable File
85 lines
2.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# copy-voice-models.sh — bundle ONNX model files into the .app at build time.
|
|
#
|
|
# Track B-1 (2026-05-03 voice audit fix). The voice subsystem expects
|
|
# models at `Contents/Resources/Models/{openWakeWord,silero}/*.onnx`
|
|
# inside the built .app bundle. This script copies them from the
|
|
# repo-root `Resources/models/{openwakeword,silero}/` canonical layout,
|
|
# applying the case fix (`openwakeword` → `openWakeWord`) along the way.
|
|
#
|
|
# Hard-fails on:
|
|
# - MANIFEST.json containing literal `PLACEHOLDER_…` sha256 (someone
|
|
# forgot to run `scripts/fetch-silero-models.sh` /
|
|
# `scripts/fetch-openwakeword-models.sh`).
|
|
# - any required model file missing on disk.
|
|
#
|
|
# Soft-degrades (warns, continues) on:
|
|
# - source models dir absent entirely (OK in CI / unit-test workflows
|
|
# that don't need voice).
|
|
#
|
|
# Environment: runs as a postBuildScript inside xcodebuild. SRCROOT,
|
|
# TARGET_BUILD_DIR, WRAPPER_NAME are provided by Xcode.
|
|
set -euo pipefail
|
|
|
|
REPO_ROOT="${SRCROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
|
SRC_BASE="${REPO_ROOT}/Resources/models"
|
|
DEST_BASE="${TARGET_BUILD_DIR:?}/${WRAPPER_NAME:?}/Contents/Resources/Models"
|
|
|
|
if [ ! -d "$SRC_BASE" ]; then
|
|
echo "warning: voice models source dir '$SRC_BASE' missing — skipping copy"
|
|
exit 0
|
|
fi
|
|
|
|
mkdir -p "$DEST_BASE"
|
|
|
|
# Mapping: canonical-on-disk → runtime-bundle-path. Add a row when a new
|
|
# subsystem ships ONNX models.
|
|
#
|
|
# Format: "src_subdir:dest_subdir:manifest_required"
|
|
declare -a SUBSYSTEMS=(
|
|
"openwakeword:openWakeWord:1"
|
|
"silero:silero:1"
|
|
)
|
|
|
|
assert_manifest_populated() {
|
|
local manifest="$1"
|
|
local subsystem="$2"
|
|
if [ ! -f "$manifest" ]; then
|
|
echo "error: $subsystem MANIFEST.json missing at $manifest" >&2
|
|
exit 1
|
|
fi
|
|
if grep -q "PLACEHOLDER_" "$manifest"; then
|
|
echo "error: $subsystem MANIFEST.json contains PLACEHOLDER hashes — run scripts/fetch-${subsystem}-models.sh first" >&2
|
|
echo " (placeholder lines:)" >&2
|
|
grep "PLACEHOLDER_" "$manifest" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
for ROW in "${SUBSYSTEMS[@]}"; do
|
|
IFS=':' read -r SRC_SUB DEST_SUB REQ <<< "$ROW"
|
|
SRC_DIR="${SRC_BASE}/${SRC_SUB}"
|
|
DEST_DIR="${DEST_BASE}/${DEST_SUB}"
|
|
|
|
if [ ! -d "$SRC_DIR" ]; then
|
|
if [ "$REQ" = "1" ]; then
|
|
echo "error: required voice model dir '$SRC_DIR' missing" >&2
|
|
exit 1
|
|
fi
|
|
continue
|
|
fi
|
|
|
|
assert_manifest_populated "${SRC_DIR}/MANIFEST.json" "$SRC_SUB"
|
|
|
|
mkdir -p "$DEST_DIR"
|
|
# rsync over cp -R: preserves timestamps so unchanged files don't
|
|
# invalidate the cache; --delete keeps the bundle clean of stale
|
|
# copies after a model removal.
|
|
rsync -a --delete \
|
|
--exclude '.gitkeep' \
|
|
--exclude '.gitignore' \
|
|
"${SRC_DIR}/" "${DEST_DIR}/"
|
|
done
|
|
|
|
echo "[copy-voice-models] OK — copied to ${DEST_BASE}"
|