20 KiB
UPDATE_PATH — iOS App for Hope Voice
Branch: feat/ios-app (created from empty repo state)
Goal: a native iOS app that is full parity with the web UI of
/Users/shane/dev/hope-voice-api, connecting to the Unraid deployment at
https://192.168.86.2:9178. The server side is NOT modified in this project —
the app reuses the existing token_server.py endpoint and media API exactly as
the web UI does.
Decisions (confirmed)
| Decision | Choice |
|---|---|
| Scope | Full parity with web UI: voice chat, transcripts, text mode, voice picker, model picker, background tasks panel, media player + library |
| LiveKit client | livekit-client-swift (official Apple SDK via SPM) |
| Auth | Reuse POST /token on the existing token server (no new endpoint, no secrets in the app) |
| Server URL | Configurable in Settings, default https://192.168.86.2:9178 |
| TLS | ATS exception for the LAN host + pinned self-signed cert (NSPinnedDomainKeys) |
What we are replacing / building against
The "voice api" being replaced is whatever a stock LiveKit demo would use (devkey/devsecret minted on-device, hardcoded URLs). It is replaced by:
-
Token endpoint —
POST {base}/token(nginx → token_server.py :8091).- Request body is ignored server-side; response:
{ "token": "<JWT>", "room": "voice-shane-<hex8>", "user": { "id", "display_name" } } - The JWT carries a
roomConfig.agentsclaim, so the agent is dispatched when we join. Joining with a self-minted token would get no agent — this endpoint is mandatory, not optional. - Room name is unique per call (uuid suffix). It scopes media-server player
state; send it as
roomin every/media/api/call.
- Request body is ignored server-side; response:
-
LiveKit signaling —
{base}/livekit/(wss), proxied to :7880. Web UI connects to the same origin:wss://192.168.86.2:9178/livekit. -
Media API —
{base}/media/api/*(FastAPI, :8093) + static/media/files/,/media/thumbs/.
Protocol reference (from web/app.js + agent/agent.py)
Data channel messages (LiveKit publishData, all reliable)
| Direction | Topic | Payload | Meaning |
|---|---|---|---|
| app → agent | voice-control |
{"type":"set_voice","voice":<azure voice id>} |
live TTS voice change |
| app → agent | voice-control |
{"type":"set_model","model":<id>} |
live LLM model change; agent acks with model_changed |
| app → agent | voice-control |
{"type":"get_models"} |
request model list (agent replies models) |
| app → agent | conversation-control |
{"type":"set_mode","mode":"voice"|"text"} |
interaction mode; text mode mutes mic server-side |
| app → agent | media-control |
{"type":"media_state","state":"playing"|"paused"|"stopped","track_id":...} |
tells agent to disable/enable its mic (music ↔ conversation exclusivity) |
| agent → app | voice-control |
{"type":"models","models":[...],"selected":...} / {"type":"model_changed","model":...} |
model list + applied-model ack |
| agent → app | transcript |
{"type":"transcript","role":"user"|"agent","text":...} |
final transcripts (secondary source) |
| agent → app | any | {"type":"task_event", ...} |
background task lifecycle: created/step/completed/failed with task_id, description, role, content, result, error |
| agent → app | — | {"type":"agent_ready"} |
agent pipeline ready (wait for this before enabling mic) |
Agent identity: participant kind .agent or identity "voice-assistant".
Scan remoteParticipants after connect AND watch participantConnected
(agent may pre-exist in the room).
Text chat input: localParticipant.sendText(text, topic: "lk.chat") (the web
UI's text-mode path; the agent consumes it as a user turn).
Transcripts — two sources
- Primary: LiveKit built-in transcription stream on track source
.transcription(lk.transcription). In livekit-client-swift this arrives asAudioStreamTrackwith sourcetranscription; register a text-stream handler / read the track'sTextStream. Word-by-word partials update the same row in place until final. Role is derived from which participant owns the transcribed track (attributelk.transcribed_track_id). - Secondary:
transcriptdata messages above.
UI must dedupe: when a final transcript arrives, replace any partial row for the same role.
Media API (all under {base}/media/api/, JSON)
| Endpoint | Method | Body / Query | Notes |
|---|---|---|---|
/state |
GET | ?room=<livekit room> |
Returns {track, desired: "playing"|"paused"|"stopped", pending: [jobs], choices: [], seek_hint: {ts, position}}. Poll every 1.5 s. |
/report |
POST | {room, state, track_id, position, duration?} |
Browser/app is the player; report real playback back. |
/play |
POST | {room, video_id?, title?, channel?, track_id?, start_paused?} |
Start a track (search result or library id). |
/control |
POST | {room, action: "pause"|"resume"|"stop"} |
Desired-state control. |
/search |
POST | {room, query, limit} |
→ {results: [{video_id, title, channel, duration, in_library}]}; library matches first. |
/library |
GET | — | → {tracks: [{id, title, channel, duration, status, kind, art_url, audio_url}]} |
/tracks/{id} |
DELETE | — | Remove from library. |
/streams |
POST | {room, name, url} |
Add live stream. |
/stream/{track_id} |
GET | — | Progressive/Range stream URL (audio element source). |
/healthz |
GET | — | Liveness. |
Static: GET {base}/media/files/<file> and {base}/media/thumbs/<img> (Range
supported; use as direct AVPlayer URLs).
Media reconciliation rules (port verbatim from app.js)
- App is the player (AVPlayer). Media server holds per-room desired state.
- On join: if a track was loaded before, carry it into the new room with
start_paused: trueand rememberwasPlayingBeforeTalk. - Poll
/state; whentrack.idchanges → load new AVPlayer item; when a previously-nullaudio_urlappears (download finished growing) → wire src. desired == "playing"+ paused → play; otherwise pause.- Apply
seek_hintonce perts. - On play/pause/ended/timeupdate(≥5 s):
/report; publishmedia_stateonmedia-controlso the agent mics off/on. - Ending conversation: if
wasPlayingBeforeTalk→/control {action:"resume"}.
Web UI feature inventory (parity checklist)
- Connect flow: token → join → wait for agent (10 s, retry once with fresh
token after 15 s) → wait
agent_ready(10 s) → enable mic / text mode - State machine: idle / connecting / listening / thinking / speaking, inferred from local + remote audio levels; orb pulse animation
- Mic meter + agent audio visualizer bars
- Transcript list with partials, delivery-marker stripping
(
[appreciative]etc.), history persistence (last 50), clear button - Voice / text mode toggle (persisted); text composer sends
lk.chat - Mute toggle
- Settings sheet: voice picker (13 Azure voices, persisted) + model picker
(from
modelsmessage, persisted; ack viamodel_changed) - Background tasks panel: badge count, cards with steps/result/error
- Media panel: now-playing card (art, title, channel, status), play/pause, ±10 s, stop, seek bar, volume (persisted), search results, agent-offered choices, library list with delete, add-stream form, download progress
- Screen wake lock while connected (
NSProcessInfo.beginActivity) - Reconnect-on-disconnect handling; stale-room guards
App architecture
voice-ios/
├── UPDATE_PATH.md ← this file
├── AGENTS.md
├── opencode.json (existing)
├── VoiceApp.xcodeproj (generated via XcodeGen or SPM-based project)
├── Package.swift (SPM: livekit-client-swift)
└── Sources/
├── App/
│ ├── VoiceApp.swift @main, scene setup
│ ├── AppRouter.swift tab/root navigation
│ └── SettingsStore.swift UserDefaults-backed: serverURL, voice, model, mode, volume
├── Networking/
│ ├── ServerConfig.swift base URL parsing (https only), LiveKit wss derivation
│ ├── TokenClient.swift POST /token → TokenResponse(token, room, user)
│ ├── MediaAPI.swift typed client for all /media/api endpoints
│ └── TLSPinning.swift URLSessionDelegate pinning the self-signed cert (fallback when NSPinnedDomainKeys is insufficient for WebRTC)
├── LiveKit/
│ ├── VoiceSession.swift Room lifecycle: connect, agent discovery, agent_ready wait, retry-once reconnect, disconnect teardown
│ ├── AgentMonitor.swift agent participant detection (kind .agent / identity), audio level observation
│ ├── TranscriptHandler.swift transcription stream + transcript data messages → TranscriptEvent stream (partial/final, role)
│ └── ControlChannel.swift publishData helpers: set_voice, set_model, get_models, set_mode, media_state; inbound models/model_changed/task_event/agent_ready parsing
├── Features/
│ ├── Conversation/
│ │ ├── ConversationViewModel.swift state machine (idle/connecting/listening/thinking/speaking), audio-level inference, mic toggle, mode switch
│ │ ├── ConversationView.swift orb + status + transcript list + composer
│ │ ├── OrbView.swift pulse animation driven by levels
│ │ └── TranscriptListView.swift partials, delivery-marker stripping, history
│ ├── Settings/
│ │ ├── SettingsView.swift server URL, voice picker, model picker, clear history
│ │ └── ModelPickerView.swift from agent-published list
│ ├── Tasks/
│ │ ├── TaskStore.swift task_event → [TaskItem]
│ │ └── TasksPanelView.swift badge + cards
│ └── Media/
│ ├── MediaPlayerController.swift AVPlayer + /state polling (1.5 s) + reconciliation + reporting
│ ├── MediaAPIModels.swift Track, Job, StateResponse, SearchResult…
│ └── MediaPanelView.swift now-playing card, transport, seek, search, library, add-stream
└── Support/
├── DeliveryMarkers.swift regex strip of [style] markers
├── AudioLevels.swift smoothing (0.6/0.4 EMA), thresholds (user 0.035, agent 0.02)
└── WakeLock.swift beginActivity while session active
└── Resources/
├── Info.plist / entitlements NSMicrophoneUsageDescription, ATS exceptions
└── Assets.xcassets app icon (port favicon.svg)
UI framework: SwiftUI (single-window conversation UI + sheets for settings,
tasks, media — mirrors the web's panel model). Audio rendering of remote TTS is
handled by livekit-client-swift's Room audio engine; local mic via its
capture. No manual WebRTC.
Key technical notes / risks
- ATS + self-signed cert.
NSAppTransportSecurity:NSExceptionDomains["192.168.86.2"] = { NSExceptionAllowsInsecureHTTPLoads: false, NSIncludesSubdomains: true }plus pinning the cert viaNSPinnedDomainKeys(bundle the.cerfromhttps://192.168.86.2:9178, extract withopenssl s_client). If LiveKit's WebRTC sockets bypass the pinned URLSession, fall back to a customURLSessionDelegateinTLSPinning.swiftthat validates the peer cert against the bundled one. Cert is regenerated at container start (entrypoint.sh) — document re-export steps; pinning will break on cert rotation until the app ships an update or we move to a real cert. Mitigation option: keep a "trust this server" first-run flow that caches the SPKI hash in Keychain instead of hard-pinning. - Audio session.
AVAudioSessioncategory.playAndRecord, mode.spokenAudio, options[.defaultToSpeaker, .allowBluetoothA2DP]; activate on connect, deactivate on disconnect. livekit-client-swift manages the engine but we own the session config. - Agent dispatch timing. If the agent worker isn't up (~15 s after server start), join gets no agent and there is no retry — hence the web UI's reconnect-once flow; port it exactly (fresh token, fresh room name).
- Text stream in livekit-client-swift. The Swift SDK exposes
AudioStreamTrack.textStream/ data messages; verify partial-word behavior during Phase 2 spike. If transcription track source isn't delivered on this LiveKit version, the secondarytranscripttopic still gives final text — degrade gracefully (no word-by-word). - AVPlayer vs progressive webm/opus. iOS plays m4a/AAC and mp3 natively; webm/opus may not play in AVPlayer. Downloaded tracks are raw bestaudio (often webm). Spike in Phase 5: if AVPlayer rejects webm, options are (a) accept the limitation + surface "not playable on iOS" for those tracks, (b) add an optional server-side transcode later (out of scope here), or (c) use a lightweight opus decoder. Decide at spike; default to (a).
- Background. Out of scope: no background audio continuation beyond the standard AVPlayer behavior while foregrounded; wake lock keeps screen on during conversation only.
Phases
Phase 0 — Project scaffold (0.5 day)
- XcodeGen spec (
project.yml) or hand-rolled.xcodeproj; iOS 17+ target, SwiftUI lifecycle, single app "Voice" (bundle iddev.shane.voice). - SPM dependency:
livekit-client-swift(pin latest stable; verify the transcription-track API surface against the pinned version). - Info.plist:
NSMicrophoneUsageDescription, ATS exceptions + pinned cert resource, URL types none. - Empty app boots to a placeholder screen; builds & runs in simulator and on device (mic needs real device for full testing — simulator mic works for capture tests).
- Exit: app installs on an iPhone on the LAN; Settings screen shows
server URL field defaulting to
https://192.168.86.2:9178.
Phase 1 — Connectivity & token (0.5 day)
ServerConfig(parse base URL, derivewss://host/livekit, validate https).TokenClient: POST /token with URLSession using the pinned-cert delegate; decodeTokenResponse; surface 503 user-file errors clearly.- Connection test screen: "Get token" button → shows room name + user display name (proves TLS pinning + endpoint work end-to-end).
- Exit: on device, token fetch succeeds against the Unraid deployment; wrong URL / offline produce clean error states.
Phase 2 — LiveKit session spike (1 day) ← de-risk first
VoiceSession: createRoom, connect with token, observe events.- Agent discovery: scan + watch for kind
.agent/ identity "voice-assistant"; wait foragent_readydata message; implement reconnect-once flow (fresh token on agent timeout). - Mic: enable local mic after
agent_ready; mute toggle. - Remote audio: confirm TTS plays through the SDK's audio engine on device.
- Transcript spike: wire transcription track source +
transcripttopic; log partials vs finals. Decide degradation path if word-by-word unavailable. - Exit: on a real iPhone, start → agent joins → speak → hear TTS back → see at least final transcripts; disconnect cleans up audio session.
Phase 3 — Conversation UI (1–2 days)
ConversationViewModelstate machine ported from app.js (inferState, EMA smoothing, thresholds), driven by local/remote audio levels from the SDK.- Orb + status line + mic meter; thinking chip (>2 s); barge-in hint while speaking.
- Transcript list: partials in place, final replace, delivery-marker strip, history (UserDefaults JSON, last 50), clear button, timestamps.
- Voice/text mode toggle (persisted): text mode disables mic + sends
set_mode; composer sends viasendText(topic:"lk.chat"). - Screen wake lock while connected; disconnect teardown path.
- Exit: full voice conversation on device with live UI states matching the web UI's behavior; text mode round-trips.
Phase 4 — Settings: voices + models (0.5 day)
- Voice picker sheet: 13 Azure voices from app.js
VOICES; persist selection; sendset_voiceon change and on agent join (sync-on-join like the web UI). - Model picker: listen for
modelsmessage, render list, persist preference, sendset_model, apply ack viamodel_changed; note state when not connected ("Start a conversation to load the model list"). - Exit: changing voice/model in the sheet takes effect live on the agent; choices survive relaunch.
Phase 5 — Media player (1–2 days) ← second de-risk spike inside
MediaAPIclient + models for all endpoints above.MediaPlayerController: AVPlayer item fromaudio_url; poll/stateevery 1.5 s; reconciliation rules ported verbatim (track swap, late audio_url wiring, desired-state apply, seek_hint once-per-ts);/reporton play/pause/ended + ≥5 s while playing; publishmedia_stateonmedia-control; enter-media-scope carry-over withstart_paused: true; resume-after-talk.- webm spike: load a downloaded webm track in AVPlayer on device; record result and apply decision (a/b/c from risks).
- Media panel UI: now-playing card (AsyncImage art, title, channel, status), transport (play/pause, ±10 s, stop), seek bar, volume slider (persisted), download progress row.
- Exit: play a library track; pause/skip/seek work and are visible to the agent (mic disables while playing); ending a conversation resumes playback.
Phase 6 — Media search, choices, library (1 day)
- Search form → results list with
in_librarytag; Play buttons →/play. - Agent-offered choices rendered from
state.choices. - Library list: refresh on open, play (ready only), delete with confirm.
- Add-stream form (name + url) →
/streams. - Exit: full media panel parity with the web UI, including library management and stream addition.
Phase 7 — Background tasks panel (0.5 day)
TaskStore: foldtask_eventmessages into task items (created/step/ completed/failed), badge = active count.- Panel: cards sorted by created_at desc, steps list with role styling, result/error blocks; open from toolbar like the web UI.
- Exit: ask Hope to research something; panel shows live steps and final result identical to the web UI.
Phase 8 — Polish, hardening, parity pass (1 day)
- Error states: token 503, agent never joins, media server down, network loss mid-call (reconnect banner), mic permission denied fallback.
- Stale-room guards on every async callback (port the
=== roomchecks). - Audio session interruption handling (phone call, Siri) — resume or pause conversation cleanly.
- Accessibility: labels/traits matching the web UI's ARIA roles; Dynamic Type.
- Side-by-side parity test against the web UI on the same deployment for every checklist item above.
- Exit: parity checklist fully ticked; no console-level crash paths in the connect/disconnect/reconnect matrix.
Phase 9 — Docs & handoff (0.5 day)
- README.md: build, run, device requirements, cert re-pinning steps after server restart/rotation, how to point at another deployment.
- AGENTS.md updates (build/test commands, conventions).
- Commit history cleaned up per conventional commits.
Total estimate: ~7–8 focused days. Phases 2 and 5 contain the two real technical risks (transcription API surface; webm playback) and are sequenced early for that reason.
Verification strategy
- Every phase ends with a manual pass on a real iPhone on the LAN against
https://192.168.86.2:9178(mic + WebRTC don't work in simulator). - XcodeBuildMCP for build/run/screenshot loops where useful; device testing via Xcode directly.
- No server-side changes are made to hope-voice-api; if a gap is found there (e.g., token endpoint needs an iOS-friendly response), it is documented as a follow-up ticket, not silently patched in this repo.
Out of scope (explicit)
- Login/user selection (server pins
VOICE_DEFAULT_USERuntil the planned login flow; app just displays the returned user). - Background audio / lock-screen controls beyond AVPlayer defaults.
- Server-side changes (transcode, new endpoints, real TLS cert).
- iPad/macCatalyst/Apple Watch targets.