Files
voice-cat/clients/apple/iOS/VoiceCatiOS/Views/VoiceControlsView.swift
Talon 8c90e250f0 feat(apple): screen-audio sharing -- macOS ScreenCaptureKit, iOS ReplayKit
Implement system/desktop audio sharing on the Apple clients, feeding the
existing SCREEN_AUDIO Opus -> AEAD -> UDP path via vc_stream_feed_pcm. No
C++/protocol/codec changes -- the core was already ready (the Windows-only
loopback is #ifdef VOICECAT_HAS_LOOPBACK; off Windows the stream just waits
for fed PCM). Audio only; video is dropped.

macOS (in-process):
- ScreenAudioCapture.swift drives an audio-only SCStream
  (excludesCurrentProcessAudio), converts Float32 -> int16 in the channel's
  mono/stereo mode, and calls feedPcm. Capture starts on the self
  .streamStarted event (effective config known then). Wired into
  MainWindowController.screenAudioClicked().

iOS (forward-to-host, single session):
- VoiceCatBroadcast: a ReplayKit Broadcast Upload Extension consumes
  .audioApp only, resamples to 48kHz int16 stereo (AVAudioConverter), and
  writes a shared App Group SPSC ring (BroadcastAudioRing.swift). It does
  not link libvoicecat.
- Host BroadcastAudioPump drains the ring (reacting to the extension's
  Darwin notifications) and feeds the SCREEN_AUDIO stream it owns, downmixing
  to mono when the channel is mono. Screen audio appears as a second stream
  of the same user; no credentials persisted. UI is RPSystemBroadcastPicker
  View in VoiceControlsView. Removes the speculative BroadcastCredentials.

Docs: voice.md s9, CLAUDE.md status, PROGRESS.md.
2026-06-21 00:14:31 +02:00

164 lines
6.5 KiB
Swift

import SwiftUI
import VoiceCatCore
import ReplayKit
struct VoiceControlsView: View {
@Bindable var session: SessionState
var body: some View {
HStack(spacing: 20) {
// Join/Leave Voice button (mirrors macOS micToggleButton)
if session.voiceState.inputMode == .pushToTalk {
PTTButton(session: session)
} else {
Button {
if session.voiceState.micActive {
session.stopMicStream()
} else {
session.startMicStream()
}
} label: {
Text(session.voiceState.micActive ? "Leave Voice" : "Join Voice")
.font(.body.weight(.semibold))
.frame(minWidth: 110)
.padding(.vertical, 8)
.padding(.horizontal, 12)
.background(session.voiceState.micActive ? Color.green.opacity(0.2) : Color.accentColor.opacity(0.15), in: RoundedRectangle(cornerRadius: 8))
.foregroundStyle(session.voiceState.micActive ? .green : .accentColor)
}
.disabled(session.currentChannelId == 0)
.accessibilityLabel(session.voiceState.micActive ? "Leave Voice — stop sending microphone audio" : "Join Voice — start sending microphone audio")
}
// Level meter
LevelMeterView(level: session.voiceState.level)
.frame(width: 80, height: 8)
.accessibilityHidden(true)
Spacer()
// Self mute (disabled when not in voice)
Button {
session.setMute(!session.voiceState.selfMuted, deafened: session.voiceState.selfDeafened)
} label: {
Image(systemName: session.voiceState.selfMuted ? "mic.slash" : "mic")
.font(.title3)
.foregroundStyle(session.voiceState.selfMuted ? .red : .primary)
}
.disabled(!session.voiceState.micActive)
.accessibilityLabel(session.voiceState.selfMuted ? "Unmute microphone" : "Mute microphone")
// Self deafen (disabled when not in voice)
Button {
session.setMute(session.voiceState.selfMuted, deafened: !session.voiceState.selfDeafened)
} label: {
Image(systemName: session.voiceState.selfDeafened ? "headphones.slash" : "headphones")
.font(.title3)
.foregroundStyle(session.voiceState.selfDeafened ? .red : .primary)
}
.disabled(!session.voiceState.micActive)
.accessibilityLabel(session.voiceState.selfDeafened ? "Undeafen" : "Deafen")
// Share screen audio (ReplayKit system broadcast picker). The picker launches the
// broadcast upload extension; the host's BroadcastAudioPump then owns + feeds the
// SCREEN_AUDIO stream. Tinted while sharing.
BroadcastPickerButton(isSharing: session.voiceState.screenSharing)
.frame(width: 32, height: 32)
.accessibilityLabel(session.voiceState.screenSharing
? "Stop sharing screen audio" : "Share screen audio")
// Disconnect
Button(role: .destructive) {
session.stopMicStream()
session.client.disconnect()
} label: {
Image(systemName: "phone.down.fill")
.font(.title3)
.foregroundStyle(.red)
}
.accessibilityLabel("Disconnect from server")
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(.bar)
}
}
// MARK: - PTT Button (DragGesture instead of NSEvent on iOS)
private struct PTTButton: View {
@Bindable var session: SessionState
@GestureState private var isPressing = false
var body: some View {
Circle()
.fill(isPressing ? Color.blue : Color(.systemGray4))
.frame(width: 44, height: 44)
.overlay {
Image(systemName: "mic.fill")
.foregroundStyle(isPressing ? .white : .primary)
}
.gesture(
DragGesture(minimumDistance: 0)
.updating($isPressing) { _, state, _ in state = true }
.onChanged { _ in
if !isPressing { return }
if !session.voiceState.micActive { session.startMicStream() }
session.setPushToTalk(true)
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
}
.onEnded { _ in
session.setPushToTalk(false)
session.stopMicStream()
}
)
.accessibilityLabel("Push to talk, hold to transmit")
.accessibilityAddTraits(.isButton)
}
}
// MARK: - Broadcast picker
/// Wraps `RPSystemBroadcastPickerView` (which contains its own button) and points it at our
/// broadcast upload extension. Tapping it shows the system broadcast picker; the user starts the
/// broadcast and our extension launches.
private struct BroadcastPickerButton: UIViewRepresentable {
let isSharing: Bool
func makeUIView(context: Context) -> RPSystemBroadcastPickerView {
let picker = RPSystemBroadcastPickerView(frame: CGRect(x: 0, y: 0, width: 32, height: 32))
picker.preferredExtension = "cat.voice.VoiceCatiOS.broadcast"
picker.showsMicrophoneButton = false
return picker
}
func updateUIView(_ uiView: RPSystemBroadcastPickerView, context: Context) {
uiView.tintColor = isSharing ? .systemGreen : .label
}
}
// MARK: - Level Meter
private struct LevelMeterView: View {
let level: Float
var body: some View {
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(Color(.systemGray5))
RoundedRectangle(cornerRadius: 4)
.fill(levelColor)
.frame(width: geo.size.width * CGFloat(min(level * 10, 1.0)))
.animation(.linear(duration: 0.05), value: level)
}
}
}
private var levelColor: Color {
if level > 0.15 { return .orange }
if level > 0.05 { return .green }
return .green.opacity(0.5)
}
}