Skip to content
Closed
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
14 changes: 5 additions & 9 deletions Talkify/Dictation/DirectDictationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -676,18 +676,14 @@ final class DirectDictationController {
let hasVisibleText = !displayText
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty
// Keep the committed/volatile split the HUD uses, and rewrite each
// half so a pair like Hetty→Hedy shows in the live draft, not only
// in the paste. Insertion still applies to the full spoken string.
let replacements = currentSessionSettings?.spellingReplacements ?? []
pendingLiveText = SpellingReplacements.apply(
update.finalizedText,
using: replacements
)
pendingVolatileText = SpellingReplacements.apply(
update.volatileText,
let draft = SpellingReplacements.apply(
committed: update.finalizedText,
volatile: update.volatileText,
using: replacements
)
pendingLiveText = draft.committed
pendingVolatileText = draft.volatile
send(.updateReceived(hasVisibleText: hasVisibleText))
pendingLiveText = nil
pendingVolatileText = ""
Expand Down
2 changes: 1 addition & 1 deletion Talkify/Dictation/HUD/DictationHUDController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ final class DictationHUDController {
}

func showLiveText(_ committed: String, volatile: String = "") {
guard isListening, !(committed + volatile).isEmpty else { return }
guard isListening else { return }
setDraft(committed, volatile: volatile)
}

Expand Down
1 change: 1 addition & 0 deletions Talkify/Dictation/HUD/DictationHUDShellView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ struct DictationHUDShellView: View {
volatile: content.volatileText,
scale: metrics.scale
)
.equatable()
.padding(.horizontal, tagInset * metrics.scale)
}
}
Expand Down
9 changes: 7 additions & 2 deletions Talkify/Dictation/HUD/HUDRecentDraftText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,13 +156,18 @@ private struct HUDRecentDraftLineLayout: Layout {
/// Edge Glow + Draft's live draft: one 24-point line of recent words,
/// centered in the island when it fits. Each word keeps a stable id, so
/// only a new or departing token slides; a finalization rewrites in place.
struct HUDRecentDraftText: View {
struct HUDRecentDraftText: View, Equatable {
static let pointSize: CGFloat = 24

let committed: String
let volatile: String
var scale: CGFloat = 1

// Audio-level redraws must not tokenize an unchanged transcript again.
nonisolated static func == (lhs: Self, rhs: Self) -> Bool {
lhs.committed == rhs.committed && lhs.volatile == rhs.volatile && lhs.scale == rhs.scale
}

@State private var lastWindow: HUDRecentDraftWindow?

private var window: HUDRecentDraftWindow {
Expand All @@ -187,7 +192,7 @@ struct HUDRecentDraftText: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.clipped()
.animation(animate ? .easeOut(duration: 0.14) : nil, value: window.tokenIDs)
.animation(animate ? .easeOut(duration: 0.10) : nil, value: window.tokenIDs)
.onChange(of: window) { _, new in
lastWindow = new
}
Expand Down
32 changes: 26 additions & 6 deletions Talkify/Dictation/SpellingReplacement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,35 +29,55 @@ enum SpellingReplacements {
/// "e-mail" are what the recognizer produces as often as a single token
/// is.
static func apply(_ text: String, using pairs: [SpellingReplacement]) -> String {
apply(committed: text, volatile: "", using: pairs).committed
}

/// Match across the recognition boundary too. A replacement touching a
/// provisional word stays provisional until the whole match commits.
static func apply(
committed: String,
volatile: String,
using pairs: [SpellingReplacement]
) -> (committed: String, volatile: String) {
let text = committed + volatile
let active = pairs.compactMap { pair -> Pair? in
let from = pair.from.trimmingCharacters(in: .whitespacesAndNewlines)
let to = pair.to.trimmingCharacters(in: .whitespacesAndNewlines)
guard !from.isEmpty, !to.isEmpty else { return nil }
return Pair(from: from, to: to, length: from.count)
}
guard !active.isEmpty, !text.isEmpty else { return text }
guard !active.isEmpty, !text.isEmpty else { return (committed, volatile) }

var result = ""
result.reserveCapacity(text.count)
var finalized = ""
var provisional = ""
var committedBytesRemaining = committed.utf8.count
var index = text.startIndex
var previous: Character?
while index < text.endIndex {
let start = index
let replacement: String
// A match can only open where a word does, which is also what keeps
// "mm" out of the middle of "comment".
if !isWordCharacter(previous),
let match = longestMatch(in: text, at: index, using: active) {
result += match.to
replacement = match.to
// The boundary the next position sees is the text's, not the
// replacement's: what was matched stays matched.
previous = text[text.index(before: match.end)]
index = match.end
} else {
previous = text[index]
result.append(text[index])
replacement = String(text[index])
index = text.index(after: index)
}
committedBytesRemaining -= text[start..<index].utf8.count
if committedBytesRemaining >= 0 {
finalized += replacement
} else {
provisional += replacement
}
}
return result
return (finalized, provisional)
}

private struct Pair {
Expand Down
12 changes: 12 additions & 0 deletions TalkifyTests/HUDPlaceholderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,18 @@ struct HUDPlaceholderTests {
#expect(hud.textForTesting == "hello there")
}

@Test func withdrawingTheLastGuessClearsTheDraft() {
let store = AppSettings.previewStore()
store.voiceVisual = .glowDraft
let hud = DictationHUDController(stage: HUDStage(settings: store), settings: store)
hud.showListening(on: nil, isLatched: false, settings: session(store))
hud.showLiveText("", volatile: "withdrawn guess")
#expect(hud.textForTesting == "withdrawn guess")
hud.showLiveText("", volatile: "")
#expect(hud.textForTesting.isEmpty)
hud.hide()
}

@Test func aVolatileGuessShowsBeforeItCommits() {
let store = AppSettings.previewStore()
store.voiceVisual = .glowDraft
Expand Down
27 changes: 27 additions & 0 deletions TalkifyTests/SpellingReplacementTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,33 @@ import Testing
@testable import Talkify

struct SpellingReplacementTests {
@Test func liveReplacementMatchesTheWholeDraftAtEveryBoundary() {
let text = "open ex code today"
let pairs = [SpellingReplacement(id: "1", from: "ex code", to: "Xcode")]
for boundary in Array(text.indices) + [text.endIndex] {
let result = SpellingReplacements.apply(
committed: String(text[..<boundary]),
volatile: String(text[boundary...]),
using: pairs
)
#expect(result.committed + result.volatile == "open Xcode today")
}
}

@Test func aReplacementCrossingTheBoundaryStaysProvisional() {
let pairs = [SpellingReplacement(id: "1", from: "ex code", to: "Xcode")]
let result = SpellingReplacements.apply(committed: "open ex ", volatile: "code", using: pairs)
#expect(result.committed == "open ")
#expect(result.volatile == "Xcode")
}

@Test func aFinalizedReplacementKeepsItsStylingAfterItsLengthChanges() {
let pairs = [SpellingReplacement(id: "1", from: "ex code", to: "Xcode")]
let result = SpellingReplacements.apply(committed: "open ex code", volatile: " today", using: pairs)
#expect(result.committed == "open Xcode")
#expect(result.volatile == " today")
}

@Test func emptyListLeavesTheTextAlone() {
#expect(SpellingReplacements.apply("Calman shipped", using: []) == "Calman shipped")
}
Expand Down