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:)onDictationPostProcessingService.sharedto handle provider resolution, prompt construction, and LLM execution in a single awaitable call. - Handle specific errors from the
AIProcessingErrorenum 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 inSettingsStore. - Leverage Apple Intelligence on macOS 26+ by setting the provider ID to
apple-intelligencewithout 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →