How to Implement Transcription Post-Processing Using FluidVoice's DictationPostProcessingService

FluidVoice’s DictationPostProcessingService lets you route raw speech-to-text transcripts through an LLM—whether OpenAI, Groq, or Apple Intelligence—before displaying the final text to the user.

The transcription post-processing pipeline in the open-source FluidVoice app provides a clean, gated architecture for enhancing dictation output with AI. By separating the decision logic from the execution logic, the service ensures that post-processing only runs when properly configured and verified.

Core Architecture and Flow

The post-processing system relies on two primary components: the DictationAIPostProcessingGate, which validates whether AI processing is allowed, and the DictationPostProcessingService, which orchestrates the LLM call.

Step 1: Validate With the Gate

Before invoking any LLM, the UI layer must check DictationAIPostProcessingGate.isConfigured(for:) to verify that the user has enabled an AI provider and that the provider fingerprint is verified. This prevents accidental data leakage to unverified endpoints.

guard DictationAIPostProcessingGate.isConfigured(for: .primary) else {
    // Post-processing disabled; use raw transcript
    return rawText
}

Source: DictationAIPostProcessingGate.isConfigured in Sources/Fluid/Services/DictationAIPostProcessingGate.swift

Step 2: Resolve the AI Provider

When the gate passes, DictationPostProcessingService.process(_:dictationSlot:) internally calls resolveProvider(settings:dictationSlot:) to determine the concrete implementation. The resolution logic supports:

  • Built-in providers (OpenAI, Groq, etc.) via ModelRepository
  • Custom providers identified by a custom: prefix
  • Private AI integration through PrivateAIIntegrationService
  • Apple Intelligence on macOS 26+

Source: resolveProvider implementation in Sources/Fluid/Services/DictationPostProcessingService.swift (lines 42-81)

Step 3: Construct the Prompt and Execute

The service merges the raw transcript with the user-selected system prompt (SettingsStore.effectiveDictationSystemPrompt) and sends it to the resolved provider. For non-Apple providers, it constructs an LLMClient.Config with a default temperature of 0.2, the model identifier, endpoint, and API key, then calls LLMClient.shared.call(config).

For Apple Intelligence, the service routes to AppleIntelligenceProvider.process(...) instead.

Source: LLM call block in Sources/Fluid/Services/DictationPostProcessingService.swift (lines 19-33)

Step 4: Format and Return Results

The LLM response is passed through ASRService.applyGAAVFormatting to fix punctuation and spacing quirks, then wrapped in a DictationPostProcessingService.Result struct containing the enhanced text.

Source: Result creation in Sources/Fluid/Services/DictationPostProcessingService.swift (lines 71-75)

Code Implementation Examples

Basic Integration in a ViewModel

Use this pattern in a @MainActor context to ensure UI-bound state updates safely:

import Fluid

class DictationViewModel {
    @MainActor
    func finalizeTranscription(_ rawText: String, slot: SettingsStore.DictationShortcutSlot = .primary) async {
        guard DictationAIPostProcessingGate.isConfigured(for: slot) else {
            self.transcribedText = rawText
            return
        }

        do {
            let result = try await DictationPostProcessingService.shared.process(
                rawText,
                dictationSlot: slot
            )
            self.transcribedText = result.text
        } catch let error as AIProcessingError {
            self.transcribedText = rawText
            DebugLogger.shared.error("Post-processing failed: \(error)", source: "DictationVM")
        }
    }

    @Published var transcribedText: String = ""
}

The gate respects the user's prompt selection (Off, Private AI, or a specific provider), while process throws specific AIProcessingError cases including noVerifiedProvider, missingModel, missingAPIKey, and emptyResponse.

Configuring a Custom LLM Provider

To route transcription post-processing through a custom endpoint, prefix the provider ID with custom: and store the API key and model in SettingsStore:

func testCustomProvider() async {
    SettingsStore.shared.selectedProviderID = "custom:my-custom-provider"
    SettingsStore.shared.providerAPIKeys["custom:my-custom-provider"] = "my-secret-key"
    SettingsStore.shared.selectedModelByProvider["custom:my-custom-provider"] = "gpt-4-mini"

    let raw = "the quick brown fox jumps over the lazy dog"
    
    do {
        let result = try await DictationPostProcessingService.shared.process(raw)
        print("Enhanced:", result.text)
    } catch {
        print("Failed:", error)
    }
}

The gate automatically verifies the custom endpoint via fingerprint comparison before allowing the request.

Enabling Apple Intelligence on macOS 26+

For devices running macOS 26 or later, you can use the built-in Apple Intelligence provider without an API key:

#if canImport(FoundationModels)
if #available(macOS 26.0, *) {
    SettingsStore.shared.selectedProviderID = "apple-intelligence"
    // No API key required for Apple Intelligence
    let output = try await DictationPostProcessingService.shared.process("hello world")
    print(output.text)
}
#endif

The service detects the apple-intelligence identifier and routes the call to AppleIntelligenceProvider.process(...).

Key Source Files

File Path Role
DictationPostProcessingService.swift Sources/Fluid/Services/DictationPostProcessingService.swift Central service that resolves providers, builds prompts, calls LLMs, and returns processed text.
DictationAIPostProcessingGate.swift Sources/Fluid/Services/DictationAIPostProcessingGate.swift Decision-making logic that validates if AI post-processing is enabled and verifies provider fingerprints.
SettingsStore.swift Sources/Fluid/SettingsStore.swift Holds user selections for providers, models, API keys, and system prompts.
LLMClient.swift Sources/Fluid/LLMClient.swift Generic LLM client used to send requests to chosen endpoints.
ASRService.swift Sources/Fluid/ASRService.swift Applies GAAV-specific formatting to raw LLM output.

Summary

  • Always check the gate using DictationAIPostProcessingGate.isConfigured(for:) before attempting post-processing to respect user privacy settings.
  • Call process(_:dictationSlot:) on DictationPostProcessingService.shared to handle provider resolution, prompt construction, and LLM execution in a single awaitable call.
  • Handle specific errors from the AIProcessingError enum to provide graceful fallbacks when providers are unverified or misconfigured.
  • Support custom providers by using the custom: prefix in the provider ID and storing credentials in SettingsStore.
  • Leverage Apple Intelligence on macOS 26+ by setting the provider ID to apple-intelligence without requiring external API keys.

Frequently Asked Questions

What is the purpose of DictationAIPostProcessingGate?

The gate acts as a security and configuration validator. It ensures that transcription post-processing only occurs when the user has explicitly enabled an AI provider and that the provider's endpoint fingerprint matches the verified configuration, preventing accidental data transmission to untrusted servers.

How do I add a custom provider to FluidVoice?

Prefix your provider identifier with custom: (e.g., custom:my-endpoint) and store the API key in SettingsStore.shared.providerAPIKeys under that same key. The DictationPostProcessingService will automatically route requests to your specified endpoint and verify it through the gate's fingerprint checking mechanism.

Can I use transcription post-processing without an API key?

Yes, but only when using Apple Intelligence on macOS 26 or later. Set SettingsStore.shared.selectedProviderID to apple-intelligence and omit the API key. All other providers (OpenAI, Groq, custom endpoints) require a valid API key stored in SettingsStore.

What happens if the LLM returns an empty response?

The DictationPostProcessingService throws AIProcessingError.emptyResponse when the LLM returns no content. You should catch this error in your view model and fall back to displaying the raw transcription text to ensure the user never loses their dictation input.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →