소스 검색

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 2 달 전
부모
커밋
49717bef71
2개의 변경된 파일354개의 추가작업 그리고 6개의 파일을 삭제
  1. 64 5
      meetings_app/Transcription/MeetingTranscriptionService.swift
  2. 290 1
      meetings_app/ViewController.swift

+ 64 - 5
meetings_app/Transcription/MeetingTranscriptionService.swift

@@ -99,8 +99,8 @@ final class MeetingTranscriptionService {
     func transcribeMeeting(
         micURL: URL?,
         systemURL: URL?,
-        chunkSeconds: TimeInterval = 20,
-        overlapSeconds: TimeInterval = 1.5,
+        chunkSeconds: TimeInterval = 7,
+        overlapSeconds: TimeInterval = 1.2,
         locales: [Locale] = [],
         onProgress: (@Sendable (MeetingTranscriptionProgress) -> Void)? = nil
     ) async throws -> [TranscriptSegment] {
@@ -255,22 +255,42 @@ final class MeetingTranscriptionService {
             orderedLocales.append(locale)
         }
 
+        var bestCandidate: (text: String, locale: Locale, score: Double)?
         for locale in orderedLocales {
             guard let recognizer = SFSpeechRecognizer(locale: locale), recognizer.isAvailable else { continue }
             do {
                 let text = try await transcribeBuffer(buffer: buffer, recognizer: recognizer)
                 let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
-                if trimmed.isEmpty == false { return (trimmed, locale) }
+                guard trimmed.isEmpty == false else { continue }
+                let score = scoreTranscriptCandidate(trimmed, locale: locale, preferredLocale: preferredLocale)
+                if let current = bestCandidate {
+                    if score > current.score {
+                        bestCandidate = (trimmed, locale, score)
+                    }
+                } else {
+                    bestCandidate = (trimmed, locale, score)
+                }
             } catch {
                 // One transient retry before moving on to the next locale.
                 try? await Task.sleep(nanoseconds: 500_000_000)
                 if let text = try? await transcribeBuffer(buffer: buffer, recognizer: recognizer) {
                     let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
-                    if trimmed.isEmpty == false { return (trimmed, locale) }
+                    guard trimmed.isEmpty == false else { continue }
+                    let score = scoreTranscriptCandidate(trimmed, locale: locale, preferredLocale: preferredLocale)
+                    if let current = bestCandidate {
+                        if score > current.score {
+                            bestCandidate = (trimmed, locale, score)
+                        }
+                    } else {
+                        bestCandidate = (trimmed, locale, score)
+                    }
                 }
                 continue
             }
         }
+        if let bestCandidate {
+            return (bestCandidate.text, bestCandidate.locale)
+        }
         return ("", nil)
     }
 
@@ -316,10 +336,49 @@ final class MeetingTranscriptionService {
         identifier.replacingOccurrences(of: "_", with: "-").lowercased()
     }
 
+    private func scoreTranscriptCandidate(_ text: String, locale: Locale, preferredLocale: Locale?) -> Double {
+        let normalizedWords = text
+            .components(separatedBy: CharacterSet.letters.inverted)
+            .filter { $0.isEmpty == false }
+        guard normalizedWords.isEmpty == false else { return 0 }
+
+        let totalChars = max(1, text.count)
+        let letterCount = text.unicodeScalars.filter { CharacterSet.letters.contains($0) }.count
+        let letterRatio = Double(letterCount) / Double(totalChars)
+
+        var repeatedWordPenalty = 0.0
+        if normalizedWords.count >= 3 {
+            var longestRun = 1
+            var run = 1
+            for index in 1..<normalizedWords.count {
+                if normalizedWords[index].caseInsensitiveCompare(normalizedWords[index - 1]) == .orderedSame {
+                    run += 1
+                    longestRun = max(longestRun, run)
+                } else {
+                    run = 1
+                }
+            }
+            if longestRun >= 3 {
+                repeatedWordPenalty = Double(longestRun - 2) * 1.8
+            }
+        }
+
+        // Bias toward continuity, but do not overpower quality score.
+        let localeBias: Double
+        if let preferredLocale,
+           normalizeLocaleIdentifier(preferredLocale.identifier) == normalizeLocaleIdentifier(locale.identifier) {
+            localeBias = 1.0
+        } else {
+            localeBias = 0.0
+        }
+
+        return Double(normalizedWords.count) + (letterRatio * 4.0) + localeBias - repeatedWordPenalty
+    }
+
     /// Removes repeated leading words from a chunk that overlap with the
     /// trailing words of the previous chunk.
     private func removeRepeatedPrefix(from text: String, previousText: String) -> String {
-        let separators = CharacterSet.whitespacesAndNewlines
+        let separators = CharacterSet.whitespacesAndNewlines.union(.punctuationCharacters)
         let currentWords = text.components(separatedBy: separators).filter { $0.isEmpty == false }
         let previousWords = previousText.components(separatedBy: separators).filter { $0.isEmpty == false }
         guard currentWords.isEmpty == false, previousWords.isEmpty == false else { return text }

+ 290 - 1
meetings_app/ViewController.swift

@@ -40,6 +40,11 @@ private enum SettingsAction: Int {
     case termsOfServices = 7
 }
 
+private struct SpeechLocaleOption {
+    let identifier: String
+    let displayName: String
+}
+
 private enum PremiumPlan: Int {
     case weekly = 0
     case monthly = 1
@@ -471,6 +476,8 @@ final class ViewController: NSViewController {
     private weak var schedulePageRangeErrorLabel: NSTextField?
     private weak var schedulePageCardsStack: NSStackView?
     private weak var schedulePageCardsScrollView: NSScrollView?
+    private weak var settingsPagePrimaryLanguagePopup: NSPopUpButton?
+    private weak var settingsPageSecondaryLanguagePopup: NSPopUpButton?
 
     // MARK: - Calendar page (custom month UI)
     private var calendarPageMonthAnchor: Date = Calendar.current.startOfDay(for: Date())
@@ -493,6 +500,8 @@ final class ViewController: NSViewController {
     private let ratingStateMigrationV2DoneDefaultsKey = "rating.stateMigrationV2Done"
     private let nonPremiumJoinTrialConsumedDefaultsKey = "join.nonPremiumTrialConsumed"
     private let aiCompanionLocalRecordingsDefaultsKey = "aiCompanion.localRecordings"
+    private let aiCompanionPreferredLanguage1DefaultsKey = "aiCompanion.preferredLanguage1"
+    private let aiCompanionPreferredLanguage2DefaultsKey = "aiCompanion.preferredLanguage2"
     private let ratingEligibleUsageSeconds: TimeInterval = 30 * 60
     private let meetingTranscriptionService = MeetingTranscriptionService()
     private let meetingNotesService = MeetingNotesService()
@@ -521,15 +530,22 @@ final class ViewController: NSViewController {
         popover.animates = true
         let showUpgradeInSettings = storeKitCoordinator.hasPremiumAccess && !storeKitCoordinator.hasLifetimeAccess
         let showRateUsInSettings = shouldShowRateUsInSettings
+        let speechOptions = aiCompanionSupportedSpeechLocaleOptions()
         popover.contentViewController = SettingsMenuViewController(
             palette: palette,
             typography: typography,
             darkModeEnabled: darkModeEnabled,
             showRateUsInSettings: showRateUsInSettings,
             showUpgradeInSettings: showUpgradeInSettings,
+            speechLocaleOptions: speechOptions,
+            selectedPrimaryLanguageIdentifier: UserDefaults.standard.string(forKey: aiCompanionPreferredLanguage1DefaultsKey),
+            selectedSecondaryLanguageIdentifier: UserDefaults.standard.string(forKey: aiCompanionPreferredLanguage2DefaultsKey),
             onToggleDarkMode: { [weak self] enabled in
                 self?.setDarkMode(enabled)
             },
+            onUpdatePreferredSpeechLanguages: { [weak self] primary, secondary in
+                self?.updateAiCompanionPreferredSpeechLanguages(primary: primary, secondary: secondary)
+            },
             onAction: { [weak self] action, sourceView, clickPoint in
                 self?.handleSettingsAction(action, sourceView: sourceView, clickLocationInSourceView: clickPoint)
             }
@@ -3150,6 +3166,7 @@ private extension ViewController {
             }
         }
 
+        let preferredLocales = aiCompanionPreferredTranscriptionLocales()
         let progressHandler: @Sendable (MeetingTranscriptionProgress) -> Void = { [weak self] progress in
             Task { @MainActor [weak self] in
                 guard let self else { return }
@@ -3170,6 +3187,7 @@ private extension ViewController {
             segments = try await meetingTranscriptionService.transcribeMeeting(
                 micURL: micURL,
                 systemURL: systemURL,
+                locales: preferredLocales,
                 onProgress: progressHandler
             )
             source = .localMultiChannelAppleSpeech
@@ -3178,6 +3196,7 @@ private extension ViewController {
             segments = try await meetingTranscriptionService.transcribeMeeting(
                 micURL: nil,
                 systemURL: mixedURL,
+                locales: preferredLocales,
                 onProgress: progressHandler
             )
             source = .localAudioAppleSpeech
@@ -3194,6 +3213,83 @@ private extension ViewController {
         return (renderedText, segmentsJSON, source)
     }
 
+    private func aiCompanionPreferredTranscriptionLocales() -> [Locale] {
+        let defaults = UserDefaults.standard
+        let selectedIdentifiers = [
+            defaults.string(forKey: aiCompanionPreferredLanguage1DefaultsKey),
+            defaults.string(forKey: aiCompanionPreferredLanguage2DefaultsKey)
+        ]
+            .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
+            .filter { $0.isEmpty == false }
+
+        var locales: [Locale] = []
+        var seen = Set<String>()
+
+        func appendIdentifier(_ identifier: String) {
+            let locale = Locale(identifier: identifier)
+            guard SFSpeechRecognizer(locale: locale)?.isAvailable == true else { return }
+            let normalized = identifier.replacingOccurrences(of: "_", with: "-").lowercased()
+            guard seen.contains(normalized) == false else { return }
+            seen.insert(normalized)
+            locales.append(locale)
+        }
+
+        for identifier in selectedIdentifiers {
+            appendIdentifier(identifier)
+        }
+
+        // Safe defaults for mixed-language meetings if user has not configured preferences yet.
+        appendIdentifier(Locale.current.identifier)
+        appendIdentifier("en-US")
+
+        if locales.isEmpty {
+            return [Locale(identifier: "en-US")]
+        }
+        return locales
+    }
+
+    private func aiCompanionSupportedSpeechLocaleOptions() -> [SpeechLocaleOption] {
+        let supported = SFSpeechRecognizer.supportedLocales()
+        let currentIdentifier = Locale.current.identifier
+        let englishUSIdentifier = "en-US"
+        var candidates = Set<String>(supported.map { $0.identifier })
+        for preferred in Locale.preferredLanguages {
+            candidates.insert(preferred)
+        }
+        candidates.insert(currentIdentifier)
+        candidates.insert(englishUSIdentifier)
+
+        return candidates
+            .map { identifier -> SpeechLocaleOption in
+                let locale = Locale(identifier: identifier)
+                let languageCode = locale.languageCode ?? Locale.components(fromIdentifier: identifier)[NSLocale.Key.languageCode.rawValue]
+                let languageName = Locale.current.localizedString(forIdentifier: identifier)
+                    ?? languageCode.flatMap { Locale.current.localizedString(forLanguageCode: $0) }
+                    ?? identifier
+                return SpeechLocaleOption(identifier: identifier, displayName: "\(languageName) (\(identifier))")
+            }
+            .sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
+    }
+
+    private func updateAiCompanionPreferredSpeechLanguages(primary: String, secondary: String?) {
+        let normalizedPrimary = primary.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard normalizedPrimary.isEmpty == false else { return }
+        UserDefaults.standard.set(normalizedPrimary, forKey: aiCompanionPreferredLanguage1DefaultsKey)
+
+        let cleanedSecondary = secondary?.trimmingCharacters(in: .whitespacesAndNewlines)
+        if let cleanedSecondary, cleanedSecondary.isEmpty == false {
+            let normalizedPrimaryKey = normalizedPrimary.replacingOccurrences(of: "_", with: "-").lowercased()
+            let normalizedSecondaryKey = cleanedSecondary.replacingOccurrences(of: "_", with: "-").lowercased()
+            if normalizedPrimaryKey == normalizedSecondaryKey {
+                UserDefaults.standard.removeObject(forKey: aiCompanionPreferredLanguage2DefaultsKey)
+            } else {
+                UserDefaults.standard.set(cleanedSecondary, forKey: aiCompanionPreferredLanguage2DefaultsKey)
+            }
+        } else {
+            UserDefaults.standard.removeObject(forKey: aiCompanionPreferredLanguage2DefaultsKey)
+        }
+    }
+
     @objc private func aiCompanionNotesTapped(_ sender: NSButton) {
         let senderId = ObjectIdentifier(sender)
         guard let meetingId = aiCompanionNotesMeetingIdByView[senderId] else { return }
@@ -3678,6 +3774,16 @@ private extension ViewController {
         darkModeRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
         stack.setCustomSpacing(24, after: darkModeRow)
 
+        let aiCompanionTitle = textLabel("AI Companion", font: typography.joinWithURLTitle, color: palette.textPrimary)
+        stack.addArrangedSubview(aiCompanionTitle)
+        let language1Row = makeSettingsSpeechLanguageRow(title: "Preferred Language 1", isPrimary: true)
+        stack.addArrangedSubview(language1Row)
+        language1Row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
+        let language2Row = makeSettingsSpeechLanguageRow(title: "Preferred Language 2", isPrimary: false)
+        stack.addArrangedSubview(language2Row)
+        language2Row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
+        stack.setCustomSpacing(24, after: language2Row)
+
         let accountTitle = textLabel("Account", font: typography.joinWithURLTitle, color: palette.textPrimary)
         stack.addArrangedSubview(accountTitle)
         let googleAccountRow = makeSettingsGoogleAccountRow()
@@ -3782,6 +3888,83 @@ private extension ViewController {
         return row
     }
 
+    private func makeSettingsSpeechLanguageRow(title: String, isPrimary: Bool) -> NSView {
+        let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
+        row.translatesAutoresizingMaskIntoConstraints = false
+        row.heightAnchor.constraint(equalToConstant: 72).isActive = true
+        styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
+
+        let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: palette.textPrimary)
+        let popup = NSPopUpButton(frame: .zero, pullsDown: false)
+        popup.translatesAutoresizingMaskIntoConstraints = false
+        popup.target = self
+        popup.action = #selector(settingsPageSpeechLanguageChanged(_:))
+
+        let options = aiCompanionSupportedSpeechLocaleOptions()
+        if isPrimary == false {
+            popup.addItem(withTitle: "None")
+            popup.lastItem?.representedObject = ""
+        }
+        for option in options {
+            popup.addItem(withTitle: option.displayName)
+            popup.lastItem?.representedObject = option.identifier
+        }
+
+        if isPrimary {
+            let selected = UserDefaults.standard.string(forKey: aiCompanionPreferredLanguage1DefaultsKey)
+                ?? options.first?.identifier
+                ?? Locale.current.identifier
+            selectSettingsPageLanguage(identifier: selected, in: popup)
+            settingsPagePrimaryLanguagePopup = popup
+        } else {
+            if let selected = UserDefaults.standard.string(forKey: aiCompanionPreferredLanguage2DefaultsKey),
+               selected.isEmpty == false {
+                selectSettingsPageLanguage(identifier: selected, in: popup)
+            } else {
+                popup.selectItem(at: 0)
+            }
+            settingsPageSecondaryLanguagePopup = popup
+        }
+
+        row.addSubview(titleLabel)
+        row.addSubview(popup)
+        NSLayoutConstraint.activate([
+            titleLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
+            titleLabel.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
+            titleLabel.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
+
+            popup.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
+            popup.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
+            popup.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8),
+            popup.heightAnchor.constraint(equalToConstant: 28)
+        ])
+        return row
+    }
+
+    private func selectSettingsPageLanguage(identifier: String, in popup: NSPopUpButton) {
+        for item in popup.itemArray {
+            if let value = item.representedObject as? String, value == identifier {
+                popup.select(item)
+                return
+            }
+        }
+    }
+
+    @objc private func settingsPageSpeechLanguageChanged(_ sender: NSPopUpButton) {
+        guard let primary = settingsPagePrimaryLanguagePopup?.selectedItem?.representedObject as? String,
+              primary.isEmpty == false else { return }
+        var secondary: String? = settingsPageSecondaryLanguagePopup?.selectedItem?.representedObject as? String
+        if secondary?.isEmpty == true {
+            secondary = nil
+        }
+        if let secondaryValue = secondary,
+           secondaryValue.replacingOccurrences(of: "_", with: "-").lowercased() == primary.replacingOccurrences(of: "_", with: "-").lowercased() {
+            secondary = nil
+            settingsPageSecondaryLanguagePopup?.selectItem(at: 0)
+        }
+        updateAiCompanionPreferredSpeechLanguages(primary: primary, secondary: secondary)
+    }
+
     private func makeSettingsRemindersSection() -> NSView {
         let container = NSStackView()
         container.translatesAutoresizingMaskIntoConstraints = false
@@ -7011,10 +7194,16 @@ private final class GoogleAccountMenuViewController: NSViewController {
 private final class SettingsMenuViewController: NSViewController {
     private let palette: Palette
     private let typography: Typography
+    private let speechLocaleOptions: [SpeechLocaleOption]
+    private let selectedPrimaryLanguageIdentifier: String?
+    private let selectedSecondaryLanguageIdentifier: String?
     private let onToggleDarkMode: (Bool) -> Void
+    private let onUpdatePreferredSpeechLanguages: (String, String?) -> Void
     private let onAction: (SettingsAction, NSView?, NSPoint?) -> Void
 
     private var darkToggle: NSSwitch?
+    private var primaryLanguagePopup: NSPopUpButton?
+    private var secondaryLanguagePopup: NSPopUpButton?
 
     init(
         palette: Palette,
@@ -7022,12 +7211,20 @@ private final class SettingsMenuViewController: NSViewController {
         darkModeEnabled: Bool,
         showRateUsInSettings: Bool,
         showUpgradeInSettings: Bool,
+        speechLocaleOptions: [SpeechLocaleOption],
+        selectedPrimaryLanguageIdentifier: String?,
+        selectedSecondaryLanguageIdentifier: String?,
         onToggleDarkMode: @escaping (Bool) -> Void,
+        onUpdatePreferredSpeechLanguages: @escaping (String, String?) -> Void,
         onAction: @escaping (SettingsAction, NSView?, NSPoint?) -> Void
     ) {
         self.palette = palette
         self.typography = typography
+        self.speechLocaleOptions = speechLocaleOptions
+        self.selectedPrimaryLanguageIdentifier = selectedPrimaryLanguageIdentifier
+        self.selectedSecondaryLanguageIdentifier = selectedSecondaryLanguageIdentifier
         self.onToggleDarkMode = onToggleDarkMode
+        self.onUpdatePreferredSpeechLanguages = onUpdatePreferredSpeechLanguages
         self.onAction = onAction
         super.init(nibName: nil, bundle: nil)
         self.view = makeView(
@@ -7061,7 +7258,7 @@ private final class SettingsMenuViewController: NSViewController {
             card.trailingAnchor.constraint(equalTo: root.trailingAnchor),
             card.topAnchor.constraint(equalTo: root.topAnchor),
             card.bottomAnchor.constraint(equalTo: root.bottomAnchor),
-            root.widthAnchor.constraint(equalToConstant: 260)
+            root.widthAnchor.constraint(equalToConstant: 320)
         ])
 
         let stack = NSStackView()
@@ -7079,6 +7276,14 @@ private final class SettingsMenuViewController: NSViewController {
         ])
 
         stack.addArrangedSubview(settingsDarkModeRow(enabled: darkModeEnabled))
+        stack.addArrangedSubview(settingsSpeechLanguageRow(
+            title: "AI Language 1",
+            isPrimary: true
+        ))
+        stack.addArrangedSubview(settingsSpeechLanguageRow(
+            title: "AI Language 2",
+            isPrimary: false
+        ))
         if showRateUsInSettings {
             stack.addArrangedSubview(settingsActionRow(icon: "★", title: "Rate Us", action: .rateUs))
         }
@@ -7193,6 +7398,90 @@ private final class SettingsMenuViewController: NSViewController {
         return row
     }
 
+    private func settingsSpeechLanguageRow(title: String, isPrimary: Bool) -> NSView {
+        let row = NSView()
+        row.translatesAutoresizingMaskIntoConstraints = false
+        row.heightAnchor.constraint(equalToConstant: 62).isActive = true
+        row.wantsLayer = true
+        row.layer?.cornerRadius = 10
+        row.layer?.backgroundColor = NSColor.clear.cgColor
+
+        let titleLabel = NSTextField(labelWithString: title)
+        titleLabel.translatesAutoresizingMaskIntoConstraints = false
+        titleLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
+        titleLabel.textColor = palette.textPrimary
+
+        let popup = NSPopUpButton(frame: .zero, pullsDown: false)
+        popup.translatesAutoresizingMaskIntoConstraints = false
+        popup.target = self
+        popup.action = #selector(speechLanguageChanged(_:))
+
+        if isPrimary {
+            for option in speechLocaleOptions {
+                popup.addItem(withTitle: option.displayName)
+                popup.lastItem?.representedObject = option.identifier
+            }
+            let preferred = selectedPrimaryLanguageIdentifier ?? speechLocaleOptions.first?.identifier
+            if let preferred {
+                selectLocale(identifier: preferred, in: popup)
+            }
+            primaryLanguagePopup = popup
+        } else {
+            popup.addItem(withTitle: "None")
+            popup.lastItem?.representedObject = ""
+            for option in speechLocaleOptions {
+                popup.addItem(withTitle: option.displayName)
+                popup.lastItem?.representedObject = option.identifier
+            }
+            if let secondary = selectedSecondaryLanguageIdentifier,
+               secondary.isEmpty == false {
+                selectLocale(identifier: secondary, in: popup)
+            } else {
+                popup.selectItem(at: 0)
+            }
+            secondaryLanguagePopup = popup
+        }
+
+        row.addSubview(titleLabel)
+        row.addSubview(popup)
+        NSLayoutConstraint.activate([
+            titleLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 4),
+            titleLabel.topAnchor.constraint(equalTo: row.topAnchor, constant: 6),
+            titleLabel.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -4),
+
+            popup.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 4),
+            popup.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -2),
+            popup.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 6),
+            popup.heightAnchor.constraint(equalToConstant: 28)
+        ])
+        return row
+    }
+
+    private func selectLocale(identifier: String, in popup: NSPopUpButton) {
+        for item in popup.itemArray {
+            if let represented = item.representedObject as? String, represented == identifier {
+                popup.select(item)
+                return
+            }
+        }
+    }
+
+    @objc private func speechLanguageChanged(_ sender: NSPopUpButton) {
+        guard let primary = primaryLanguagePopup?.selectedItem?.representedObject as? String,
+              primary.isEmpty == false else { return }
+
+        var normalizedSecondary: String? = secondaryLanguagePopup?.selectedItem?.representedObject as? String
+        if normalizedSecondary?.isEmpty == true {
+            normalizedSecondary = nil
+        }
+        if let secondaryValue = normalizedSecondary,
+           secondaryValue.replacingOccurrences(of: "_", with: "-").lowercased() == primary.replacingOccurrences(of: "_", with: "-").lowercased() {
+            normalizedSecondary = nil
+            secondaryLanguagePopup?.selectItem(at: 0)
+        }
+        onUpdatePreferredSpeechLanguages(primary, normalizedSecondary)
+    }
+
     @objc private func darkModeToggled(_ sender: NSSwitch) {
         onToggleDarkMode(sender.state == .on)
     }