diff --git a/CHANGELOG.md b/CHANGELOG.md index 514d1ff..860a997 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. ## [Unreleased] +- Smart trailing punctuation: when a dictation is just an email address, URL, number, or single word, the sentence-final period the model adds is stripped so the text pastes clean. Default-on toggle in Settings → General → Transcript Cleanup. - Recorder pill position: new setting under General lets the user pin the floating recorder to any of the 9 on-screen positions (4 corners, 3 mid-edges, top/bottom center). Default is bottom center. ## [1.3.0] - 2026-07-10 diff --git a/speaktype/Services/Transcription/TranscriptionManager.swift b/speaktype/Services/Transcription/TranscriptionManager.swift index 80bb9c0..6d6904f 100644 --- a/speaktype/Services/Transcription/TranscriptionManager.swift +++ b/speaktype/Services/Transcription/TranscriptionManager.swift @@ -92,11 +92,13 @@ class TranscriptionManager { /// /// The raw engine output is passed through the user's dictionary rules so /// custom replacements and spoken snippets apply uniformly regardless of - /// which backend produced the text. + /// which backend produced the text. Smart trailing punctuation runs last, + /// after snippets have expanded, so a dictation that resolves to an email, + /// URL, number, or single token loses the model's sentence-final period. func transcribe(audioFile: URL, language: String = "auto") async throws -> String { let kind = AIModel.engineKind(for: currentModelVariant) let text = try await engine(for: kind).transcribe(audioFile: audioFile, language: language) - return DictionaryService.apply(to: text) + return SmartTrailingPunctuation.apply(to: DictionaryService.apply(to: text)) } } diff --git a/speaktype/Utilities/SmartTrailingPunctuation.swift b/speaktype/Utilities/SmartTrailingPunctuation.swift new file mode 100644 index 0000000..8221a78 --- /dev/null +++ b/speaktype/Utilities/SmartTrailingPunctuation.swift @@ -0,0 +1,79 @@ +import Foundation + +/// Removes the sentence-final period speech models append when the dictated +/// content isn't prose — an email address, URL, number, or lone token. +/// +/// Whisper (and Parakeet) punctuate everything as a sentence, so dictating an +/// email into a login field yields `roy@example.com.` and the trailing dot +/// breaks the address. This pass runs on the final transcript, after +/// dictionary replacements, so spoken snippets that expand to an address +/// benefit too. +/// +/// The heuristic is deliberately conservative: it only ever touches a +/// transcript that ends in exactly one `.` and is, in its entirety, one of +/// the recognized shapes. Multi-word dictation, ellipses, `?`/`!`, and +/// dotted abbreviations like `U.S.` are never modified. +enum SmartTrailingPunctuation { + static let defaultsKey = "smartTrailingPunctuation" + + /// Default-on: an absent key counts as enabled. + static var isEnabled: Bool { + UserDefaults.standard.object(forKey: defaultsKey) as? Bool ?? true + } + + /// Apply the heuristic if the user hasn't turned the toggle off. + static func apply(to text: String) -> String { + isEnabled ? strip(text) : text + } + + /// The toggle-independent heuristic. Returns `text` unchanged unless the + /// whole (trimmed) transcript is an email/URL/number/single token with a + /// single trailing period. + static func strip(_ text: String) -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + // Only a lone trailing period qualifies — "..", "…", "?", "!" are + // deliberate punctuation and stay. + guard trimmed.hasSuffix("."), !trimmed.hasSuffix("..") else { return text } + + let candidate = String(trimmed.dropLast()) + guard !candidate.isEmpty else { return text } + + if isNumber(candidate) || isEmail(candidate) || isURL(candidate) + || isPlainToken(candidate) { + return candidate + } + return text + } + + // MARK: - Content shapes + + /// Digits with common separators: "3.14", "1,000", "1.2.3", + /// "+49 170 1234567", "(555) 123-4567", "12:30", "01/02/2026". + private static func isNumber(_ text: String) -> Bool { + matches(text, #"^\+?\(?\d(?:[\d\s.,\-()/:]*\d)?$"#) + } + + private static func isEmail(_ text: String) -> Bool { + matches(text, #"^[^\s@]+@[^\s@]+\.[^\s@]+$"#) + } + + /// Explicit scheme, www-prefixed, or bare domain with a plausible TLD + /// (final label ≥ 2 letters, so "U.S" doesn't read as a domain). + private static func isURL(_ text: String) -> Bool { + matches( + text, + #"^(?:[a-z][a-z0-9+.\-]*://\S+|www\.\S+\.\S+|[a-z0-9\-]+(?:\.[a-z0-9\-]+)*\.[a-z]{2,}(?:[/:?#]\S*)?)$"# + ) + } + + /// A lone token with no internal dots — "Hello", "ok", "ABC123", "€1,000". + /// Tokens with internal dots must qualify as email/URL/number instead, so + /// dotted abbreviations keep their final period. + private static func isPlainToken(_ text: String) -> Bool { + !text.contains(where: \.isWhitespace) && !text.contains(".") + } + + private static func matches(_ text: String, _ pattern: String) -> Bool { + text.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil + } +} diff --git a/speaktype/Views/Screens/Settings/SettingsView.swift b/speaktype/Views/Screens/Settings/SettingsView.swift index f269e8b..e5630b9 100644 --- a/speaktype/Views/Screens/Settings/SettingsView.swift +++ b/speaktype/Views/Screens/Settings/SettingsView.swift @@ -97,6 +97,8 @@ struct GeneralSettingsTab: View { @AppStorage("transcriptionLanguage") private var transcriptionLanguage: String = "auto" @AppStorage("recentTranscriptionLanguages") private var recentLanguagesString: String = "" @AppStorage("enableAutoEdit") private var enableAutoEdit: Bool = false + @AppStorage(SmartTrailingPunctuation.defaultsKey) + private var smartTrailingPunctuation: Bool = true private var recentLanguageCodes: [String] { recentLanguagesString.split(separator: ",").map(String.init).filter { !$0.isEmpty } @@ -321,6 +323,23 @@ struct GeneralSettingsTab: View { Divider() + HStack { + Text("Smart trailing punctuation") + .font(Typography.bodyMedium) + .foregroundStyle(Color.textPrimary) + Spacer() + Toggle("", isOn: $smartTrailingPunctuation) + .labelsHidden() + } + + Text( + "When a dictation is just an email address, URL, number, or single word, the sentence-final period the model adds is removed so the text pastes clean. Full sentences are never touched." + ) + .font(Typography.captionSmall) + .foregroundStyle(Color.textMuted) + + Divider() + HStack(alignment: .top, spacing: 10) { Image(systemName: "character.book.closed") .font(.system(size: 13)) diff --git a/speaktypeTests/SmartTrailingPunctuationTests.swift b/speaktypeTests/SmartTrailingPunctuationTests.swift new file mode 100644 index 0000000..62e7856 --- /dev/null +++ b/speaktypeTests/SmartTrailingPunctuationTests.swift @@ -0,0 +1,164 @@ +import XCTest + +@testable import speaktype + +final class SmartTrailingPunctuationTests: XCTestCase { + + // MARK: - Shapes that lose the trailing period + + func testStripsPeriodAfterEmail() { + XCTAssertEqual( + SmartTrailingPunctuation.strip("roy.sanhik@gmail.com."), + "roy.sanhik@gmail.com") + } + + func testStripsPeriodAfterURLWithScheme() { + XCTAssertEqual( + SmartTrailingPunctuation.strip("https://example.com/pricing."), + "https://example.com/pricing") + } + + func testStripsPeriodAfterWWWURL() { + XCTAssertEqual( + SmartTrailingPunctuation.strip("www.example.com."), "www.example.com") + } + + func testStripsPeriodAfterBareDomain() { + XCTAssertEqual(SmartTrailingPunctuation.strip("example.com."), "example.com") + } + + func testStripsPeriodAfterDomainWithPath() { + XCTAssertEqual( + SmartTrailingPunctuation.strip("example.com/docs/setup."), + "example.com/docs/setup") + } + + func testStripsPeriodAfterDecimalNumber() { + XCTAssertEqual(SmartTrailingPunctuation.strip("3.14."), "3.14") + } + + func testStripsPeriodAfterThousandsNumber() { + XCTAssertEqual(SmartTrailingPunctuation.strip("1,000."), "1,000") + } + + func testStripsPeriodAfterVersionNumber() { + XCTAssertEqual(SmartTrailingPunctuation.strip("1.2.3."), "1.2.3") + } + + func testStripsPeriodAfterPhoneNumberWithSpaces() { + XCTAssertEqual( + SmartTrailingPunctuation.strip("+49 170 1234567."), "+49 170 1234567") + } + + func testStripsPeriodAfterFormattedPhoneNumber() { + XCTAssertEqual( + SmartTrailingPunctuation.strip("(555) 123-4567."), "(555) 123-4567") + } + + func testStripsPeriodAfterSingleDigit() { + XCTAssertEqual(SmartTrailingPunctuation.strip("5."), "5") + } + + func testStripsPeriodAfterSingleWord() { + XCTAssertEqual(SmartTrailingPunctuation.strip("Hello."), "Hello") + } + + func testStripsPeriodAfterAlphanumericToken() { + XCTAssertEqual(SmartTrailingPunctuation.strip("ABC123."), "ABC123") + } + + func testStripsPeriodAfterCurrencyToken() { + XCTAssertEqual(SmartTrailingPunctuation.strip("€1,000."), "€1,000") + } + + func testStripsSurroundingWhitespaceWhenStripping() { + XCTAssertEqual( + SmartTrailingPunctuation.strip(" roy.sanhik@gmail.com. "), + "roy.sanhik@gmail.com") + } + + // MARK: - Shapes that keep their punctuation + + func testKeepsPeriodOnSentence() { + let sentence = "This is a normal sentence." + XCTAssertEqual(SmartTrailingPunctuation.strip(sentence), sentence) + } + + func testKeepsPeriodOnSentenceEndingWithEmail() { + let sentence = "Email me at roy@gmail.com." + XCTAssertEqual(SmartTrailingPunctuation.strip(sentence), sentence) + } + + func testKeepsEllipsis() { + XCTAssertEqual(SmartTrailingPunctuation.strip("Wait..."), "Wait...") + } + + func testKeepsUnicodeEllipsis() { + XCTAssertEqual(SmartTrailingPunctuation.strip("Wait…"), "Wait…") + } + + func testKeepsDottedAbbreviation() { + XCTAssertEqual(SmartTrailingPunctuation.strip("U.S."), "U.S.") + } + + func testKeepsQuestionAndExclamation() { + XCTAssertEqual(SmartTrailingPunctuation.strip("Really?"), "Really?") + XCTAssertEqual(SmartTrailingPunctuation.strip("Stop!"), "Stop!") + } + + func testKeepsLonePeriod() { + XCTAssertEqual(SmartTrailingPunctuation.strip("."), ".") + } + + func testKeepsEmptyString() { + XCTAssertEqual(SmartTrailingPunctuation.strip(""), "") + } + + func testKeepsTokenWithoutTrailingPeriod() { + XCTAssertEqual(SmartTrailingPunctuation.strip("roy@gmail.com"), "roy@gmail.com") + } + + // MARK: - Toggle behavior + + func testApplyDefaultsToEnabledWhenKeyIsAbsent() { + withDefaultsValue(nil) { + XCTAssertTrue(SmartTrailingPunctuation.isEnabled) + XCTAssertEqual(SmartTrailingPunctuation.apply(to: "Hello."), "Hello") + } + } + + func testApplyIsANoOpWhenDisabled() { + withDefaultsValue(false) { + XCTAssertEqual(SmartTrailingPunctuation.apply(to: "Hello."), "Hello.") + } + } + + func testApplyStripsWhenExplicitlyEnabled() { + withDefaultsValue(true) { + XCTAssertEqual( + SmartTrailingPunctuation.apply(to: "example.com."), "example.com") + } + } + + /// Run `body` with the toggle key set to `value` (nil removes it), + /// restoring whatever was stored before. + private func withDefaultsValue(_ value: Bool?, body: () -> Void) { + let defaults = UserDefaults.standard + let key = SmartTrailingPunctuation.defaultsKey + let previous = defaults.object(forKey: key) + defer { + if let previous { + defaults.set(previous, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + + if let value { + defaults.set(value, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + body() + } +}