Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions speaktype/Services/Transcription/TranscriptionManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
}

Expand Down
79 changes: 79 additions & 0 deletions speaktype/Utilities/SmartTrailingPunctuation.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
19 changes: 19 additions & 0 deletions speaktype/Views/Screens/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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))
Expand Down
164 changes: 164 additions & 0 deletions speaktypeTests/SmartTrailingPunctuationTests.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
Loading