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
4 changes: 2 additions & 2 deletions OpenWhisp/Models/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -281,8 +281,8 @@ class AppState: ObservableObject {
let textOutput: TextOutput

var audioRecorder: AudioRecorder!
var whisperEngine: WhisperEngine!
var appleSpeechEngine: AppleSpeechEngine!
var whisperEngine: FileTranscriptionEngine!
var appleSpeechEngine: StreamingTranscriptionEngine!
var translationService: OpenAITranslationService!
var hotkeyMonitor: HotkeyControlling!

Expand Down
2 changes: 1 addition & 1 deletion OpenWhisp/Services/AppleSpeechEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import Foundation
import AVFoundation
import Speech

final class AppleSpeechEngine {
final class AppleSpeechEngine: StreamingTranscriptionEngine {
var onPartial: ((String) -> Void)?
var onFinal: ((String) -> Void)?
var onError: ((String) -> Void)?
Expand Down
100 changes: 100 additions & 0 deletions OpenWhisp/Services/TranscriptionEngine.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import Foundation

/// Which whisper.cpp execution path to use for a file transcription.
///
/// Foundation-only (string-free enum) so it lives in OpenWhispCore and can be
/// named by the `FileTranscriptionEngine` protocol and by AppState without
/// pulling in the concrete engine.
enum WhisperBackend {
/// Spawn the `whisper-cli` binary per request.
case cli
/// POST to a warm `whisper-server` over loopback HTTP.
case serverAPI
}

/// Platform-agnostic file-based transcription seam (Phase 2.5 core extraction).
///
/// Models the request/response engine (whisper.cpp): hand it a WAV path, get a
/// result back via `onTranscriptionComplete`/`onTranscriptionError` keyed by the
/// request's UUID. The warm-server lifecycle and progress/worker-status reporting
/// are part of the contract so AppState can drive them without naming the concrete
/// macOS `WhisperEngine`. A port keeps whisper.cpp (cross-platform C++) but
/// supplies its own process/HTTP plumbing.
protocol FileTranscriptionEngine: AnyObject {
/// A finished transcription: (requestID, text).
var onTranscriptionComplete: ((UUID, String) -> Void)? { get set }
/// A failed transcription: (requestID, message).
var onTranscriptionError: ((UUID, String) -> Void)? { get set }
/// Coarse progress percent (0–100) for the active request.
var onProgress: ((Int) -> Void)? { get set }
/// Human-readable warm-server/worker status for the UI.
var onWorkerStatus: ((String) -> Void)? { get set }

/// Transcribe a single WAV file. Async — does not block the caller; the
/// result arrives via the completion/error callbacks.
func transcribe(
requestID: UUID,
binaryPath: String,
modelPath: String,
language: String,
wavPath: String,
deleteWhenDone: Bool,
backend: WhisperBackend,
prompt: String
)

/// Pre-warm the persistent server for the given binary/model so the first
/// real request is fast.
func warmServer(binaryPath: String, modelPath: String)
/// Tear down the persistent server.
func stopServer()
}

extension FileTranscriptionEngine {
/// Convenience matching the engine's own defaults, so call sites that don't
/// care about `deleteWhenDone` can omit it when calling through the protocol.
func transcribe(
requestID: UUID,
binaryPath: String,
modelPath: String,
language: String,
wavPath: String,
backend: WhisperBackend,
prompt: String
) {
transcribe(
requestID: requestID,
binaryPath: binaryPath,
modelPath: modelPath,
language: language,
wavPath: wavPath,
deleteWhenDone: true,
backend: backend,
prompt: prompt
)
}
}

/// Platform-agnostic streaming transcription seam (Phase 2.5 core extraction).
///
/// Models the live recognizer (Apple Speech on macOS): `start` begins listening,
/// partial/final hypotheses and audio level arrive via callbacks, `stop` ends.
/// macOS-only today (no good on-device equivalent elsewhere — see
/// WINDOWS_PORT.md), but behind a protocol so AppState's session orchestration
/// doesn't name `AppleSpeechEngine`. Authorization stays a concrete-type concern
/// (it returns platform permission types).
protocol StreamingTranscriptionEngine: AnyObject {
/// Interim hypothesis as the user speaks.
var onPartial: ((String) -> Void)? { get set }
/// Final recognized text for the utterance.
var onFinal: ((String) -> Void)? { get set }
/// A recognition error message.
var onError: ((String) -> Void)? { get set }
/// Normalized (0–1) live audio level for the waveform.
var onLevelChanged: ((Float) -> Void)? { get set }

/// Begin streaming recognition for `language` ("auto" = current locale).
func start(language: String) throws
/// Stop streaming. `cancel` discards any pending final result.
func stop(cancel: Bool)
}
10 changes: 5 additions & 5 deletions OpenWhisp/Services/WhisperEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import Darwin

// MARK: - Whisper Engine

class WhisperEngine {
enum Backend {
case cli
case serverAPI
}
class WhisperEngine: FileTranscriptionEngine {
/// The backend enum now lives in OpenWhispCore as `WhisperBackend` so the
/// `FileTranscriptionEngine` protocol can name it; kept as a typealias so the
/// engine body and `.cli`/`.serverAPI` references are unchanged.
typealias Backend = WhisperBackend

var onTranscriptionComplete: ((UUID, String) -> Void)?
var onTranscriptionError: ((UUID, String) -> Void)?
Expand Down
3 changes: 2 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ let package = Package(
"SecretStore.swift",
"LaunchAtLoginService.swift",
"TextOutput.swift",
"HotkeyControlling.swift"
"HotkeyControlling.swift",
"TranscriptionEngine.swift"
]
),
.testTarget(
Expand Down
137 changes: 137 additions & 0 deletions Tests/OpenWhispCoreTests/TranscriptionEngineTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import XCTest
@testable import OpenWhispCore

/// Fakes for the two transcription seams — the kind of doubles the protocol
/// extraction makes possible. The concrete WhisperEngine/AppleSpeechEngine need a
/// real binary / Speech framework and aren't unit-testable; these record the
/// driving calls and let callbacks be fired on demand, which is what future
/// AppState-orchestration tests need.
final class FakeFileTranscriptionEngine: FileTranscriptionEngine {
var onTranscriptionComplete: ((UUID, String) -> Void)?
var onTranscriptionError: ((UUID, String) -> Void)?
var onProgress: ((Int) -> Void)?
var onWorkerStatus: ((String) -> Void)?

struct Request: Equatable {
let id: UUID
let language: String
let wavPath: String
let deleteWhenDone: Bool
let backend: WhisperBackend
let prompt: String
}
private(set) var requests: [Request] = []
private(set) var warmed: [(binary: String, model: String)] = []
private(set) var stopServerCount = 0

func transcribe(
requestID: UUID,
binaryPath: String,
modelPath: String,
language: String,
wavPath: String,
deleteWhenDone: Bool,
backend: WhisperBackend,
prompt: String
) {
requests.append(.init(
id: requestID, language: language, wavPath: wavPath,
deleteWhenDone: deleteWhenDone, backend: backend, prompt: prompt
))
}
func warmServer(binaryPath: String, modelPath: String) {
warmed.append((binaryPath, modelPath))
}
func stopServer() { stopServerCount += 1 }
}

final class FakeStreamingTranscriptionEngine: StreamingTranscriptionEngine {
var onPartial: ((String) -> Void)?
var onFinal: ((String) -> Void)?
var onError: ((String) -> Void)?
var onLevelChanged: ((Float) -> Void)?

private(set) var startedLanguages: [String] = []
private(set) var stops: [Bool] = []

func start(language: String) throws { startedLanguages.append(language) }
func stop(cancel: Bool) { stops.append(cancel) }
}

final class TranscriptionEngineTests: XCTestCase {
// MARK: File engine

func testProtocolDefaultDeleteWhenDoneIsTrue() {
let engine: FileTranscriptionEngine = FakeFileTranscriptionEngine()
let id = UUID()
// Call the convenience overload (no deleteWhenDone), as AppState does.
engine.transcribe(
requestID: id, binaryPath: "/bin/w", modelPath: "/m",
language: "en", wavPath: "/tmp/a.wav", backend: .cli, prompt: ""
)
let fake = engine as! FakeFileTranscriptionEngine
XCTAssertEqual(fake.requests.count, 1)
XCTAssertEqual(fake.requests.first?.deleteWhenDone, true,
"the convenience overload must default deleteWhenDone to true")
XCTAssertEqual(fake.requests.first?.backend, .cli)
}

func testFileEngineRecordsAllDrivingCalls() {
let fake = FakeFileTranscriptionEngine()
let id = UUID()
fake.transcribe(
requestID: id, binaryPath: "/b", modelPath: "/m",
language: "ru", wavPath: "/w.wav", deleteWhenDone: false,
backend: .serverAPI, prompt: "vocab"
)
fake.warmServer(binaryPath: "/b", modelPath: "/m")
fake.stopServer()

XCTAssertEqual(fake.requests, [.init(
id: id, language: "ru", wavPath: "/w.wav",
deleteWhenDone: false, backend: .serverAPI, prompt: "vocab"
)])
XCTAssertEqual(fake.warmed.count, 1)
XCTAssertEqual(fake.stopServerCount, 1)
}

func testFileEngineCallbacksAreInvokable() {
let fake = FakeFileTranscriptionEngine()
var completed: (UUID, String)?
fake.onTranscriptionComplete = { completed = ($0, $1) }
let id = UUID()
fake.onTranscriptionComplete?(id, "hello world")
XCTAssertEqual(completed?.0, id)
XCTAssertEqual(completed?.1, "hello world")
}

// MARK: Streaming engine

func testStreamingEngineRecordsLifecycle() throws {
let fake = FakeStreamingTranscriptionEngine()
try fake.start(language: "auto")
fake.stop(cancel: false)
fake.stop(cancel: true)
XCTAssertEqual(fake.startedLanguages, ["auto"])
XCTAssertEqual(fake.stops, [false, true])
}

func testStreamingEngineCallbacksAreInvokable() {
let fake = FakeStreamingTranscriptionEngine()
var partials: [String] = []
var finals: [String] = []
var levels: [Float] = []
fake.onPartial = { partials.append($0) }
fake.onFinal = { finals.append($0) }
fake.onLevelChanged = { levels.append($0) }

fake.onPartial?("he")
fake.onPartial?("hello")
fake.onLevelChanged?(0.4)
fake.onFinal?("hello there")

XCTAssertEqual(partials, ["he", "hello"])
XCTAssertEqual(finals, ["hello there"])
XCTAssertEqual(levels, [0.4])
}
}
6 changes: 5 additions & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,11 @@ makes the macOS app more testable **and** converts "port the whole app" into
constructing concrete types:
- ✅ `SecretStore` (Keychain → Credential Manager) — extracted; `AppState`
injects it, `InMemorySecretStore` covers the logic in `swift test`.
- `TranscriptionEngine` (whisper CLI/server, Apple Speech)
- ✅ `FileTranscriptionEngine` (whisper CLI/server) + `StreamingTranscriptionEngine`
(Apple Speech) — extracted as two focused protocols (the engines have different
request/response vs. streaming shapes); `AppleSpeechEngine`'s permission statics
stay concrete (they return platform types). `WhisperBackend` moved to the core;
fakes added for both.
- `AudioCapture` (AVAudioEngine today → WASAPI on Windows)
- ✅ `TextOutput` (AX + Cmd+V → UI Automation + SendInput) — extracted;
`AppState` injects it, `InsertionMode` moved to the core, the
Expand Down
Loading