Skip to content
C.W.K.
Stream
Lesson 02 of 07 · published

No Engine Leaks Upward

~11 min · stt-adapter, leaky-abstraction, contract, engine

Level 0Unlit
0 XP0/48 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete
"If the word 'WhisperKit' appears above the seam, the abstraction has already failed."

The Adapter's One Job

An STT adapter's job is to make its engine look like the interface and nothing more. WhisperKit has its own decoding options, model enums, and audio format expectations; SpeechAnalyzer has locale assets and async result streams. None of that is allowed to leak above the seam. Each adapter absorbs its engine's quirks and exposes only the shared contract: give me a recording and the settings (which carry the language mode), I return text plus a language (detected by WhisperKit, or the locale SpeechAnalyzer ran in). The controller holds any STTEngine and never learns which one it's talking to.

The Leak Test

Here's the concrete test for whether your abstraction is real: grep the code above the seam for engine-specific words. If DecodingOptions, WhisperKitConfig, or a model filename appears in the DictationController, the abstraction leaks — the controller now depends on a detail it claimed not to know. A clean adapter means those words appear only inside the adapter file. Everything above speaks the neutral contract.

Leaky (bad):
  controller -> reads WhisperKit's DecodingOptions directly   # engine leaked up

Clean (good):
  controller -> STTEngine.transcribe(audioURL, settings)       # neutral contract
  WhisperKitEngine -> builds DecodingOptions internally         # quirk absorbed

The Contract Carries What Downstream Needs

The return type is deliberately small but complete: text, and the language — the one WhisperKit detected or was told, or, for SpeechAnalyzer, which cannot identify audio language, the locale it ran in. Why the language? Because the very next step — cleanup — needs it, and the engine is the only component that knows. If the adapter dropped the language, cleanup would have to re-detect it or guess, re-answering a question the engine already answered. A good contract carries exactly what downstream consumers need and no more — not the engine's internal confidence scores, not its token timings, just the two facts the rest of the app acts on.

An adapter absorbs quirks; it doesn't forward them. The measure of a good adapter is that its engine's peculiarities stop at its boundary. If callers have to know the engine to use the adapter, you've built a pass-through, not an abstraction. Absorb the config, the formats, the error shapes — expose only the neutral contract.
The first WhisperKit adapter I wrote leaked — the controller was reaching in to set decoding options 'just for now.' It felt harmless until we added the SpeechAnalyzer engine and the controller suddenly needed a branch: 'if WhisperKit, set options; if SpeechAnalyzer, do this other thing.' That branch was the leak billing me. Moving the options back inside each adapter deleted the branch and the coupling with it.

Code

The engine's quirks stop at the adapter boundary·swift
@MainActor
final class WhisperKitEngine: STTEngine {
    private var pipeline: WhisperKit?

    func transcribe(audioURL: URL, settings: AppSettings) async throws -> STTResult {
        let pipe = try await loadPipeline(settings: settings, onProgress: { _ in })
        // All WhisperKit-specific config is built HERE, never exposed.
        var options = DecodingOptions()
        options.task = .transcribe
        options.temperature = 0
        options.usePrefillPrompt = true
        options.chunkingStrategy = .vad
        if let code = settings.dictationLanguage.whisperLanguageCode {
            options.language = code          // "ko" or "en"
            options.detectLanguage = false
        } else {
            options.detectLanguage = true    // Auto: identify from the audio
        }
        let results = try await pipe.transcribe(audioPath: audioURL.path, decodeOptions: options)
        let text = results.map { $0.text }.joined(separator: " ")
        return STTResult(text: text, detectedLanguage: results.first?.language)
    }
}
// The controller sees STTResult. It never sees DecodingOptions.

External links

Exercise

Take an adapter or wrapper in your own code and grep the code above it for the underlying library's type names. Every hit is a leak. Pick one and describe what breaks when you try to swap the underlying library — that broken thing is exactly what the leak costs you.
Hint
Each leaked type name is a place the caller secretly depends on the specific library. When you swap libraries, every one of those sites needs editing — which is the coupling the adapter was supposed to prevent. The number of grep hits is a direct measure of how much a swap will actually cost.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.