feat: add additional voice wake languages + clean locale labels
This commit is contained in:
@@ -25,6 +25,7 @@ private let swabbleTriggersKey = "clawdis.swabbleTriggers"
|
|||||||
private let defaultVoiceWakeTriggers = ["clawd", "claude"]
|
private let defaultVoiceWakeTriggers = ["clawd", "claude"]
|
||||||
private let voiceWakeMicKey = "clawdis.voiceWakeMicID"
|
private let voiceWakeMicKey = "clawdis.voiceWakeMicID"
|
||||||
private let voiceWakeLocaleKey = "clawdis.voiceWakeLocaleID"
|
private let voiceWakeLocaleKey = "clawdis.voiceWakeLocaleID"
|
||||||
|
private let voiceWakeAdditionalLocalesKey = "clawdis.voiceWakeAdditionalLocaleIDs"
|
||||||
private let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
|
private let voiceWakeSupported: Bool = ProcessInfo.processInfo.operatingSystemVersion.majorVersion >= 26
|
||||||
|
|
||||||
// MARK: - App model
|
// MARK: - App model
|
||||||
@@ -65,6 +66,9 @@ final class AppState: ObservableObject {
|
|||||||
@Published var voiceWakeLocaleID: String {
|
@Published var voiceWakeLocaleID: String {
|
||||||
didSet { UserDefaults.standard.set(voiceWakeLocaleID, forKey: voiceWakeLocaleKey) }
|
didSet { UserDefaults.standard.set(voiceWakeLocaleID, forKey: voiceWakeLocaleKey) }
|
||||||
}
|
}
|
||||||
|
@Published var voiceWakeAdditionalLocaleIDs: [String] {
|
||||||
|
didSet { UserDefaults.standard.set(voiceWakeAdditionalLocaleIDs, forKey: voiceWakeAdditionalLocalesKey) }
|
||||||
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
|
||||||
@@ -77,6 +81,7 @@ final class AppState: ObservableObject {
|
|||||||
self.swabbleTriggerWords = UserDefaults.standard.stringArray(forKey: swabbleTriggersKey) ?? defaultVoiceWakeTriggers
|
self.swabbleTriggerWords = UserDefaults.standard.stringArray(forKey: swabbleTriggersKey) ?? defaultVoiceWakeTriggers
|
||||||
self.voiceWakeMicID = UserDefaults.standard.string(forKey: voiceWakeMicKey) ?? ""
|
self.voiceWakeMicID = UserDefaults.standard.string(forKey: voiceWakeMicKey) ?? ""
|
||||||
self.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier
|
self.voiceWakeLocaleID = UserDefaults.standard.string(forKey: voiceWakeLocaleKey) ?? Locale.current.identifier
|
||||||
|
self.voiceWakeAdditionalLocaleIDs = UserDefaults.standard.stringArray(forKey: voiceWakeAdditionalLocalesKey) ?? []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1947,16 +1952,74 @@ struct VoiceWakeSettings: View {
|
|||||||
LabeledContent("Recognition language") {
|
LabeledContent("Recognition language") {
|
||||||
Picker("Language", selection: $state.voiceWakeLocaleID) {
|
Picker("Language", selection: $state.voiceWakeLocaleID) {
|
||||||
let current = Locale(identifier: Locale.current.identifier)
|
let current = Locale(identifier: Locale.current.identifier)
|
||||||
Text("\(displayName(for: current)) (System)").tag(Locale.current.identifier)
|
Text("\(friendlyName(for: current)) (System)").tag(Locale.current.identifier)
|
||||||
ForEach(availableLocales.map { $0.identifier }, id: \.self) { id in
|
ForEach(availableLocales.map { $0.identifier }, id: \.self) { id in
|
||||||
if id != Locale.current.identifier {
|
if id != Locale.current.identifier {
|
||||||
Text(displayName(for: Locale(identifier: id))).tag(id)
|
Text(friendlyName(for: Locale(identifier: id))).tag(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.frame(width: 260)
|
.frame(width: 260)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !state.voiceWakeAdditionalLocaleIDs.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Additional languages")
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
ForEach(Array(state.voiceWakeAdditionalLocaleIDs.enumerated()), id: \.offset) { idx, localeID in
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Picker("Extra \(idx + 1)", selection: Binding(
|
||||||
|
get: { localeID },
|
||||||
|
set: { newValue in
|
||||||
|
guard state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
|
||||||
|
state.voiceWakeAdditionalLocaleIDs[idx] = newValue
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
ForEach(availableLocales.map { $0.identifier }, id: \.self) { id in
|
||||||
|
Text(friendlyName(for: Locale(identifier: id))).tag(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.frame(width: 220)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
guard state.voiceWakeAdditionalLocaleIDs.indices.contains(idx) else { return }
|
||||||
|
state.voiceWakeAdditionalLocaleIDs.remove(at: idx)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderless)
|
||||||
|
.help("Remove language")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
if let first = availableLocales.first {
|
||||||
|
state.voiceWakeAdditionalLocaleIDs.append(first.identifier)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Add language", systemImage: "plus")
|
||||||
|
}
|
||||||
|
.disabled(availableLocales.isEmpty)
|
||||||
|
}
|
||||||
|
.padding(.top, 4)
|
||||||
|
} else {
|
||||||
|
Button {
|
||||||
|
if let first = availableLocales.first {
|
||||||
|
state.voiceWakeAdditionalLocaleIDs.append(first.identifier)
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
Label("Add additional language", systemImage: "plus")
|
||||||
|
}
|
||||||
|
.buttonStyle(.link)
|
||||||
|
.disabled(availableLocales.isEmpty)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("Languages are tried in order. Models may need a first-use download on macOS 26.")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1977,13 +2040,41 @@ struct VoiceWakeSettings: View {
|
|||||||
private func loadLocalesIfNeeded() async {
|
private func loadLocalesIfNeeded() async {
|
||||||
guard availableLocales.isEmpty else { return }
|
guard availableLocales.isEmpty else { return }
|
||||||
availableLocales = Array(SFSpeechRecognizer.supportedLocales()).sorted { lhs, rhs in
|
availableLocales = Array(SFSpeechRecognizer.supportedLocales()).sorted { lhs, rhs in
|
||||||
displayName(for: lhs).localizedCaseInsensitiveCompare(displayName(for: rhs)) == .orderedAscending
|
friendlyName(for: lhs).localizedCaseInsensitiveCompare(friendlyName(for: rhs)) == .orderedAscending
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func displayName(for locale: Locale) -> String {
|
/// Produce a human-friendly label without odd BCP-47 variants (rg=zzzz, calendar, collation, numbering).
|
||||||
let name = locale.localizedString(forIdentifier: locale.identifier) ?? locale.identifier
|
private func friendlyName(for locale: Locale) -> String {
|
||||||
return name
|
let cleanedID = normalizedLocaleIdentifier(locale.identifier)
|
||||||
|
let cleanLocale = Locale(identifier: cleanedID)
|
||||||
|
|
||||||
|
if let langCode = cleanLocale.languageCode,
|
||||||
|
let lang = cleanLocale.localizedString(forLanguageCode: langCode),
|
||||||
|
let regionCode = cleanLocale.regionCode,
|
||||||
|
let region = cleanLocale.localizedString(forRegionCode: regionCode) {
|
||||||
|
return "\(lang) (\(region))"
|
||||||
|
}
|
||||||
|
if let langCode = cleanLocale.languageCode,
|
||||||
|
let lang = cleanLocale.localizedString(forLanguageCode: langCode) {
|
||||||
|
return lang
|
||||||
|
}
|
||||||
|
return cleanLocale.localizedString(forIdentifier: cleanedID) ?? cleanedID
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strip uncommon BCP-47 subtags so labels stay readable (e.g. remove @rg=zzzz, -u- extensions).
|
||||||
|
private func normalizedLocaleIdentifier(_ raw: String) -> String {
|
||||||
|
var trimmed = raw
|
||||||
|
if let at = trimmed.firstIndex(of: "@") {
|
||||||
|
trimmed = String(trimmed[..<at])
|
||||||
|
}
|
||||||
|
if let u = trimmed.range(of: "-u-") {
|
||||||
|
trimmed = String(trimmed[..<u.lowerBound])
|
||||||
|
}
|
||||||
|
if let t = trimmed.range(of: "-t-") { // transform extension
|
||||||
|
trimmed = String(trimmed[..<t.lowerBound])
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
private var levelMeter: some View {
|
private var levelMeter: some View {
|
||||||
|
|||||||
Reference in New Issue
Block a user