Переглянути джерело

Improve AI Companion multilingual Apple Speech transcription and language controls.

Use chunk-level locale scoring with shorter overlapping chunks to improve mixed-language transcript quality, and add preferred language selectors in Settings with persisted primary/secondary locale choices.

Co-authored-by: Cursor <cursoragent@cursor.com>
huzaifahayat12 1 місяць тому
батько
коміт
49717bef71

+ 64 - 5
meetings_app/Transcription/MeetingTranscriptionService.swift

@@ -99,8 +99,8 @@ final class MeetingTranscriptionService {
99 99
     func transcribeMeeting(
100 100
         micURL: URL?,
101 101
         systemURL: URL?,
102
-        chunkSeconds: TimeInterval = 20,
103
-        overlapSeconds: TimeInterval = 1.5,
102
+        chunkSeconds: TimeInterval = 7,
103
+        overlapSeconds: TimeInterval = 1.2,
104 104
         locales: [Locale] = [],
105 105
         onProgress: (@Sendable (MeetingTranscriptionProgress) -> Void)? = nil
106 106
     ) async throws -> [TranscriptSegment] {
@@ -255,22 +255,42 @@ final class MeetingTranscriptionService {
255 255
             orderedLocales.append(locale)
256 256
         }
257 257
 
258
+        var bestCandidate: (text: String, locale: Locale, score: Double)?
258 259
         for locale in orderedLocales {
259 260
             guard let recognizer = SFSpeechRecognizer(locale: locale), recognizer.isAvailable else { continue }
260 261
             do {
261 262
                 let text = try await transcribeBuffer(buffer: buffer, recognizer: recognizer)
262 263
                 let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
263
-                if trimmed.isEmpty == false { return (trimmed, locale) }
264
+                guard trimmed.isEmpty == false else { continue }
265
+                let score = scoreTranscriptCandidate(trimmed, locale: locale, preferredLocale: preferredLocale)
266
+                if let current = bestCandidate {
267
+                    if score > current.score {
268
+                        bestCandidate = (trimmed, locale, score)
269
+                    }
270
+                } else {
271
+                    bestCandidate = (trimmed, locale, score)
272
+                }
264 273
             } catch {
265 274
                 // One transient retry before moving on to the next locale.
266 275
                 try? await Task.sleep(nanoseconds: 500_000_000)
267 276
                 if let text = try? await transcribeBuffer(buffer: buffer, recognizer: recognizer) {
268 277
                     let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
269
-                    if trimmed.isEmpty == false { return (trimmed, locale) }
278
+                    guard trimmed.isEmpty == false else { continue }
279
+                    let score = scoreTranscriptCandidate(trimmed, locale: locale, preferredLocale: preferredLocale)
280
+                    if let current = bestCandidate {
281
+                        if score > current.score {
282
+                            bestCandidate = (trimmed, locale, score)
283
+                        }
284
+                    } else {
285
+                        bestCandidate = (trimmed, locale, score)
286
+                    }
270 287
                 }
271 288
                 continue
272 289
             }
273 290
         }
291
+        if let bestCandidate {
292
+            return (bestCandidate.text, bestCandidate.locale)
293
+        }
274 294
         return ("", nil)
275 295
     }
276 296
 
@@ -316,10 +336,49 @@ final class MeetingTranscriptionService {
316 336
         identifier.replacingOccurrences(of: "_", with: "-").lowercased()
317 337
     }
318 338
 
339
+    private func scoreTranscriptCandidate(_ text: String, locale: Locale, preferredLocale: Locale?) -> Double {
340
+        let normalizedWords = text
341
+            .components(separatedBy: CharacterSet.letters.inverted)
342
+            .filter { $0.isEmpty == false }
343
+        guard normalizedWords.isEmpty == false else { return 0 }
344
+
345
+        let totalChars = max(1, text.count)
346
+        let letterCount = text.unicodeScalars.filter { CharacterSet.letters.contains($0) }.count
347
+        let letterRatio = Double(letterCount) / Double(totalChars)
348
+
349
+        var repeatedWordPenalty = 0.0
350
+        if normalizedWords.count >= 3 {
351
+            var longestRun = 1
352
+            var run = 1
353
+            for index in 1..<normalizedWords.count {
354
+                if normalizedWords[index].caseInsensitiveCompare(normalizedWords[index - 1]) == .orderedSame {
355
+                    run += 1
356
+                    longestRun = max(longestRun, run)
357
+                } else {
358
+                    run = 1
359
+                }
360
+            }
361
+            if longestRun >= 3 {
362
+                repeatedWordPenalty = Double(longestRun - 2) * 1.8
363
+            }
364
+        }
365
+
366
+        // Bias toward continuity, but do not overpower quality score.
367
+        let localeBias: Double
368
+        if let preferredLocale,
369
+           normalizeLocaleIdentifier(preferredLocale.identifier) == normalizeLocaleIdentifier(locale.identifier) {
370
+            localeBias = 1.0
371
+        } else {
372
+            localeBias = 0.0
373
+        }
374
+
375
+        return Double(normalizedWords.count) + (letterRatio * 4.0) + localeBias - repeatedWordPenalty
376
+    }
377
+
319 378
     /// Removes repeated leading words from a chunk that overlap with the
320 379
     /// trailing words of the previous chunk.
321 380
     private func removeRepeatedPrefix(from text: String, previousText: String) -> String {
322
-        let separators = CharacterSet.whitespacesAndNewlines
381
+        let separators = CharacterSet.whitespacesAndNewlines.union(.punctuationCharacters)
323 382
         let currentWords = text.components(separatedBy: separators).filter { $0.isEmpty == false }
324 383
         let previousWords = previousText.components(separatedBy: separators).filter { $0.isEmpty == false }
325 384
         guard currentWords.isEmpty == false, previousWords.isEmpty == false else { return text }

+ 290 - 1
meetings_app/ViewController.swift

@@ -40,6 +40,11 @@ private enum SettingsAction: Int {
40 40
     case termsOfServices = 7
41 41
 }
42 42
 
43
+private struct SpeechLocaleOption {
44
+    let identifier: String
45
+    let displayName: String
46
+}
47
+
43 48
 private enum PremiumPlan: Int {
44 49
     case weekly = 0
45 50
     case monthly = 1
@@ -471,6 +476,8 @@ final class ViewController: NSViewController {
471 476
     private weak var schedulePageRangeErrorLabel: NSTextField?
472 477
     private weak var schedulePageCardsStack: NSStackView?
473 478
     private weak var schedulePageCardsScrollView: NSScrollView?
479
+    private weak var settingsPagePrimaryLanguagePopup: NSPopUpButton?
480
+    private weak var settingsPageSecondaryLanguagePopup: NSPopUpButton?
474 481
 
475 482
     // MARK: - Calendar page (custom month UI)
476 483
     private var calendarPageMonthAnchor: Date = Calendar.current.startOfDay(for: Date())
@@ -493,6 +500,8 @@ final class ViewController: NSViewController {
493 500
     private let ratingStateMigrationV2DoneDefaultsKey = "rating.stateMigrationV2Done"
494 501
     private let nonPremiumJoinTrialConsumedDefaultsKey = "join.nonPremiumTrialConsumed"
495 502
     private let aiCompanionLocalRecordingsDefaultsKey = "aiCompanion.localRecordings"
503
+    private let aiCompanionPreferredLanguage1DefaultsKey = "aiCompanion.preferredLanguage1"
504
+    private let aiCompanionPreferredLanguage2DefaultsKey = "aiCompanion.preferredLanguage2"
496 505
     private let ratingEligibleUsageSeconds: TimeInterval = 30 * 60
497 506
     private let meetingTranscriptionService = MeetingTranscriptionService()
498 507
     private let meetingNotesService = MeetingNotesService()
@@ -521,15 +530,22 @@ final class ViewController: NSViewController {
521 530
         popover.animates = true
522 531
         let showUpgradeInSettings = storeKitCoordinator.hasPremiumAccess && !storeKitCoordinator.hasLifetimeAccess
523 532
         let showRateUsInSettings = shouldShowRateUsInSettings
533
+        let speechOptions = aiCompanionSupportedSpeechLocaleOptions()
524 534
         popover.contentViewController = SettingsMenuViewController(
525 535
             palette: palette,
526 536
             typography: typography,
527 537
             darkModeEnabled: darkModeEnabled,
528 538
             showRateUsInSettings: showRateUsInSettings,
529 539
             showUpgradeInSettings: showUpgradeInSettings,
540
+            speechLocaleOptions: speechOptions,
541
+            selectedPrimaryLanguageIdentifier: UserDefaults.standard.string(forKey: aiCompanionPreferredLanguage1DefaultsKey),
542
+            selectedSecondaryLanguageIdentifier: UserDefaults.standard.string(forKey: aiCompanionPreferredLanguage2DefaultsKey),
530 543
             onToggleDarkMode: { [weak self] enabled in
531 544
                 self?.setDarkMode(enabled)
532 545
             },
546
+            onUpdatePreferredSpeechLanguages: { [weak self] primary, secondary in
547
+                self?.updateAiCompanionPreferredSpeechLanguages(primary: primary, secondary: secondary)
548
+            },
533 549
             onAction: { [weak self] action, sourceView, clickPoint in
534 550
                 self?.handleSettingsAction(action, sourceView: sourceView, clickLocationInSourceView: clickPoint)
535 551
             }
@@ -3150,6 +3166,7 @@ private extension ViewController {
3150 3166
             }
3151 3167
         }
3152 3168
 
3169
+        let preferredLocales = aiCompanionPreferredTranscriptionLocales()
3153 3170
         let progressHandler: @Sendable (MeetingTranscriptionProgress) -> Void = { [weak self] progress in
3154 3171
             Task { @MainActor [weak self] in
3155 3172
                 guard let self else { return }
@@ -3170,6 +3187,7 @@ private extension ViewController {
3170 3187
             segments = try await meetingTranscriptionService.transcribeMeeting(
3171 3188
                 micURL: micURL,
3172 3189
                 systemURL: systemURL,
3190
+                locales: preferredLocales,
3173 3191
                 onProgress: progressHandler
3174 3192
             )
3175 3193
             source = .localMultiChannelAppleSpeech
@@ -3178,6 +3196,7 @@ private extension ViewController {
3178 3196
             segments = try await meetingTranscriptionService.transcribeMeeting(
3179 3197
                 micURL: nil,
3180 3198
                 systemURL: mixedURL,
3199
+                locales: preferredLocales,
3181 3200
                 onProgress: progressHandler
3182 3201
             )
3183 3202
             source = .localAudioAppleSpeech
@@ -3194,6 +3213,83 @@ private extension ViewController {
3194 3213
         return (renderedText, segmentsJSON, source)
3195 3214
     }
3196 3215
 
3216
+    private func aiCompanionPreferredTranscriptionLocales() -> [Locale] {
3217
+        let defaults = UserDefaults.standard
3218
+        let selectedIdentifiers = [
3219
+            defaults.string(forKey: aiCompanionPreferredLanguage1DefaultsKey),
3220
+            defaults.string(forKey: aiCompanionPreferredLanguage2DefaultsKey)
3221
+        ]
3222
+            .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
3223
+            .filter { $0.isEmpty == false }
3224
+
3225
+        var locales: [Locale] = []
3226
+        var seen = Set<String>()
3227
+
3228
+        func appendIdentifier(_ identifier: String) {
3229
+            let locale = Locale(identifier: identifier)
3230
+            guard SFSpeechRecognizer(locale: locale)?.isAvailable == true else { return }
3231
+            let normalized = identifier.replacingOccurrences(of: "_", with: "-").lowercased()
3232
+            guard seen.contains(normalized) == false else { return }
3233
+            seen.insert(normalized)
3234
+            locales.append(locale)
3235
+        }
3236
+
3237
+        for identifier in selectedIdentifiers {
3238
+            appendIdentifier(identifier)
3239
+        }
3240
+
3241
+        // Safe defaults for mixed-language meetings if user has not configured preferences yet.
3242
+        appendIdentifier(Locale.current.identifier)
3243
+        appendIdentifier("en-US")
3244
+
3245
+        if locales.isEmpty {
3246
+            return [Locale(identifier: "en-US")]
3247
+        }
3248
+        return locales
3249
+    }
3250
+
3251
+    private func aiCompanionSupportedSpeechLocaleOptions() -> [SpeechLocaleOption] {
3252
+        let supported = SFSpeechRecognizer.supportedLocales()
3253
+        let currentIdentifier = Locale.current.identifier
3254
+        let englishUSIdentifier = "en-US"
3255
+        var candidates = Set<String>(supported.map { $0.identifier })
3256
+        for preferred in Locale.preferredLanguages {
3257
+            candidates.insert(preferred)
3258
+        }
3259
+        candidates.insert(currentIdentifier)
3260
+        candidates.insert(englishUSIdentifier)
3261
+
3262
+        return candidates
3263
+            .map { identifier -> SpeechLocaleOption in
3264
+                let locale = Locale(identifier: identifier)
3265
+                let languageCode = locale.languageCode ?? Locale.components(fromIdentifier: identifier)[NSLocale.Key.languageCode.rawValue]
3266
+                let languageName = Locale.current.localizedString(forIdentifier: identifier)
3267
+                    ?? languageCode.flatMap { Locale.current.localizedString(forLanguageCode: $0) }
3268
+                    ?? identifier
3269
+                return SpeechLocaleOption(identifier: identifier, displayName: "\(languageName) (\(identifier))")
3270
+            }
3271
+            .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
3272
+    }
3273
+
3274
+    private func updateAiCompanionPreferredSpeechLanguages(primary: String, secondary: String?) {
3275
+        let normalizedPrimary = primary.trimmingCharacters(in: .whitespacesAndNewlines)
3276
+        guard normalizedPrimary.isEmpty == false else { return }
3277
+        UserDefaults.standard.set(normalizedPrimary, forKey: aiCompanionPreferredLanguage1DefaultsKey)
3278
+
3279
+        let cleanedSecondary = secondary?.trimmingCharacters(in: .whitespacesAndNewlines)
3280
+        if let cleanedSecondary, cleanedSecondary.isEmpty == false {
3281
+            let normalizedPrimaryKey = normalizedPrimary.replacingOccurrences(of: "_", with: "-").lowercased()
3282
+            let normalizedSecondaryKey = cleanedSecondary.replacingOccurrences(of: "_", with: "-").lowercased()
3283
+            if normalizedPrimaryKey == normalizedSecondaryKey {
3284
+                UserDefaults.standard.removeObject(forKey: aiCompanionPreferredLanguage2DefaultsKey)
3285
+            } else {
3286
+                UserDefaults.standard.set(cleanedSecondary, forKey: aiCompanionPreferredLanguage2DefaultsKey)
3287
+            }
3288
+        } else {
3289
+            UserDefaults.standard.removeObject(forKey: aiCompanionPreferredLanguage2DefaultsKey)
3290
+        }
3291
+    }
3292
+
3197 3293
     @objc private func aiCompanionNotesTapped(_ sender: NSButton) {
3198 3294
         let senderId = ObjectIdentifier(sender)
3199 3295
         guard let meetingId = aiCompanionNotesMeetingIdByView[senderId] else { return }
@@ -3678,6 +3774,16 @@ private extension ViewController {
3678 3774
         darkModeRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
3679 3775
         stack.setCustomSpacing(24, after: darkModeRow)
3680 3776
 
3777
+        let aiCompanionTitle = textLabel("AI Companion", font: typography.joinWithURLTitle, color: palette.textPrimary)
3778
+        stack.addArrangedSubview(aiCompanionTitle)
3779
+        let language1Row = makeSettingsSpeechLanguageRow(title: "Preferred Language 1", isPrimary: true)
3780
+        stack.addArrangedSubview(language1Row)
3781
+        language1Row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
3782
+        let language2Row = makeSettingsSpeechLanguageRow(title: "Preferred Language 2", isPrimary: false)
3783
+        stack.addArrangedSubview(language2Row)
3784
+        language2Row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
3785
+        stack.setCustomSpacing(24, after: language2Row)
3786
+
3681 3787
         let accountTitle = textLabel("Account", font: typography.joinWithURLTitle, color: palette.textPrimary)
3682 3788
         stack.addArrangedSubview(accountTitle)
3683 3789
         let googleAccountRow = makeSettingsGoogleAccountRow()
@@ -3782,6 +3888,83 @@ private extension ViewController {
3782 3888
         return row
3783 3889
     }
3784 3890
 
3891
+    private func makeSettingsSpeechLanguageRow(title: String, isPrimary: Bool) -> NSView {
3892
+        let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
3893
+        row.translatesAutoresizingMaskIntoConstraints = false
3894
+        row.heightAnchor.constraint(equalToConstant: 72).isActive = true
3895
+        styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
3896
+
3897
+        let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: palette.textPrimary)
3898
+        let popup = NSPopUpButton(frame: .zero, pullsDown: false)
3899
+        popup.translatesAutoresizingMaskIntoConstraints = false
3900
+        popup.target = self
3901
+        popup.action = #selector(settingsPageSpeechLanguageChanged(_:))
3902
+
3903
+        let options = aiCompanionSupportedSpeechLocaleOptions()
3904
+        if isPrimary == false {
3905
+            popup.addItem(withTitle: "None")
3906
+            popup.lastItem?.representedObject = ""
3907
+        }
3908
+        for option in options {
3909
+            popup.addItem(withTitle: option.displayName)
3910
+            popup.lastItem?.representedObject = option.identifier
3911
+        }
3912
+
3913
+        if isPrimary {
3914
+            let selected = UserDefaults.standard.string(forKey: aiCompanionPreferredLanguage1DefaultsKey)
3915
+                ?? options.first?.identifier
3916
+                ?? Locale.current.identifier
3917
+            selectSettingsPageLanguage(identifier: selected, in: popup)
3918
+            settingsPagePrimaryLanguagePopup = popup
3919
+        } else {
3920
+            if let selected = UserDefaults.standard.string(forKey: aiCompanionPreferredLanguage2DefaultsKey),
3921
+               selected.isEmpty == false {
3922
+                selectSettingsPageLanguage(identifier: selected, in: popup)
3923
+            } else {
3924
+                popup.selectItem(at: 0)
3925
+            }
3926
+            settingsPageSecondaryLanguagePopup = popup
3927
+        }
3928
+
3929
+        row.addSubview(titleLabel)
3930
+        row.addSubview(popup)
3931
+        NSLayoutConstraint.activate([
3932
+            titleLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
3933
+            titleLabel.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
3934
+            titleLabel.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
3935
+
3936
+            popup.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
3937
+            popup.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
3938
+            popup.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8),
3939
+            popup.heightAnchor.constraint(equalToConstant: 28)
3940
+        ])
3941
+        return row
3942
+    }
3943
+
3944
+    private func selectSettingsPageLanguage(identifier: String, in popup: NSPopUpButton) {
3945
+        for item in popup.itemArray {
3946
+            if let value = item.representedObject as? String, value == identifier {
3947
+                popup.select(item)
3948
+                return
3949
+            }
3950
+        }
3951
+    }
3952
+
3953
+    @objc private func settingsPageSpeechLanguageChanged(_ sender: NSPopUpButton) {
3954
+        guard let primary = settingsPagePrimaryLanguagePopup?.selectedItem?.representedObject as? String,
3955
+              primary.isEmpty == false else { return }
3956
+        var secondary: String? = settingsPageSecondaryLanguagePopup?.selectedItem?.representedObject as? String
3957
+        if secondary?.isEmpty == true {
3958
+            secondary = nil
3959
+        }
3960
+        if let secondaryValue = secondary,
3961
+           secondaryValue.replacingOccurrences(of: "_", with: "-").lowercased() == primary.replacingOccurrences(of: "_", with: "-").lowercased() {
3962
+            secondary = nil
3963
+            settingsPageSecondaryLanguagePopup?.selectItem(at: 0)
3964
+        }
3965
+        updateAiCompanionPreferredSpeechLanguages(primary: primary, secondary: secondary)
3966
+    }
3967
+
3785 3968
     private func makeSettingsRemindersSection() -> NSView {
3786 3969
         let container = NSStackView()
3787 3970
         container.translatesAutoresizingMaskIntoConstraints = false
@@ -7011,10 +7194,16 @@ private final class GoogleAccountMenuViewController: NSViewController {
7011 7194
 private final class SettingsMenuViewController: NSViewController {
7012 7195
     private let palette: Palette
7013 7196
     private let typography: Typography
7197
+    private let speechLocaleOptions: [SpeechLocaleOption]
7198
+    private let selectedPrimaryLanguageIdentifier: String?
7199
+    private let selectedSecondaryLanguageIdentifier: String?
7014 7200
     private let onToggleDarkMode: (Bool) -> Void
7201
+    private let onUpdatePreferredSpeechLanguages: (String, String?) -> Void
7015 7202
     private let onAction: (SettingsAction, NSView?, NSPoint?) -> Void
7016 7203
 
7017 7204
     private var darkToggle: NSSwitch?
7205
+    private var primaryLanguagePopup: NSPopUpButton?
7206
+    private var secondaryLanguagePopup: NSPopUpButton?
7018 7207
 
7019 7208
     init(
7020 7209
         palette: Palette,
@@ -7022,12 +7211,20 @@ private final class SettingsMenuViewController: NSViewController {
7022 7211
         darkModeEnabled: Bool,
7023 7212
         showRateUsInSettings: Bool,
7024 7213
         showUpgradeInSettings: Bool,
7214
+        speechLocaleOptions: [SpeechLocaleOption],
7215
+        selectedPrimaryLanguageIdentifier: String?,
7216
+        selectedSecondaryLanguageIdentifier: String?,
7025 7217
         onToggleDarkMode: @escaping (Bool) -> Void,
7218
+        onUpdatePreferredSpeechLanguages: @escaping (String, String?) -> Void,
7026 7219
         onAction: @escaping (SettingsAction, NSView?, NSPoint?) -> Void
7027 7220
     ) {
7028 7221
         self.palette = palette
7029 7222
         self.typography = typography
7223
+        self.speechLocaleOptions = speechLocaleOptions
7224
+        self.selectedPrimaryLanguageIdentifier = selectedPrimaryLanguageIdentifier
7225
+        self.selectedSecondaryLanguageIdentifier = selectedSecondaryLanguageIdentifier
7030 7226
         self.onToggleDarkMode = onToggleDarkMode
7227
+        self.onUpdatePreferredSpeechLanguages = onUpdatePreferredSpeechLanguages
7031 7228
         self.onAction = onAction
7032 7229
         super.init(nibName: nil, bundle: nil)
7033 7230
         self.view = makeView(
@@ -7061,7 +7258,7 @@ private final class SettingsMenuViewController: NSViewController {
7061 7258
             card.trailingAnchor.constraint(equalTo: root.trailingAnchor),
7062 7259
             card.topAnchor.constraint(equalTo: root.topAnchor),
7063 7260
             card.bottomAnchor.constraint(equalTo: root.bottomAnchor),
7064
-            root.widthAnchor.constraint(equalToConstant: 260)
7261
+            root.widthAnchor.constraint(equalToConstant: 320)
7065 7262
         ])
7066 7263
 
7067 7264
         let stack = NSStackView()
@@ -7079,6 +7276,14 @@ private final class SettingsMenuViewController: NSViewController {
7079 7276
         ])
7080 7277
 
7081 7278
         stack.addArrangedSubview(settingsDarkModeRow(enabled: darkModeEnabled))
7279
+        stack.addArrangedSubview(settingsSpeechLanguageRow(
7280
+            title: "AI Language 1",
7281
+            isPrimary: true
7282
+        ))
7283
+        stack.addArrangedSubview(settingsSpeechLanguageRow(
7284
+            title: "AI Language 2",
7285
+            isPrimary: false
7286
+        ))
7082 7287
         if showRateUsInSettings {
7083 7288
             stack.addArrangedSubview(settingsActionRow(icon: "★", title: "Rate Us", action: .rateUs))
7084 7289
         }
@@ -7193,6 +7398,90 @@ private final class SettingsMenuViewController: NSViewController {
7193 7398
         return row
7194 7399
     }
7195 7400
 
7401
+    private func settingsSpeechLanguageRow(title: String, isPrimary: Bool) -> NSView {
7402
+        let row = NSView()
7403
+        row.translatesAutoresizingMaskIntoConstraints = false
7404
+        row.heightAnchor.constraint(equalToConstant: 62).isActive = true
7405
+        row.wantsLayer = true
7406
+        row.layer?.cornerRadius = 10
7407
+        row.layer?.backgroundColor = NSColor.clear.cgColor
7408
+
7409
+        let titleLabel = NSTextField(labelWithString: title)
7410
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
7411
+        titleLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
7412
+        titleLabel.textColor = palette.textPrimary
7413
+
7414
+        let popup = NSPopUpButton(frame: .zero, pullsDown: false)
7415
+        popup.translatesAutoresizingMaskIntoConstraints = false
7416
+        popup.target = self
7417
+        popup.action = #selector(speechLanguageChanged(_:))
7418
+
7419
+        if isPrimary {
7420
+            for option in speechLocaleOptions {
7421
+                popup.addItem(withTitle: option.displayName)
7422
+                popup.lastItem?.representedObject = option.identifier
7423
+            }
7424
+            let preferred = selectedPrimaryLanguageIdentifier ?? speechLocaleOptions.first?.identifier
7425
+            if let preferred {
7426
+                selectLocale(identifier: preferred, in: popup)
7427
+            }
7428
+            primaryLanguagePopup = popup
7429
+        } else {
7430
+            popup.addItem(withTitle: "None")
7431
+            popup.lastItem?.representedObject = ""
7432
+            for option in speechLocaleOptions {
7433
+                popup.addItem(withTitle: option.displayName)
7434
+                popup.lastItem?.representedObject = option.identifier
7435
+            }
7436
+            if let secondary = selectedSecondaryLanguageIdentifier,
7437
+               secondary.isEmpty == false {
7438
+                selectLocale(identifier: secondary, in: popup)
7439
+            } else {
7440
+                popup.selectItem(at: 0)
7441
+            }
7442
+            secondaryLanguagePopup = popup
7443
+        }
7444
+
7445
+        row.addSubview(titleLabel)
7446
+        row.addSubview(popup)
7447
+        NSLayoutConstraint.activate([
7448
+            titleLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 4),
7449
+            titleLabel.topAnchor.constraint(equalTo: row.topAnchor, constant: 6),
7450
+            titleLabel.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -4),
7451
+
7452
+            popup.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 4),
7453
+            popup.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -2),
7454
+            popup.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 6),
7455
+            popup.heightAnchor.constraint(equalToConstant: 28)
7456
+        ])
7457
+        return row
7458
+    }
7459
+
7460
+    private func selectLocale(identifier: String, in popup: NSPopUpButton) {
7461
+        for item in popup.itemArray {
7462
+            if let represented = item.representedObject as? String, represented == identifier {
7463
+                popup.select(item)
7464
+                return
7465
+            }
7466
+        }
7467
+    }
7468
+
7469
+    @objc private func speechLanguageChanged(_ sender: NSPopUpButton) {
7470
+        guard let primary = primaryLanguagePopup?.selectedItem?.representedObject as? String,
7471
+              primary.isEmpty == false else { return }
7472
+
7473
+        var normalizedSecondary: String? = secondaryLanguagePopup?.selectedItem?.representedObject as? String
7474
+        if normalizedSecondary?.isEmpty == true {
7475
+            normalizedSecondary = nil
7476
+        }
7477
+        if let secondaryValue = normalizedSecondary,
7478
+           secondaryValue.replacingOccurrences(of: "_", with: "-").lowercased() == primary.replacingOccurrences(of: "_", with: "-").lowercased() {
7479
+            normalizedSecondary = nil
7480
+            secondaryLanguagePopup?.selectItem(at: 0)
7481
+        }
7482
+        onUpdatePreferredSpeechLanguages(primary, normalizedSecondary)
7483
+    }
7484
+
7196 7485
     @objc private func darkModeToggled(_ sender: NSSwitch) {
7197 7486
         onToggleDarkMode(sender.state == .on)
7198 7487
     }