commit 297fbaa19cb99e065375e19b7c7a08ec254281d1 Author: Shane Maynard Date: Sat Aug 29 17:15:27 2026 -0400 chore: scaffold Voice iOS app (Phase 0) XcodeGen project, SwiftUI iOS 17 target dev.shane.voice, SPM livekit-client-swift 2.16.0, mic usage + ATS LAN exception in Info.plist, placeholder conversation screen and Settings with server URL field. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..16a960e --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.DS_Store +DerivedData/ +xcuserdata/ +*.xcresult +build/ +.swiftpm/ diff --git a/.opencode/skills/xcodebuildmcp/SKILL.md b/.opencode/skills/xcodebuildmcp/SKILL.md new file mode 100644 index 0000000..5fc4740 --- /dev/null +++ b/.opencode/skills/xcodebuildmcp/SKILL.md @@ -0,0 +1,41 @@ +--- +name: xcodebuildmcp +description: Official skill for XcodeBuildMCP. Use when doing iOS/macOS/watchOS/tvOS/visionOS work (build, test, run, debug, log, UI automation). +--- + +# XcodeBuildMCP + +Use XcodeBuildMCP tools instead of raw `xcodebuild`, `xcrun`, or `simctl`. + +Capabilities: +- Session defaults: Configure project, scheme, simulator, and device defaults to avoid repetitive parameters +- Project discovery: Find Xcode projects/workspaces, list schemes, inspect build settings +- Simulator workflows: Build, run, test, install, and launch apps on iOS simulators; manage simulator state (boot, erase, location, appearance) +- Device workflows: Build, test, install, and launch apps on physical devices with code signing +- macOS workflows: Build, run, and test macOS applications +- Log capture: Stream and capture logs from simulators and devices +- LLDB debugging: Attach debugger, set breakpoints, inspect stack traces and variables, execute LLDB commands +- UI automation: Capture screenshots, inspect view hierarchy with coordinates, perform taps/swipes/gestures, type text, press hardware buttons +- SwiftPM: Build, run, test, and manage Swift Package Manager projects +- Project scaffolding: Generate new iOS/macOS project templates + +Only simulator workflow tools are enabled by default. If capabilities like device, macOS, debugging, or UI automation are not available, the user must configure XcodeBuildMCP to enable them. See https://xcodebuildmcp.com/docs/configuration for workflow configuration. + +## Step 1: Establish Session Context + +- Call `session_show_defaults` before the first build/run/test action in a session. +- Use `discover_projs` only when defaults show missing or incorrect project/workspace context. +- Do not run discovery speculatively or in parallel with `session_show_defaults`. +- For simulator run intent, prefer the combined build-and-run tool instead of separate build then run calls. +- Do not chain build-only then build-and-run unless the user explicitly requests both. + +## Step 2: Understand Workflow-Scoped Tool Availability + +- Not all tools are enabled by default; tool availability depends on enabled workflows. +- If a tool is expected but missing, check enabled workflows first. +- Update enabled workflows in `.xcodebuildmcp/config.yaml`, then ask user to reload/restart the session to surface refreshes. + +## Step 3: Report Context Clearly + +- Return the active defaults context used for execution (project/workspace, scheme, simulator/device). +- For failures, include the exact failing step and the next actionable command/tool call. diff --git a/.xcodebuildmcp/config.yaml b/.xcodebuildmcp/config.yaml new file mode 100644 index 0000000..95e36f9 --- /dev/null +++ b/.xcodebuildmcp/config.yaml @@ -0,0 +1,7 @@ +schemaVersion: 1 +sessionDefaults: + projectPath: /Users/shane/dev/voice-ios/Voice.xcodeproj + scheme: Voice_iOS + simulatorName: iPhone 17 Pro + simulatorId: AFDA137E-1657-428E-BF01-E5CD7989772C + simulatorPlatform: iOS Simulator diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1a9b266 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,3 @@ +# AGENTS.md + +- If using XcodeBuildMCP, use the installed XcodeBuildMCP skill before calling XcodeBuildMCP tools. diff --git a/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..dbc3f63 --- /dev/null +++ b/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal-platform", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Assets.xcassets/Contents.json b/Resources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Resources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Resources/Info.plist b/Resources/Info.plist new file mode 100644 index 0000000..f37f95b --- /dev/null +++ b/Resources/Info.plist @@ -0,0 +1,41 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Hope Voice + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSAppTransportSecurity + + NSExceptionDomains + + 192.168.86.2 + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + + + NSMicrophoneUsageDescription + Hope Voice uses the microphone for live voice conversation. + UILaunchScreen + + + diff --git a/Sources/App/SettingsStore.swift b/Sources/App/SettingsStore.swift new file mode 100644 index 0000000..7770868 --- /dev/null +++ b/Sources/App/SettingsStore.swift @@ -0,0 +1,41 @@ +import Foundation +import Combine + +final class SettingsStore: ObservableObject { + @Published var serverURL: String { + didSet { UserDefaults.standard.set(serverURL, forKey: Keys.serverURL) } + } + @Published var voiceID: String { + didSet { UserDefaults.standard.set(voiceID, forKey: Keys.voiceID) } + } + @Published var modelID: String? { + didSet { UserDefaults.standard.set(modelID, forKey: Keys.modelID) } + } + @Published var textMode: Bool { + didSet { UserDefaults.standard.set(textMode, forKey: Keys.textMode) } + } + @Published var volume: Double { + didSet { UserDefaults.standard.set(volume, forKey: Keys.volume) } + } + + private enum Keys { + static let serverURL = "serverURL" + static let voiceID = "voiceID" + static let modelID = "modelID" + static let textMode = "textMode" + static let volume = "volume" + } + + init() { + let d = UserDefaults.standard + serverURL = d.string(forKey: Keys.serverURL) ?? "https://192.168.86.2:9178" + voiceID = d.string(forKey: Keys.voiceID) ?? "" + modelID = d.string(forKey: Keys.modelID) + textMode = d.bool(forKey: Keys.textMode) + volume = d.object(forKey: Keys.volume) as? Double ?? 0.8 + } + + var trimmedServerURL: String { + serverURL.trimmingCharacters(in: .whitespacesAndNewlines) + } +} diff --git a/Sources/App/VoiceApp.swift b/Sources/App/VoiceApp.swift new file mode 100644 index 0000000..7705623 --- /dev/null +++ b/Sources/App/VoiceApp.swift @@ -0,0 +1,13 @@ +import SwiftUI + +@main +struct VoiceApp: App { + @StateObject private var settings = SettingsStore() + + var body: some Scene { + WindowGroup { + ConversationView(settings: settings) + .preferredColorScheme(.dark) + } + } +} diff --git a/Sources/Features/Conversation/ConversationView.swift b/Sources/Features/Conversation/ConversationView.swift new file mode 100644 index 0000000..c36cdbc --- /dev/null +++ b/Sources/Features/Conversation/ConversationView.swift @@ -0,0 +1,36 @@ +import SwiftUI + +struct ConversationView: View { + @ObservedObject var settings: SettingsStore + + var body: some View { + NavigationStack { + VStack(spacing: 24) { + Spacer() + Circle() + .fill(.blue.gradient) + .frame(width: 96, height: 96) + Text("Hope Voice") + .font(.title.bold()) + Text(settings.trimmedServerURL) + .font(.footnote) + .foregroundStyle(.secondary) + Spacer() + } + .navigationTitle("Voice") + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + NavigationLink { + SettingsView(settings: settings) + } label: { + Image(systemName: "gearshape") + } + } + } + } + } +} + +#Preview { + ConversationView(settings: SettingsStore()) +} diff --git a/Sources/Features/Settings/SettingsView.swift b/Sources/Features/Settings/SettingsView.swift new file mode 100644 index 0000000..1e0de5f --- /dev/null +++ b/Sources/Features/Settings/SettingsView.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct SettingsView: View { + @ObservedObject var settings: SettingsStore + @State private var urlText: String = "" + + var body: some View { + Form { + Section("Server") { + TextField("https://192.168.86.2:9178", text: $urlText) + .keyboardType(.URL) + .autocorrectionDisabled() + .onAppear { urlText = settings.serverURL } + .onChange(of: urlText) { _, newValue in + settings.serverURL = newValue + } + } + Section("Interaction") { + Toggle("Text mode", isOn: $settings.textMode) + } + } + .navigationTitle("Settings") + } +} + +#Preview { + SettingsView(settings: SettingsStore()) +} diff --git a/UPDATE_PATH.md b/UPDATE_PATH.md new file mode 100644 index 0000000..9a56a2b --- /dev/null +++ b/UPDATE_PATH.md @@ -0,0 +1,344 @@ +# 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: + +1. **Token endpoint** — `POST {base}/token` (nginx → token_server.py :8091). + - Request body is ignored server-side; response: + `{ "token": "", "room": "voice-shane-", "user": { "id", "display_name" } }` + - The JWT carries a `roomConfig.agents` claim, 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 `room` in every `/media/api/` call. + +2. **LiveKit signaling** — `{base}/livekit/` (wss), proxied to :7880. + Web UI connects to the same origin: `wss://192.168.86.2:9178/livekit`. + +3. **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":}` | live TTS voice change | +| app → agent | `voice-control` | `{"type":"set_model","model":}` | 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 + +1. **Primary:** LiveKit built-in transcription stream on track source + `.transcription` (`lk.transcription`). In livekit-client-swift this arrives + as `AudioStreamTrack` with source `transcription`; register a text-stream + handler / read the track's `TextStream`. Word-by-word partials update the + same row in place until final. Role is derived from which participant owns + the transcribed track (attribute `lk.transcribed_track_id`). +2. **Secondary:** `transcript` data 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=` | 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/` and `{base}/media/thumbs/` (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: true` and remember `wasPlayingBeforeTalk`. +- Poll `/state`; when `track.id` changes → load new AVPlayer item; when a + previously-null `audio_url` appears (download finished growing) → wire src. +- `desired == "playing"` + paused → play; otherwise pause. +- Apply `seek_hint` once per `ts`. +- On play/pause/ended/timeupdate(≥5 s): `/report`; publish `media_state` on + `media-control` so 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 `models` message, persisted; ack via `model_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 via + `NSPinnedDomainKeys` (bundle the `.cer` from `https://192.168.86.2:9178`, + extract with `openssl s_client`). If LiveKit's WebRTC sockets bypass the + pinned URLSession, fall back to a custom `URLSessionDelegate` in + `TLSPinning.swift` that 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.** `AVAudioSession` category `.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 secondary `transcript` topic 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 id `dev.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, derive `wss://host/livekit`, validate https). +- `TokenClient`: POST /token with URLSession using the pinned-cert delegate; + decode `TokenResponse`; 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`: create `Room`, connect with token, observe events. +- Agent discovery: scan + watch for kind `.agent` / identity + "voice-assistant"; wait for `agent_ready` data 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 + `transcript` topic; + 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) +- `ConversationViewModel` state 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 via `sendText(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; + send `set_voice` on change and on agent join (sync-on-join like the web UI). +- Model picker: listen for `models` message, render list, persist preference, + send `set_model`, apply ack via `model_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 +- `MediaAPI` client + models for all endpoints above. +- `MediaPlayerController`: AVPlayer item from `audio_url`; poll `/state` + every 1.5 s; reconciliation rules ported verbatim (track swap, late + audio_url wiring, desired-state apply, seek_hint once-per-ts); `/report` + on play/pause/ended + ≥5 s while playing; publish `media_state` on + `media-control`; enter-media-scope carry-over with `start_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_library` tag; 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`: fold `task_event` messages 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 `=== room` checks). +- 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_USER` until 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. diff --git a/Voice.xcodeproj/project.pbxproj b/Voice.xcodeproj/project.pbxproj new file mode 100644 index 0000000..0713e6e --- /dev/null +++ b/Voice.xcodeproj/project.pbxproj @@ -0,0 +1,375 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXBuildFile section */ + 1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FED8976B60FBC13BC980FCB /* VoiceApp.swift */; }; + 36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */; }; + 98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48F76881B4AE51DEE74511CC /* SettingsView.swift */; }; + A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */; }; + D9A0059E342955A06A021D6C /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = A6BAD55715F383C0DB5F306F /* LiveKit */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 2A4C3CE2CA6FF828452803D1 /* Voice.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Voice.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 2FED8976B60FBC13BC980FCB /* VoiceApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceApp.swift; sourceTree = ""; }; + 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationView.swift; sourceTree = ""; }; + 48F76881B4AE51DEE74511CC /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsStore.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + F79CBCE3DCE9D63A26B167D9 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D9A0059E342955A06A021D6C /* LiveKit in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 0DF0591DF1D767F89099F62C /* Settings */ = { + isa = PBXGroup; + children = ( + 48F76881B4AE51DEE74511CC /* SettingsView.swift */, + ); + path = Settings; + sourceTree = ""; + }; + 6F99423E59017FED59B4D71D /* Products */ = { + isa = PBXGroup; + children = ( + 2A4C3CE2CA6FF828452803D1 /* Voice.app */, + ); + name = Products; + sourceTree = ""; + }; + 84ADAF12A43A57888CA178A6 /* Conversation */ = { + isa = PBXGroup; + children = ( + 3F3FA9F420065D2127B0AEC0 /* ConversationView.swift */, + ); + path = Conversation; + sourceTree = ""; + }; + 8DA8BEF2EE3E0F4181A94EB9 /* Features */ = { + isa = PBXGroup; + children = ( + 84ADAF12A43A57888CA178A6 /* Conversation */, + 0DF0591DF1D767F89099F62C /* Settings */, + ); + path = Features; + sourceTree = ""; + }; + B3D0379BEBE1E5F4AA58DA2D = { + isa = PBXGroup; + children = ( + FCC61E9990B71498171F9D34 /* Sources */, + 6F99423E59017FED59B4D71D /* Products */, + ); + sourceTree = ""; + }; + CE49A38E40AAF659A9281B85 /* App */ = { + isa = PBXGroup; + children = ( + 6D0D153557BB1C756ED04C61 /* SettingsStore.swift */, + 2FED8976B60FBC13BC980FCB /* VoiceApp.swift */, + ); + path = App; + sourceTree = ""; + }; + FCC61E9990B71498171F9D34 /* Sources */ = { + isa = PBXGroup; + children = ( + CE49A38E40AAF659A9281B85 /* App */, + 8DA8BEF2EE3E0F4181A94EB9 /* Features */, + ); + path = Sources; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 57E3A292BDE525A22EB7ADE4 /* Voice_iOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = 67CE95EFE28F976D1CF1A59A /* Build configuration list for PBXNativeTarget "Voice_iOS" */; + buildPhases = ( + 548DD373FE3F2A49C30AA9CB /* Sources */, + F79CBCE3DCE9D63A26B167D9 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Voice_iOS; + packageProductDependencies = ( + A6BAD55715F383C0DB5F306F /* LiveKit */, + ); + productName = Voice_iOS; + productReference = 2A4C3CE2CA6FF828452803D1 /* Voice.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 201BFA602F0A78910611E5F5 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1430; + TargetAttributes = { + 57E3A292BDE525A22EB7ADE4 = { + ProvisioningStyle = Automatic; + }; + }; + }; + buildConfigurationList = 9F111BF768E1A4FB89724F08 /* Build configuration list for PBXProject "Voice" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + Base, + en, + ); + mainGroup = B3D0379BEBE1E5F4AA58DA2D; + minimizedProjectReferenceProxies = 1; + packageReferences = ( + 8C4532D91555015807E47884 /* XCRemoteSwiftPackageReference "client-sdk-swift" */, + ); + preferredProjectObjectVersion = 77; + productRefGroup = 6F99423E59017FED59B4D71D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 57E3A292BDE525A22EB7ADE4 /* Voice_iOS */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXSourcesBuildPhase section */ + 548DD373FE3F2A49C30AA9CB /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 36DF44D9A7896F18A064847D /* ConversationView.swift in Sources */, + A94F6C509E3FE215115B2C58 /* SettingsStore.swift in Sources */, + 98BA1A0F92DB7B98A8BA7814 /* SettingsView.swift in Sources */, + 1ECD63F10A8EAF33FD2A5B62 /* VoiceApp.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 25699B8C57BC38421732D5E6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + "DEBUG=1", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 8DB4436439AC31CB0409E1A4 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Resources/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.shane.voice; + PRODUCT_NAME = Voice; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.9; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Release; + }; + 97702898F64A55FAD1D4E4D9 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_IDENTITY = "iPhone Developer"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = NO; + INFOPLIST_FILE = Resources/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 0.1.0; + PRODUCT_BUNDLE_IDENTIFIER = dev.shane.voice; + PRODUCT_NAME = Voice; + SDKROOT = iphoneos; + SWIFT_VERSION = 5.9; + TARGETED_DEVICE_FAMILY = 1; + }; + name = Debug; + }; + BC9661613EA9B797B71B7F82 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; + CLANG_CXX_LIBRARY = "libc++"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 67CE95EFE28F976D1CF1A59A /* Build configuration list for PBXNativeTarget "Voice_iOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97702898F64A55FAD1D4E4D9 /* Debug */, + 8DB4436439AC31CB0409E1A4 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; + 9F111BF768E1A4FB89724F08 /* Build configuration list for PBXProject "Voice" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 25699B8C57BC38421732D5E6 /* Debug */, + BC9661613EA9B797B71B7F82 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 8C4532D91555015807E47884 /* XCRemoteSwiftPackageReference "client-sdk-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/livekit/client-sdk-swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + A6BAD55715F383C0DB5F306F /* LiveKit */ = { + isa = XCSwiftPackageProductDependency; + package = 8C4532D91555015807E47884 /* XCRemoteSwiftPackageReference "client-sdk-swift" */; + productName = LiveKit; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 201BFA602F0A78910611E5F5 /* Project object */; +} diff --git a/Voice.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Voice.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/Voice.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Voice.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Voice.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..61366bf --- /dev/null +++ b/Voice.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "727398c0b292ba4052728cc638944a00ac7067321e283dedd2630f3e865fa408", + "pins" : [ + { + "identity" : "client-sdk-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/livekit/client-sdk-swift", + "state" : { + "revision" : "79fb2beee98e45556bffebefa50b5d05c3382af1", + "version" : "2.16.0" + } + }, + { + "identity" : "livekit-uniffi-xcframework", + "kind" : "remoteSourceControl", + "location" : "https://github.com/livekit/livekit-uniffi-xcframework.git", + "state" : { + "revision" : "7c161254ce7cd55debc48023f69a917076b12a26", + "version" : "0.0.6" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "55d7a1cc5666b85c13464aea1c4b4a90feccb4c8", + "version" : "1.38.1" + } + }, + { + "identity" : "webrtc-xcframework", + "kind" : "remoteSourceControl", + "location" : "https://github.com/livekit/webrtc-xcframework.git", + "state" : { + "revision" : "46f2af86f06b9a8a9158d37cadda5cb5a214e4c4", + "version" : "144.7559.11" + } + } + ], + "version" : 3 +} diff --git a/opencode.json b/opencode.json new file mode 100644 index 0000000..95b0e11 --- /dev/null +++ b/opencode.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "xcodebuildmcp": { + "type": "local", + "command": ["/Users/shane/.local/bin/xcodebuildmcp", "mcp"], + "enabled": true, + "environment": {} + } + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..244f78e --- /dev/null +++ b/project.yml @@ -0,0 +1,42 @@ +name: Voice +options: + bundleIdPrefix: dev.shane + deploymentTarget: + iOS: "17.0" + createIntermediateGroups: true +packages: + LiveKit: + url: https://github.com/livekit/client-sdk-swift + from: "2.0.0" +targets: + Voice: + type: application + platform: [iOS] + sources: + - Sources + dependencies: + - package: LiveKit + product: LiveKit + resources: + - path: Resources/Assets.xcassets + info: + path: Resources/Info.plist + properties: + CFBundleDisplayName: Hope Voice + UILaunchScreen: {} + NSMicrophoneUsageDescription: "Hope Voice uses the microphone for live voice conversation." + NSAppTransportSecurity: + NSExceptionDomains: + 192.168.86.2: + NSIncludesSubdomains: true + NSExceptionAllowsInsecureHTTPLoads: false + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.shane.voice + GENERATE_INFOPLIST_FILE: NO + INFOPLIST_FILE: Resources/Info.plist + SWIFT_VERSION: "5.9" + TARGETED_DEVICE_FAMILY: "1" + CURRENT_PROJECT_VERSION: 1 + MARKETING_VERSION: "0.1.0" + CODE_SIGN_STYLE: Automatic