diff --git a/.gitignore b/.gitignore index 28dd55b..546269e 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ .vscode/ docs/superpowers/ .worktrees/ +REBUILD diff --git a/README.md b/README.md index 12d270a..12a069b 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,16 @@ swift build -c release .build/release/open-wispr start ``` +## Troubleshooting + +**App won't open on macOS 26 (Tahoe) or later** + +macOS 26 blocks ad-hoc signed apps launched from Finder, Raycast, or Spotlight with no error shown. open-wispr is designed to run as a background service — use `brew services` instead of double-clicking the app: + +```bash +brew services start open-wispr # start now and on every login +``` + ## Support open-wispr is free and always will be. If you find it useful, you can [leave a tip](https://buy.stripe.com/4gM5kC2AU0Ssd4l6Hqd7q00). diff --git a/Sources/OpenWisprLib/AppDelegate.swift b/Sources/OpenWisprLib/AppDelegate.swift index 62d889b..f474b93 100644 --- a/Sources/OpenWisprLib/AppDelegate.swift +++ b/Sources/OpenWisprLib/AppDelegate.swift @@ -2,6 +2,7 @@ import AppKit public class AppDelegate: NSObject, NSApplicationDelegate { var statusBar: StatusBarController! + var pill: PillOverlay! var hotkeyManagers: [HotkeyManager] = [] var recorder: AudioRecorder! var transcriber: Transcriber! @@ -10,9 +11,11 @@ public class AppDelegate: NSObject, NSApplicationDelegate { var isPressed = false var isReady = false public var lastTranscription: String? + private var recordingStartTime: Date? public func applicationDidFinishLaunching(_ notification: Notification) { statusBar = StatusBarController() + pill = PillOverlay() recorder = AudioRecorder() DispatchQueue.global(qos: .userInitiated).async { [weak self] in @@ -258,7 +261,16 @@ public class AppDelegate: NSObject, NSApplicationDelegate { private func handleRecordingStart() { guard !isPressed else { return } isPressed = true + statusBar.liveLevel = 0 + pill.level = 0 + recorder.onLevelUpdate = { [weak self] level in + self?.statusBar.liveLevel = level + self?.pill.level = level + } + recordingStartTime = Date() statusBar.state = .recording + pill.state = .recording + if config.showOverlay?.value ?? false { pill.show() } do { let outputURL: URL if Config.effectiveMaxRecordings(config.maxRecordings) == 0 { @@ -270,20 +282,42 @@ public class AppDelegate: NSObject, NSApplicationDelegate { } catch { print("Error: \(error.localizedDescription)") isPressed = false + pill.hide() statusBar.state = .idle } } + // Whisper needs at least ~0.5 s of audio to produce meaningful output. + // Taps shorter than this are treated as accidental presses: the pill is + // dismissed immediately and no transcription job is started. + private static let minimumRecordingSeconds: TimeInterval = 0.5 + private func handleRecordingStop() { guard isPressed else { return } isPressed = false + recorder.onLevelUpdate = nil + + let elapsed = recordingStartTime.map { Date().timeIntervalSince($0) } ?? 1 + recordingStartTime = nil guard let audioURL = recorder.stopRecording() else { + pill.hide() statusBar.state = .idle return } + // Tap too short to contain real speech — skip Whisper entirely. + if elapsed < AppDelegate.minimumRecordingSeconds { + pill.hide() + statusBar.state = .idle + if Config.effectiveMaxRecordings(config.maxRecordings) == 0 { + try? FileManager.default.removeItem(at: audioURL) + } + return + } + statusBar.state = .transcribing + pill.state = .transcribing DispatchQueue.global(qos: .userInitiated).async { [weak self] in guard let self = self else { return } @@ -295,15 +329,24 @@ public class AppDelegate: NSObject, NSApplicationDelegate { } do { let raw = try self.transcriber.transcribe(audioURL: audioURL) - let text = (self.config.spokenPunctuation?.value ?? false) ? TextPostProcessor.process(raw) : raw + let spokenPunctuation = self.config.spokenPunctuation?.value ?? false + let text = spokenPunctuation ? TextPostProcessor.process(raw) : raw if maxRecordings > 0 { RecordingStore.prune(maxCount: maxRecordings) } DispatchQueue.main.async { if !text.isEmpty { - self.lastTranscription = text - self.inserter.insert(text: text) + let polished = TextPolisher.polish( + text, + voiceCommands: spokenPunctuation, + removeFillers: self.config.removeFillers?.value ?? false + ) + self.lastTranscription = polished + self.inserter.insert(text: polished) } + // Only hide the pill if the user hasn't already started a new + // recording — otherwise we'd close the pill mid-dictation. + if !self.isPressed { self.pill.hide() } self.statusBar.state = .idle self.statusBar.buildMenu() } @@ -313,6 +356,7 @@ public class AppDelegate: NSObject, NSApplicationDelegate { } DispatchQueue.main.async { print("Error: \(error.localizedDescription)") + if !self.isPressed { self.pill.hide() } self.statusBar.state = .error(error.localizedDescription) self.statusBar.buildMenu() DispatchQueue.main.asyncAfter(deadline: .now() + 5) { diff --git a/Sources/OpenWisprLib/AudioRecorder.swift b/Sources/OpenWisprLib/AudioRecorder.swift index bff4ea2..3cfb236 100644 --- a/Sources/OpenWisprLib/AudioRecorder.swift +++ b/Sources/OpenWisprLib/AudioRecorder.swift @@ -8,6 +8,12 @@ class AudioRecorder { private var currentOutputURL: URL? var preferredDeviceID: AudioDeviceID? + /// Called on the audio thread with a smoothed 0–1 RMS level each buffer. + var onLevelUpdate: ((Float) -> Void)? + // smoothedLevel is written on the audio thread and read nowhere outside this + // class — safe. Callers receive values only via the onLevelUpdate callback. + private var smoothedLevel: Float = 0 + func prewarm() { guard audioEngine == nil else { return } @@ -78,7 +84,7 @@ class AudioRecorder { let file = try AVAudioFile(forWriting: outputURL, settings: settings) let converter = AVAudioConverter(from: inputFmt, to: recordingFormat) - engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFmt) { buffer, _ in + engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: inputFmt) { [weak self] buffer, _ in guard let converter = converter else { return } let convertedBuffer = AVAudioPCMBuffer( @@ -97,6 +103,30 @@ class AudioRecorder { if error == nil && convertedBuffer.frameLength > 0 { try? file.write(from: convertedBuffer) } + + // Compute RMS from the raw input buffer and report a smoothed level. + if let self, let cb = self.onLevelUpdate, + let ch = buffer.floatChannelData { + let n = Int(buffer.frameLength) + var sum: Float = 0 + let ptr = ch[0] + let step = max(1, n / 256) + var count = 0 + var i = 0 + while i < n { let v = ptr[i]; sum += v * v; count += 1; i += step } + let rms = count > 0 ? sqrtf(sum / Float(count)) : 0 + // Scale factor: typical conversational speech produces RMS ~0.02–0.06 + // on a 0–1 float PCM scale. Multiplying by 28 maps the mid-range of + // normal speech (RMS ≈ 0.035) to roughly 1.0 so the bars read full + // during active dictation and drop to near-zero in silence. + let norm = min(1.0, rms * 28) + // Asymmetric smoothing: fast attack (0.75), slow release (0.18). + // Bars jump up instantly when you speak and decay gradually — + // the same envelope shape used in broadcast audio meters. + let alpha: Float = norm > self.smoothedLevel ? 0.75 : 0.18 + self.smoothedLevel = self.smoothedLevel * (1 - alpha) + norm * alpha + cb(self.smoothedLevel) + } } currentOutputURL = outputURL diff --git a/Sources/OpenWisprLib/Config.swift b/Sources/OpenWisprLib/Config.swift index 9bdc7ce..8c87c81 100644 --- a/Sources/OpenWisprLib/Config.swift +++ b/Sources/OpenWisprLib/Config.swift @@ -11,8 +11,15 @@ public struct Config: Codable { public var modelSize: String public var language: String public var spokenPunctuation: FlexBool? + /// When true, TextPolisher strips disfluencies/fillers (um, uh, like, …). + /// Off by default: the filler list includes ordinary English words, so + /// removal can only be opted into, never applied silently. + public var removeFillers: FlexBool? public var maxRecordings: Int? public var toggleMode: FlexBool? + /// When true, a floating pill near the cursor shows "Listening"/"Transcribing". + /// Off by default; toggle via the "Show Overlay" menu item. + public var showOverlay: FlexBool? public var audioInputDeviceID: UInt32? public var audioInputDeviceUID: String? @@ -42,8 +49,10 @@ public struct Config: Codable { case modelSize case language case spokenPunctuation + case removeFillers case maxRecordings case toggleMode + case showOverlay case audioInputDeviceID case audioInputDeviceUID } @@ -63,8 +72,10 @@ public struct Config: Codable { self.modelSize = try c.decode(String.self, forKey: .modelSize) self.language = try c.decode(String.self, forKey: .language) self.spokenPunctuation = try c.decodeIfPresent(FlexBool.self, forKey: .spokenPunctuation) + self.removeFillers = try c.decodeIfPresent(FlexBool.self, forKey: .removeFillers) self.maxRecordings = try c.decodeIfPresent(Int.self, forKey: .maxRecordings) self.toggleMode = try c.decodeIfPresent(FlexBool.self, forKey: .toggleMode) + self.showOverlay = try c.decodeIfPresent(FlexBool.self, forKey: .showOverlay) self.audioInputDeviceID = try c.decodeIfPresent(UInt32.self, forKey: .audioInputDeviceID) self.audioInputDeviceUID = try c.decodeIfPresent(String.self, forKey: .audioInputDeviceUID) } @@ -77,8 +88,10 @@ public struct Config: Codable { try c.encode(modelSize, forKey: .modelSize) try c.encode(language, forKey: .language) try c.encodeIfPresent(spokenPunctuation, forKey: .spokenPunctuation) + try c.encodeIfPresent(removeFillers, forKey: .removeFillers) try c.encodeIfPresent(maxRecordings, forKey: .maxRecordings) try c.encodeIfPresent(toggleMode, forKey: .toggleMode) + try c.encodeIfPresent(showOverlay, forKey: .showOverlay) try c.encodeIfPresent(audioInputDeviceID, forKey: .audioInputDeviceID) try c.encodeIfPresent(audioInputDeviceUID, forKey: .audioInputDeviceUID) } @@ -91,6 +104,8 @@ public struct Config: Codable { spokenPunctuation: FlexBool?, maxRecordings: Int?, toggleMode: FlexBool?, + removeFillers: FlexBool? = nil, + showOverlay: FlexBool? = nil, audioInputDeviceID: UInt32? = nil, audioInputDeviceUID: String? = nil ) { @@ -101,8 +116,10 @@ public struct Config: Codable { self.modelSize = modelSize self.language = language self.spokenPunctuation = spokenPunctuation + self.removeFillers = removeFillers self.maxRecordings = maxRecordings self.toggleMode = toggleMode + self.showOverlay = showOverlay self.audioInputDeviceID = audioInputDeviceID self.audioInputDeviceUID = audioInputDeviceUID } @@ -250,7 +267,9 @@ public struct Config: Codable { language: "en", spokenPunctuation: FlexBool(false), maxRecordings: nil, - toggleMode: FlexBool(false) + toggleMode: FlexBool(false), + removeFillers: FlexBool(false), + showOverlay: FlexBool(false) ) public static var configDir: URL { diff --git a/Sources/OpenWisprLib/PillOverlay.swift b/Sources/OpenWisprLib/PillOverlay.swift new file mode 100644 index 0000000..b39a4b1 --- /dev/null +++ b/Sources/OpenWisprLib/PillOverlay.swift @@ -0,0 +1,181 @@ +import AppKit + +/// A small dark floating pill that appears near the cursor while recording or +/// transcribing. Gives immediate visual confirmation that the app is listening. +/// +/// Show/hide from the main thread only. Thread-safe level updates via `level`. +public class PillOverlay { + + // MARK: - Public + + public enum PillState { + case recording + case transcribing + case hidden + } + + public var state: PillState = .hidden { + didSet { updateAppearance() } + } + + /// Live mic level 0–1. Written from the audio thread, read on the main + /// thread by the animation timer. On arm64, aligned 32-bit stores are + /// atomic at the hardware level — benign race for a display-only value. + public var level: Float = 0 + + // MARK: - Private + + private let panel: NSPanel + private let label: NSTextField + private let waveView: WaveView + + private var animTimer: Timer? + + // MARK: - Init + + public init() { + let W: CGFloat = 200, H: CGFloat = 40 + + panel = NSPanel( + contentRect: NSRect(x: 0, y: 0, width: W, height: H), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.level = .statusBar + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.hidesOnDeactivate = false + panel.ignoresMouseEvents = true + panel.collectionBehavior = [.canJoinAllSpaces, .stationary] + + // Dark pill container + let container = PillBackground(frame: NSRect(x: 0, y: 0, width: W, height: H)) + panel.contentView = container + + // Wave bars (bottom strip) + waveView = WaveView(frame: NSRect(x: 12, y: 6, width: W - 24, height: 12)) + container.addSubview(waveView) + + // Status label (upper strip) + label = NSTextField(frame: NSRect(x: 12, y: H - 22, width: W - 24, height: 16)) + label.isBezeled = false + label.drawsBackground = false + label.isEditable = false + label.isSelectable = false + label.font = .systemFont(ofSize: 11, weight: .medium) + label.textColor = NSColor.white.withAlphaComponent(0.9) + label.stringValue = "Listening" + label.cell?.lineBreakMode = .byTruncatingTail + container.addSubview(label) + } + + // MARK: - Show / hide + + public func show() { + positionNearCursor() + panel.orderFrontRegardless() + startAnimating() + } + + public func hide() { + stopAnimating() + panel.orderOut(nil) + level = 0 + } + + // MARK: - Private helpers + + private func positionNearCursor() { + let cursor = NSEvent.mouseLocation // bottom-left origin, Quartz display space + // Use the screen that contains the cursor — not necessarily the main screen. + // Falling back to main then first handles edge cases (display sleep, mirroring). + guard let screen = NSScreen.screens.first(where: { NSMouseInRect(cursor, $0.frame, false) }) + ?? NSScreen.main + ?? NSScreen.screens.first + else { return } + let sf = screen.frame + let W = panel.frame.width, H = panel.frame.height + var x = cursor.x + 14 + var y = cursor.y - H - 14 + // clamp inside visible area + x = max(sf.minX + 8, min(x, sf.maxX - W - 8)) + y = max(sf.minY + 8, min(y, sf.maxY - H - 8)) + panel.setFrameOrigin(NSPoint(x: x, y: y)) + } + + private func updateAppearance() { + switch state { + case .recording: + label.stringValue = "Listening" + label.textColor = NSColor.white.withAlphaComponent(0.9) + case .transcribing: + label.stringValue = "Transcribing…" + label.textColor = NSColor.white.withAlphaComponent(0.6) + case .hidden: + break + } + } + + private func startAnimating() { + stopAnimating() + animTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in + guard let self else { return } + self.waveView.level = CGFloat(self.level) + self.waveView.needsDisplay = true + } + } + + private func stopAnimating() { + animTimer?.invalidate() + animTimer = nil + waveView.level = 0 + waveView.needsDisplay = true + } +} + +// MARK: - Dark pill background view + +private class PillBackground: NSView { + override func draw(_ dirtyRect: NSRect) { + let r = bounds.height / 2 + let path = NSBezierPath(roundedRect: bounds, xRadius: r, yRadius: r) + NSColor(white: 0.10, alpha: 0.95).setFill() + path.fill() + NSColor(white: 1.0, alpha: 0.15).setStroke() + path.lineWidth = 0.5 + path.stroke() + } + override var isOpaque: Bool { false } +} + +// MARK: - Wave bar view + +private class WaveView: NSView { + var level: CGFloat = 0 // 0–1, set by animation timer + + private let n = 10 + // Apple blue accent + private let accent = NSColor(calibratedRed: 0.0, green: 0.478, blue: 1.0, alpha: 1.0) + + override func draw(_ dirtyRect: NSRect) { + let bw: CGFloat = 3, gap: CGFloat = 2.5 + let total = CGFloat(n) * bw + CGFloat(n - 1) * gap + var x = (bounds.width - total) / 2 + let midY = bounds.midY + + for i in 0.. [NSImage] { - let count = waveFrameCount - let baseHeights: [CGFloat] = [4, 8, 12, 8, 4] - let minScale: CGFloat = 0.3 - let phaseOffsets: [Double] = [0.0, 0.15, 0.3, 0.45, 0.6] - - return (0.. NSImage { + let size = NSSize(width: 18, height: 18) + let image = NSImage(size: size, flipped: false) { rect in + NSColor.black.setFill() + let barWidth: CGFloat = 2.0 + let gap: CGFloat = 2.5 + let radius: CGFloat = 1.0 + let centerX = rect.midX + let centerY = rect.midY + // Idle min height 2px; at full level, bell-curve peaks at 16px (centre bar) + let maxHeights: [CGFloat] = [8, 12, 16, 12, 8] + let minH: CGFloat = 2 + let totalWidth = CGFloat(maxHeights.count) * barWidth + CGFloat(maxHeights.count - 1) * gap + let startX = centerX - totalWidth / 2 + for (i, maxH) in maxHeights.enumerated() { + let h = minH + level * (maxH - minH) + let x = startX + CGFloat(i) * (barWidth + gap) + let y = centerY - h / 2 + NSBezierPath(roundedRect: NSRect(x: x, y: y, width: barWidth, height: h), + xRadius: radius, yRadius: radius).fill() } - image.isTemplate = true - return image + return true } + image.isTemplate = true + return image } - private func startRecordingAnimation() { - animationFrame = 0 - animationFrames = StatusBarController.prerenderWaveFrames() - setIcon(animationFrames[0]) + private var lastRenderedLevel: CGFloat = -1 // –1 forces the first draw + private func startRecordingAnimation() { + liveLevel = 0 + lastRenderedLevel = -1 + setIcon(StatusBarController.drawWave(level: 0)) animationTimer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in guard let self = self else { return } - self.animationFrame = (self.animationFrame + 1) % StatusBarController.waveFrameCount - self.setIcon(self.animationFrames[self.animationFrame]) + let level = CGFloat(self.liveLevel) + // Skip the NSImage allocation when the level hasn't moved enough to + // produce a visible change (~1 px on the tallest bar ≈ 0.07 delta). + guard abs(level - self.lastRenderedLevel) > 0.015 else { return } + self.lastRenderedLevel = level + self.setIcon(StatusBarController.drawWave(level: level)) } } diff --git a/Sources/OpenWisprLib/TextPolisher.swift b/Sources/OpenWisprLib/TextPolisher.swift new file mode 100644 index 0000000..c200b38 --- /dev/null +++ b/Sources/OpenWisprLib/TextPolisher.swift @@ -0,0 +1,149 @@ +import Foundation + +/// Deterministic rule-based text polishing applied to the Whisper transcript +/// before it gets pasted. No AI, no network. +/// +/// Pipeline: +/// 1. Voice commands — "new line" → "\n" etc. (opt-in) +/// 2. Filler removal — um / uh / like / you know … (opt-in) +/// 3. Spacing — collapse double spaces, fix space-before-punctuation +/// 4. Capitalization — sentence starts + standalone "i" +/// +/// Steps 1 and 2 are off by default and gated by the caller. Voice commands +/// duplicate `TextPostProcessor` and must follow the same `spokenPunctuation` +/// gate, or "comma" gets substituted for users who turned that off. Filler +/// removal matches ordinary English words ("like", "actually", …) so it can +/// never run unconditionally. Spacing and capitalization are always safe. +public enum TextPolisher { + + // MARK: - Defaults + + static let voiceCommands: [(pattern: String, replacement: String)] = [ + ("new paragraph", "\n\n"), + ("new line", "\n"), + ("open paren", "("), + ("close paren", ")"), + ("open bracket", "["), + ("close bracket", "]"), + ("open brace", "{"), + ("close brace", "}"), + ("exclamation point", "!"), + ("question mark", "?"), + ("period", "."), + ("comma", ","), + ("colon", ":"), + ("semicolon", ";"), + ("ellipsis", "..."), + ("dash", "-"), + ("hyphen", "-"), + ] + + static let fillers: [String] = [ + "you know", "i mean", "sort of", "kind of", "i guess", "or whatever", + "you see", "um", "uh", "uhh", "umm", "erm", "ah", + "like", "basically", "literally", "actually", + ] + + // MARK: - Public entry point + + /// - Parameters: + /// - voiceCommands: substitute "comma" → "," etc. Pass the same value as + /// the `spokenPunctuation` setting so this can't bypass that gate. + /// - removeFillers: strip disfluencies/fillers. Off by default because the + /// list includes ordinary English words. + public static func polish( + _ text: String, + voiceCommands: Bool = false, + removeFillers: Bool = false + ) -> String { + var s = text + if voiceCommands { s = applyVoiceCommands(s) } + if removeFillers { s = removeFillerWords(s) } + s = fixSpacing(s) + s = fixCapitalization(s) + return s + } + + // MARK: - Steps + + private static func applyVoiceCommands(_ text: String) -> String { + var s = text + // Longest first so "new paragraph" wins over "new" + for cmd in voiceCommands.sorted(by: { $0.pattern.count > $1.pattern.count }) { + s = wholeWordReplace(s, find: cmd.pattern, replace: cmd.replacement) + } + return s + } + + private static func removeFillerWords(_ text: String) -> String { + var s = text + for filler in fillers.sorted(by: { $0.count > $1.count }) { + // whole word/phrase, optionally followed by a comma + let pat = "(?i)(? String { + var s = text + // Collapse multiple spaces/tabs + s = re(s, "[ \\t]+", " ") + // No space before close punctuation + s = re(s, "\\s+([,.;:!?\\)\\]])", "$1") + // No space after open brackets + s = re(s, "([\\(\\[]) +", "$1") + // Space after sentence punctuation, but only when preceded by 2+ word chars + // and followed by a capital/opening quote. Splits run-on sentences ("end.Next") + // without breaking URLs, emails, decimals, or abbreviations (github.com, e.g., U.S.). + s = re(s, "(?<=\\w\\w)([.!?])(?=[\"'A-Z])", "$1 ") + // Space after comma/colon/semicolon glued to a letter (protects http:// and decimals/times). + s = re(s, "([,;:])(?=[A-Za-z])", "$1 ") + // Tidy newlines + s = re(s, " *\\n *", "\n") + s = re(s, "\\n{3,}", "\n\n") + return s.trimmingCharacters(in: .whitespaces) + } + + private static func fixCapitalization(_ text: String) -> String { + var s = text + // Capitalize first letter after sentence-end or start of string. The + // sentence-end form requires 2+ word chars before the punctuation so + // abbreviations ("U.S. last") don't capitalize the following word. + if let re = try? NSRegularExpression(pattern: "(?:^|(?<=\\w\\w)[.!?]\\s+|\\n)([a-z])") { + var result = s + let matches = re.matches(in: s, range: NSRange(s.startIndex..., in: s)).reversed() + for match in matches { + guard let charRange = Range(match.range(at: 1), in: s) else { continue } + result.replaceSubrange(charRange, with: s[charRange].uppercased()) + } + s = result + } + // Standalone "i" → "I" + s = re(s, "(? String { + let pat = "(?i)(? String { + guard let re = try? NSRegularExpression(pattern: pattern) else { return text } + return re.stringByReplacingMatches( + in: text, range: NSRange(text.startIndex..., in: text), + withTemplate: replacement + ) + } +} diff --git a/Tests/OpenWisprTests/TextPolisherTests.swift b/Tests/OpenWisprTests/TextPolisherTests.swift new file mode 100644 index 0000000..c3d88a0 --- /dev/null +++ b/Tests/OpenWisprTests/TextPolisherTests.swift @@ -0,0 +1,104 @@ +import XCTest +@testable import OpenWisprLib + +final class TextPolisherTests: XCTestCase { + + // MARK: - Spacing must not corrupt URLs / emails / abbreviations + // Regression net for the bug where "github.com" became "github. Com". + + func testURLNotBroken() { + XCTAssertEqual(TextPolisher.polish("visit github.com today"), + "Visit github.com today") + } + + func testSchemeURLNotBroken() { + XCTAssertEqual(TextPolisher.polish("go to https://github.com now"), + "Go to https://github.com now") + } + + func testEmailNotBroken() { + XCTAssertEqual(TextPolisher.polish("email me at user@example.com please"), + "Email me at user@example.com please") + } + + func testLowercaseAbbreviationNotBroken() { + XCTAssertEqual(TextPolisher.polish("use e.g. a cat for this"), + "Use e.g. a cat for this") + } + + func testDottedAbbreviationNotBroken() { + XCTAssertEqual(TextPolisher.polish("the U.S. last year"), + "The U.S. last year") + } + + func testDecimalNotBroken() { + XCTAssertEqual(TextPolisher.polish("the price is 3.14 dollars"), + "The price is 3.14 dollars") + } + + func testTimeColonNotBroken() { + XCTAssertEqual(TextPolisher.polish("meet at 12:30 today"), + "Meet at 12:30 today") + } + + func testNumericCommaNotBroken() { + XCTAssertEqual(TextPolisher.polish("I have 1,000 apples"), + "I have 1,000 apples") + } + + // MARK: - Spacing still does its job + + func testRunOnSentenceSplit() { + XCTAssertEqual(TextPolisher.polish("I finished the report.Then I left"), + "I finished the report. Then I left") + } + + func testCommaGluedToLetterGetsSpace() { + XCTAssertEqual(TextPolisher.polish("I have apples,oranges,pears"), + "I have apples, oranges, pears") + } + + func testSpaceBeforePunctuationRemoved() { + XCTAssertEqual(TextPolisher.polish("hello world , how are you ?"), + "Hello world, how are you?") + } + + func testMultipleSpacesCollapsed() { + XCTAssertEqual(TextPolisher.polish("too many spaces"), + "Too many spaces") + } + + // MARK: - Capitalization + + func testSentenceStartCapitalized() { + XCTAssertEqual(TextPolisher.polish("hello world. this is fine"), + "Hello world. This is fine") + } + + func testStandaloneIUppercased() { + XCTAssertEqual(TextPolisher.polish("i think i can do it"), + "I think I can do it") + } + + // MARK: - Filler / voice-command flags are gated (off by default) + + func testFillersKeptByDefault() { + XCTAssertEqual(TextPolisher.polish("this is um basically done"), + "This is um basically done") + } + + func testFillersRemovedWhenEnabled() { + XCTAssertEqual(TextPolisher.polish("this is um basically done", removeFillers: true), + "This is done") + } + + func testVoiceCommandsOffByDefault() { + XCTAssertEqual(TextPolisher.polish("add a comma here"), + "Add a comma here") + } + + func testVoiceCommandsWhenEnabled() { + XCTAssertEqual(TextPolisher.polish("add a comma here", voiceCommands: true), + "Add a, here") + } +}