openclaw/apps/ios/Sources/Settings/TTSVoiceSettingsView.swift
ronitchidara fdc7e11891 mobile: Phase 4 polish - refactoring, app store prep, and infrastructure
Tier 5 - Code Refactoring:
- iOS: Split NodeAppModel.swift (988→493 LOC) into focused modules
  - NodeCommandHandlers.swift: command routing (372 LOC)
  - NodeGatewaySync.swift: gateway sync, branding (153 LOC)
- Android: Split NodeRuntime.kt (1268→756 LOC, 40% reduction)
  - NodeCommandHandlers.kt: command routing (326 LOC)
  - NodeGatewaySync.kt: gateway sync, A2UI helpers (294 LOC)

Tier 6 - App Store Preparation:
- iOS: Add PrivacyInfo.xcprivacy (iOS 17+ privacy manifest)
- Android: Add ProGuard rules and enable minification for release builds

Infrastructure (from earlier tiers):
- iOS: Add OfflineMessageQueue for disconnected message queueing
- iOS: Add PushManager for VoIP push foundation
- iOS: Add TTSVoiceSettingsView for voice selection
- Android: Add ClawdbotMessagingService for FCM push
- Android: Add SyncWorker for background chat sync
- Both: Deep linking support (clawdbot:// URL scheme)
- Both: Empty states and loading indicators in settings

Testing:
- iOS: Add OfflineMessageQueueTests (8 tests)
- Android: Add NodeGatewaySyncTest (11 tests)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 17:01:20 +05:30

141 lines
4.7 KiB
Swift

import AVFoundation
import SwiftUI
/// Settings view for configuring Text-to-Speech voice parameters.
struct TTSVoiceSettingsView: View {
@AppStorage("tts.voiceIdentifier") private var selectedVoiceId = ""
@AppStorage("tts.rate") private var speechRate = 0.5
@AppStorage("tts.pitch") private var speechPitch = 1.0
@State private var availableVoices: [AVSpeechSynthesisVoice] = []
@State private var isTestPlaying = false
private let synthesizer = AVSpeechSynthesizer()
var body: some View {
Form {
Section {
Picker("Voice", selection: self.$selectedVoiceId) {
Text("System Default").tag("")
ForEach(self.availableVoices, id: \.identifier) { voice in
Text(self.voiceDisplayName(voice))
.tag(voice.identifier)
}
}
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Speech Rate")
Spacer()
Text(self.rateLabel)
.foregroundStyle(.secondary)
}
Slider(value: self.$speechRate, in: 0.0 ... 1.0, step: 0.1)
}
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Pitch")
Spacer()
Text(String(format: "%.1f", self.speechPitch))
.foregroundStyle(.secondary)
}
Slider(value: self.$speechPitch, in: 0.5 ... 2.0, step: 0.1)
}
Button {
self.testVoice()
} label: {
HStack {
if self.isTestPlaying {
ProgressView()
.progressViewStyle(.circular)
.scaleEffect(0.8)
}
Text(self.isTestPlaying ? "Playing..." : "Test Voice")
}
}
.disabled(self.isTestPlaying)
} header: {
Text("Text-to-Speech")
} footer: {
Text("These settings apply to system TTS fallback when cloud voices are unavailable.")
}
Section {
Button("Reset to Defaults") {
self.resetDefaults()
}
}
}
.navigationTitle("TTS Voice")
.onAppear {
self.loadVoices()
}
}
private var rateLabel: String {
switch self.speechRate {
case 0.0 ..< 0.3:
"Slow"
case 0.3 ..< 0.6:
"Normal"
case 0.6 ..< 0.8:
"Fast"
default:
"Very Fast"
}
}
private func voiceDisplayName(_ voice: AVSpeechSynthesisVoice) -> String {
let language = Locale.current.localizedString(forIdentifier: voice.language) ?? voice.language
return "\(voice.name) (\(language))"
}
private func loadVoices() {
let voices = AVSpeechSynthesisVoice.speechVoices()
// Filter to show only enhanced/premium voices and common languages
let filtered = voices.filter { voice in
voice.quality == .enhanced || voice.quality == .premium
}
// Sort by language then name
self.availableVoices = filtered.sorted { a, b in
if a.language != b.language {
return a.language < b.language
}
return a.name < b.name
}
}
private func testVoice() {
self.isTestPlaying = true
let utterance = AVSpeechUtterance(string: "Hello, I'm Clawdbot. How can I help you today?")
// Apply selected voice
if !self.selectedVoiceId.isEmpty,
let voice = AVSpeechSynthesisVoice(identifier: self.selectedVoiceId)
{
utterance.voice = voice
}
// Apply rate (AVSpeechUtterance rate is 0.0-1.0 but default speaking rate is around 0.5)
utterance.rate = Float(self.speechRate) * AVSpeechUtteranceMaximumSpeechRate
utterance.pitchMultiplier = Float(self.speechPitch)
self.synthesizer.speak(utterance)
// Reset playing state when done
Task {
// Simple delay approximation since we can't easily track utterance completion here
try? await Task.sleep(nanoseconds: 3_000_000_000)
await MainActor.run {
self.isTestPlaying = false
}
}
}
private func resetDefaults() {
self.selectedVoiceId = ""
self.speechRate = 0.5
self.speechPitch = 1.0
}
}