Skip to content

Stop invalidating ContentView on streaming partial transcription updates#293

Open
NathanDrake2406 wants to merge 1 commit intoaltic-dev:mainfrom
NathanDrake2406:perf/demote-partial-transcription
Open

Stop invalidating ContentView on streaming partial transcription updates#293
NathanDrake2406 wants to merge 1 commit intoaltic-dev:mainfrom
NathanDrake2406:perf/demote-partial-transcription

Conversation

@NathanDrake2406
Copy link
Copy Markdown

What this changes

ASRService.partialTranscription moves from @Published to a private CurrentValueSubject<String, Never> exposed to subscribers via an AnyPublisher. The value getter and the single Combine consumer (MenuBarManager) are updated accordingly. No other @Published properties on ASRService are affected.

Why

During streaming ASR, partialTranscription is written 1-5 times per second depending on the model (parakeetRealtime runs at 0.2s intervals, default models at 0.6s, Cohere at 1.0s per SettingsStore.swift).

Because it was @Published, every write fired Combine's synthesized objectWillChange.send(). AppServices.setupASRForwarding pipes ASRService.objectWillChange into AppServices.objectWillChange, and ContentView observes AppServices via @EnvironmentObject. SwiftUI's legacy ObservableObject protocol has no per-property dependency tracking, so every tick invalidated ContentView and forced its 3,061-line body to re-evaluate, even though ContentView never reads partialTranscription.

MenuBarManager is the only consumer of this value (it forwards to the notch overlay when streaming preview is enabled). Moving the hot path off objectWillChange eliminates the invalidation storm without touching any legitimate cold-signal forwarding (isRunning, isAsrReady, micStatus, errorTitle, etc. stay @Published and ContentView continues to react to them correctly).

Approach

  1. Replace @Published var partialTranscription: String = "" with private let partialTranscriptionSubject = CurrentValueSubject<String, Never>("").
  2. Expose var partialTranscription: String { subject.value } for synchronous reads and var partialTranscriptionPublisher: AnyPublisher<String, Never> for subscription.
  3. Update the three writers in ASRService to call partialTranscriptionSubject.send(...) instead of assigning the property.
  4. Update MenuBarManager.configure(asrService:) to subscribe via asrService.partialTranscriptionPublisher instead of asrService.$partialTranscription.

Non-goals: no changes to any other @Published property, no migration to @Observable, no changes to AppServices forwarding or ContentView. This is the minimal surgical fix to the documented hot path.

Validation

  • SwiftLint strict: 0 violations on both edited files.
  • xcodebuild build: clean with respect to this change. (The swift-sdk MCP transport has two pre-existing data-race errors on upstream/main that remain; confirmed identical on the unmodified branch.)
  • Manual verification of the value flow: partialTranscriptionSubject.send(x) updates the getter and the published publisher on the next subscription tick. MenuBarManager's .receive(on: DispatchQueue.main).sink { ... } still delivers on main as before.

Risks / follow-ups

  • Any future code that subscribes to $partialTranscription via the Combine projected value will break and must use partialTranscriptionPublisher instead. Currently there are no other consumers.
  • Broader cleanup opportunities remain in the AppServices forwarding design (blanket objectWillChange forwarding for AudioHardwareObserver still exists, and migrating ASRService to the @Observable macro would give per-property tracking across all fields). Those are out of scope for this PR.

During streaming ASR, partialTranscription writes fire at 1-5 Hz. Because it
was declared @published, each write triggered Combine's synthesized
objectWillChange.send(). AppServices.setupASRForwarding forwards
ASRService.objectWillChange into AppServices.objectWillChange, and ContentView
observes AppServices via @EnvironmentObject. The result: ContentView's body
re-evaluated at the streaming cadence for the entire duration of every
dictation, even though ContentView never reads partialTranscription.

The mistaken assumption was that @published is a free observability primitive.
Under legacy ObservableObject it is not. Any objectWillChange.send()
invalidates every subscriber regardless of which property they read. A
hot-path value whose only Combine consumer is MenuBarManager belongs on a
dedicated subject that does not route through objectWillChange.

Demote partialTranscription from @published to a private CurrentValueSubject,
expose an AnyPublisher for subscription, and keep the String getter for
direct reads. MenuBarManager switches from the $partialTranscription
projected publisher to partialTranscriptionPublisher. All other @published
properties on ASRService stay intact because they are cold signals
(isRunning, isAsrReady, micStatus, errorTitle, etc.) that ContentView
legitimately reacts to.
Copilot AI review requested due to automatic review settings April 21, 2026 07:20
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Reduces SwiftUI invalidation during streaming ASR by taking partialTranscription off ObservableObject.objectWillChange and exposing it via a dedicated Combine publisher for the single consumer (MenuBarManager).

Changes:

  • Replaced @Published partialTranscription with a private CurrentValueSubject plus a synchronous getter and AnyPublisher accessor.
  • Updated all internal writers to send(...) through the subject.
  • Updated MenuBarManager to subscribe via partialTranscriptionPublisher instead of $partialTranscription.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
Sources/Fluid/Services/ASRService.swift Moves hot-path partial transcription updates to a CurrentValueSubject to avoid objectWillChange storms.
Sources/Fluid/Services/MenuBarManager.swift Switches subscription from $partialTranscription to the new partialTranscriptionPublisher.
Comments suppressed due to low confidence (1)

Sources/Fluid/Services/MenuBarManager.swift:90

  • The sink closure doesn’t reference self, so [weak self] plus guard self != nil is redundant. Consider removing the weak capture/guard (or, if you intend to use self later, unwrap with guard let self = self else { return } and actually use self).
            .sink { [weak self] newText in
                guard self != nil else { return }
                // CRITICAL FIX: Check if streaming preview is enabled before updating notch

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants