fix(ios): stabilize voice wake + bridge UI
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# ClawdisNode (iOS)
|
# ClawdisKit (iOS)
|
||||||
|
|
||||||
Internal-only SwiftUI app scaffold.
|
Internal-only SwiftUI app scaffold.
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import Network
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class BridgeDiscoveryModel: ObservableObject {
|
final class BridgeDiscoveryModel: ObservableObject {
|
||||||
struct DiscoveredBridge: Identifiable, Equatable {
|
struct DiscoveredBridge: Identifiable, Equatable {
|
||||||
var id: String { self.debugID }
|
var id: String { self.stableID }
|
||||||
var name: String
|
var name: String
|
||||||
var endpoint: NWEndpoint
|
var endpoint: NWEndpoint
|
||||||
|
var stableID: String
|
||||||
var debugID: String
|
var debugID: String
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +55,8 @@ final class BridgeDiscoveryModel: ObservableObject {
|
|||||||
return DiscoveredBridge(
|
return DiscoveredBridge(
|
||||||
name: decodedName,
|
name: decodedName,
|
||||||
endpoint: result.endpoint,
|
endpoint: result.endpoint,
|
||||||
debugID: Self.prettyEndpointDebugID(result.endpoint))
|
stableID: BridgeEndpointID.stableID(result.endpoint),
|
||||||
|
debugID: BridgeEndpointID.prettyDescription(result.endpoint))
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -73,8 +75,4 @@ final class BridgeDiscoveryModel: ObservableObject {
|
|||||||
self.bridges = []
|
self.bridges = []
|
||||||
self.statusText = "Stopped"
|
self.statusText = "Stopped"
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func prettyEndpointDebugID(_ endpoint: NWEndpoint) -> String {
|
|
||||||
BonjourEscapes.decode(String(describing: endpoint))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
19
apps/ios/Sources/Bridge/BridgeEndpointID.swift
Normal file
19
apps/ios/Sources/Bridge/BridgeEndpointID.swift
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import ClawdisKit
|
||||||
|
import Foundation
|
||||||
|
import Network
|
||||||
|
|
||||||
|
enum BridgeEndpointID {
|
||||||
|
static func stableID(_ endpoint: NWEndpoint) -> String {
|
||||||
|
switch endpoint {
|
||||||
|
case let .service(name, type, domain, _):
|
||||||
|
// Keep this stable across encode/decode differences; use raw service tuple.
|
||||||
|
"\(type)|\(domain)|\(name)"
|
||||||
|
default:
|
||||||
|
String(describing: endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func prettyDescription(_ endpoint: NWEndpoint) -> String {
|
||||||
|
BonjourEscapes.decode(String(describing: endpoint))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Clawdis Node</string>
|
<string>ClawdisKit</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -25,11 +25,11 @@
|
|||||||
<string>_clawdis-bridge._tcp</string>
|
<string>_clawdis-bridge._tcp</string>
|
||||||
</array>
|
</array>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>Clawdis Node discovers and connects to your Clawdis bridge on the local network.</string>
|
<string>ClawdisKit discovers and connects to your Clawdis bridge on the local network.</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
<string>Clawdis Node needs microphone access for voice wake.</string>
|
<string>ClawdisKit needs microphone access for voice wake.</string>
|
||||||
<key>NSSpeechRecognitionUsageDescription</key>
|
<key>NSSpeechRecognitionUsageDescription</key>
|
||||||
<string>Clawdis Node uses on-device speech recognition for voice wake.</string>
|
<string>ClawdisKit uses on-device speech recognition for voice wake.</string>
|
||||||
<key>UIApplicationSceneManifest</key>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ final class NodeAppModel: ObservableObject {
|
|||||||
@Published var bridgeStatusText: String = "Not connected"
|
@Published var bridgeStatusText: String = "Not connected"
|
||||||
@Published var bridgeServerName: String?
|
@Published var bridgeServerName: String?
|
||||||
@Published var bridgeRemoteAddress: String?
|
@Published var bridgeRemoteAddress: String?
|
||||||
@Published var connectedBridgeDebugID: String?
|
@Published var connectedBridgeID: String?
|
||||||
|
|
||||||
private let bridge = BridgeSession()
|
private let bridge = BridgeSession()
|
||||||
private var bridgeTask: Task<Void, Never>?
|
private var bridgeTask: Task<Void, Never>?
|
||||||
@@ -58,7 +58,7 @@ final class NodeAppModel: ObservableObject {
|
|||||||
self.bridgeStatusText = "Connecting…"
|
self.bridgeStatusText = "Connecting…"
|
||||||
self.bridgeServerName = nil
|
self.bridgeServerName = nil
|
||||||
self.bridgeRemoteAddress = nil
|
self.bridgeRemoteAddress = nil
|
||||||
self.connectedBridgeDebugID = BonjourEscapes.decode(String(describing: endpoint))
|
self.connectedBridgeID = BridgeEndpointID.stableID(endpoint)
|
||||||
|
|
||||||
self.bridgeTask = Task {
|
self.bridgeTask = Task {
|
||||||
do {
|
do {
|
||||||
@@ -96,14 +96,14 @@ final class NodeAppModel: ObservableObject {
|
|||||||
self.bridgeStatusText = "Disconnected"
|
self.bridgeStatusText = "Disconnected"
|
||||||
self.bridgeServerName = nil
|
self.bridgeServerName = nil
|
||||||
self.bridgeRemoteAddress = nil
|
self.bridgeRemoteAddress = nil
|
||||||
self.connectedBridgeDebugID = nil
|
self.connectedBridgeID = nil
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
|
self.bridgeStatusText = "Bridge error: \(error.localizedDescription)"
|
||||||
self.bridgeServerName = nil
|
self.bridgeServerName = nil
|
||||||
self.bridgeRemoteAddress = nil
|
self.bridgeRemoteAddress = nil
|
||||||
self.connectedBridgeDebugID = nil
|
self.connectedBridgeID = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -116,7 +116,7 @@ final class NodeAppModel: ObservableObject {
|
|||||||
self.bridgeStatusText = "Disconnected"
|
self.bridgeStatusText = "Disconnected"
|
||||||
self.bridgeServerName = nil
|
self.bridgeServerName = nil
|
||||||
self.bridgeRemoteAddress = nil
|
self.bridgeRemoteAddress = nil
|
||||||
self.connectedBridgeDebugID = nil
|
self.connectedBridgeID = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
|
func sendVoiceTranscript(text: String, sessionKey: String?) async throws {
|
||||||
|
|||||||
@@ -98,9 +98,9 @@ struct SettingsTab: View {
|
|||||||
Text("No bridges found yet.")
|
Text("No bridges found yet.")
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
let connectedID = self.appModel.connectedBridgeDebugID
|
let connectedID = self.appModel.connectedBridgeID
|
||||||
let rows = self.discovery.bridges.filter { bridge in
|
let rows = self.discovery.bridges.filter { bridge in
|
||||||
let isConnected = bridge.debugID == connectedID
|
let isConnected = bridge.stableID == connectedID
|
||||||
switch showing {
|
switch showing {
|
||||||
case .all:
|
case .all:
|
||||||
return true
|
return true
|
||||||
@@ -117,10 +117,6 @@ struct SettingsTab: View {
|
|||||||
HStack {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
Text(bridge.name)
|
Text(bridge.name)
|
||||||
Text(bridge.debugID)
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
}
|
||||||
Spacer()
|
Spacer()
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import AVFAudio
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Speech
|
import Speech
|
||||||
|
|
||||||
|
private func makeAudioTapEnqueueCallback(queue: AudioBufferQueue) -> AVAudioNodeTapBlock {
|
||||||
|
{ buffer, _ in
|
||||||
|
// This callback is invoked on a realtime audio thread/queue. Keep it tiny and nonisolated.
|
||||||
|
queue.enqueueCopy(of: buffer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private final class AudioBufferQueue: @unchecked Sendable {
|
private final class AudioBufferQueue: @unchecked Sendable {
|
||||||
private let lock = NSLock()
|
private let lock = NSLock()
|
||||||
private var buffers: [AVAudioPCMBuffer] = []
|
private var buffers: [AVAudioPCMBuffer] = []
|
||||||
@@ -41,7 +48,7 @@ extension AVAudioPCMBuffer {
|
|||||||
let channels = Int(format.channelCount)
|
let channels = Int(format.channelCount)
|
||||||
let frames = Int(frameLength)
|
let frames = Int(frameLength)
|
||||||
for ch in 0..<channels {
|
for ch in 0..<channels {
|
||||||
dst[ch].assign(from: src[ch], count: frames)
|
dst[ch].update(from: src[ch], count: frames)
|
||||||
}
|
}
|
||||||
return copy
|
return copy
|
||||||
}
|
}
|
||||||
@@ -50,7 +57,7 @@ extension AVAudioPCMBuffer {
|
|||||||
let channels = Int(format.channelCount)
|
let channels = Int(format.channelCount)
|
||||||
let frames = Int(frameLength)
|
let frames = Int(frameLength)
|
||||||
for ch in 0..<channels {
|
for ch in 0..<channels {
|
||||||
dst[ch].assign(from: src[ch], count: frames)
|
dst[ch].update(from: src[ch], count: frames)
|
||||||
}
|
}
|
||||||
return copy
|
return copy
|
||||||
}
|
}
|
||||||
@@ -59,7 +66,7 @@ extension AVAudioPCMBuffer {
|
|||||||
let channels = Int(format.channelCount)
|
let channels = Int(format.channelCount)
|
||||||
let frames = Int(frameLength)
|
let frames = Int(frameLength)
|
||||||
for ch in 0..<channels {
|
for ch in 0..<channels {
|
||||||
dst[ch].assign(from: src[ch], count: frames)
|
dst[ch].update(from: src[ch], count: frames)
|
||||||
}
|
}
|
||||||
return copy
|
return copy
|
||||||
}
|
}
|
||||||
@@ -176,11 +183,11 @@ final class VoiceWakeManager: NSObject, ObservableObject {
|
|||||||
|
|
||||||
let queue = AudioBufferQueue()
|
let queue = AudioBufferQueue()
|
||||||
self.tapQueue = queue
|
self.tapQueue = queue
|
||||||
inputNode.installTap(onBus: 0, bufferSize: 1024, format: recordingFormat) { [weak queue] buffer, _ in
|
inputNode.installTap(
|
||||||
// `SFSpeechAudioBufferRecognitionRequest.append` is MainActor-isolated on iOS 26.
|
onBus: 0,
|
||||||
// Copy + enqueue in the realtime callback, drain + append from the MainActor.
|
bufferSize: 1024,
|
||||||
queue?.enqueueCopy(of: buffer)
|
format: recordingFormat,
|
||||||
}
|
block: makeAudioTapEnqueueCallback(queue: queue))
|
||||||
|
|
||||||
self.audioEngine.prepare()
|
self.audioEngine.prepare()
|
||||||
try self.audioEngine.start()
|
try self.audioEngine.start()
|
||||||
|
|||||||
@@ -43,14 +43,14 @@ targets:
|
|||||||
info:
|
info:
|
||||||
path: Sources/Info.plist
|
path: Sources/Info.plist
|
||||||
properties:
|
properties:
|
||||||
CFBundleDisplayName: Clawdis Node
|
CFBundleDisplayName: ClawdisKit
|
||||||
UILaunchScreen: {}
|
UILaunchScreen: {}
|
||||||
UIApplicationSceneManifest:
|
UIApplicationSceneManifest:
|
||||||
UIApplicationSupportsMultipleScenes: false
|
UIApplicationSupportsMultipleScenes: false
|
||||||
UIBackgroundModes:
|
UIBackgroundModes:
|
||||||
- audio
|
- audio
|
||||||
NSLocalNetworkUsageDescription: Clawdis Node discovers and connects to your Clawdis bridge on the local network.
|
NSLocalNetworkUsageDescription: ClawdisKit discovers and connects to your Clawdis bridge on the local network.
|
||||||
NSBonjourServices:
|
NSBonjourServices:
|
||||||
- _clawdis-bridge._tcp
|
- _clawdis-bridge._tcp
|
||||||
NSMicrophoneUsageDescription: Clawdis Node needs microphone access for voice wake.
|
NSMicrophoneUsageDescription: ClawdisKit needs microphone access for voice wake.
|
||||||
NSSpeechRecognitionUsageDescription: Clawdis Node uses on-device speech recognition for voice wake.
|
NSSpeechRecognitionUsageDescription: ClawdisKit uses on-device speech recognition for voice wake.
|
||||||
|
|||||||
@@ -74,6 +74,12 @@ CLI must be able to fully operate without any GUI:
|
|||||||
Optional interactive helper:
|
Optional interactive helper:
|
||||||
- `clawdis nodes watch` (subscribe to `node.pair.requested` and prompt in-place)
|
- `clawdis nodes watch` (subscribe to `node.pair.requested` and prompt in-place)
|
||||||
|
|
||||||
|
Implementation pointers:
|
||||||
|
- CLI commands: `src/cli/nodes-cli.ts`
|
||||||
|
- Gateway handlers + events: `src/gateway/server.ts`
|
||||||
|
- Pairing store: `src/infra/node-pairing.ts` (under `~/.clawdis/nodes/`)
|
||||||
|
- Optional macOS UI prompt (frontend only): `apps/macos/Sources/Clawdis/NodePairingApprovalPrompter.swift`
|
||||||
|
|
||||||
## Storage (private, local)
|
## Storage (private, local)
|
||||||
Gateway stores the authoritative state under `~/.clawdis/`:
|
Gateway stores the authoritative state under `~/.clawdis/`:
|
||||||
- `~/.clawdis/nodes/paired.json`
|
- `~/.clawdis/nodes/paired.json`
|
||||||
|
|||||||
Reference in New Issue
Block a user