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
21 changes: 17 additions & 4 deletions Talkify/Dictation/HUD/DictationHUDController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ final class DictationHUDController {
private var sessionSettings: DictationSessionSettings
/// Remembered so a download line can restore the right session text.
private var sessionIsLatched = false
/// True from the moment speech ends until the next session opens. The
/// placeholder is a listening affordance, so nothing may write one after
/// this — a finished model download restoring "Listening (latched)" under
/// the shaping caption is the case that found it.
private var hasStoppedListening = false
private var lastLevelAt = ContinuousClock.now
private var micWatchdogTask: Task<Void, Never>?
private var hasPlayedBeginSound = false
Expand Down Expand Up @@ -72,6 +77,7 @@ final class DictationHUDController {
sessionSettings = settings
hasPlayedBeginSound = false
hasPlayedEndSound = false
hasStoppedListening = false
content.languageTag = languageTag
content.shapingName = nil
content.shapingChoiceLabel = nil
Expand All @@ -96,11 +102,16 @@ final class DictationHUDController {
/// What the band says before any words arrive.
///
/// Empty for Compact and Edge Glow + Draft: both are built around the live
/// draft, so a placeholder there is words nobody spoke. Every path that
/// would write one asks here, rather than each deciding for itself — which
/// is how "Listening (latched)" kept coming back after the opening text was
/// handled.
/// draft, so a placeholder there is words nobody spoke. Empty again once
/// the session stops listening, because the word is a lie by then.
///
/// Every path that would write one asks here, rather than each deciding for
/// itself — which is how "Listening (latched)" kept coming back after the
/// opening text was handled, and how it came back a second time after the
/// shaping phase cleared it: a one-shot clear only beats the writers that
/// ran before it.
private var placeholder: String {
guard !hasStoppedListening else { return "" }
guard !sessionSettings.voiceVisual.showsDraftWhileListening else { return "" }
return sessionIsLatched ? Self.latchedText : Self.listeningText
}
Expand All @@ -125,6 +136,7 @@ final class DictationHUDController {
/// settles to zero so the visual comes to rest instead of freezing mid-wobble.
func showFinalizing() {
guard isListening else { return }
hasStoppedListening = true
stopWatchdog()
content.isAudioAlive = true
content.audioLevel = 0
Expand All @@ -136,6 +148,7 @@ final class DictationHUDController {
/// hide() that follows the rewrite must not replay it.
func showShaping(with promptName: String) {
guard isListening else { return }
hasStoppedListening = true
stopVoiceVisual()
if !hasPlayedEndSound {
hasPlayedEndSound = true
Expand Down
37 changes: 37 additions & 0 deletions TalkifyTests/HUDPlaceholderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,43 @@ struct HUDPlaceholderTests {
#expect(hud.textForTesting.isEmpty)
}

/// The clear in showShaping only beats the writers that ran before it. A
/// model download finishing mid-session reports nil, which restored the
/// placeholder under the shaping caption — and the same hole is open to
/// every other path that asks for one.
@Test func nothingRestoresThePlaceholderAfterSpeechEnds() {
let store = AppSettings.previewStore()
store.voiceVisual = .waveform
let hud = DictationHUDController(stage: HUDStage(settings: store), settings: store)

hud.showListening(on: CGDirectDisplayID?.none, isLatched: true, settings: session(store))
hud.showLatched()
#expect(hud.textForTesting == "Listening (latched)")

hud.showFinalizing()
hud.showShaping(with: "Tighten grammar")
#expect(hud.textForTesting.isEmpty)

hud.showModelDownload(nil)
#expect(hud.textForTesting.isEmpty, "the placeholder came back after the session stopped listening")
}

/// The next session opens with one again, so the rule above cannot leak
/// into it.
@Test func theNextSessionStillOpensWithAPlaceholder() {
let store = AppSettings.previewStore()
store.voiceVisual = .waveform
let hud = DictationHUDController(stage: HUDStage(settings: store), settings: store)

hud.showListening(on: CGDirectDisplayID?.none, isLatched: false, settings: session(store))
hud.showFinalizing()
hud.showShaping(with: "Tighten grammar")
#expect(hud.textForTesting.isEmpty)

hud.showListening(on: CGDirectDisplayID?.none, isLatched: false, settings: session(store))
#expect(hud.textForTesting == "Listening…")
}

/// The words being rewritten are not a placeholder, so the shaping phase
/// leaves them where they are.
@Test func theShapingPhaseKeepsARealDraft() {
Expand Down