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
14 changes: 14 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ let package = Package(
products: [
.executable(name: "bestasr", targets: ["bestasr"]),
.executable(name: "bestasr-mcp", targets: ["bestasr-mcp"]),
.executable(name: "bestasr-gui", targets: ["bestasr-gui"]),
.library(name: "BestASRKit", targets: ["BestASRKit"]),
],
dependencies: [
Expand Down Expand Up @@ -50,11 +51,24 @@ let package = Package(
dependencies: ["BestASRMCPCore"],
swiftSettings: [.swiftLanguageMode(.v5)]
),
// #87 GUI dual-track: logic in a library target (mirrors BestASRMCPCore)
// so the session state machine is testable without SwiftUI.
.target(
name: "BestASRGUICore",
dependencies: ["BestASRKit"],
swiftSettings: [.swiftLanguageMode(.v5)]
),
.executableTarget(
name: "bestasr-gui",
dependencies: ["BestASRGUICore"],
swiftSettings: [.swiftLanguageMode(.v5)]
),
.testTarget(
name: "BestASRKitTests",
dependencies: [
"BestASRKit",
"BestASRMCPCore",
"BestASRGUICore",
.product(name: "WhisperKit", package: "WhisperKit"),
],
swiftSettings: [.swiftLanguageMode(.v5)]
Expand Down
28 changes: 25 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,31 @@ claude mcp add bestasr -- ~/bin/bestasr-mcp
# Claude Desktop: add ~/bin/bestasr-mcp under mcpServers in the app config
```

One project, three consumption surfaces sharing one benchmark store:
**CLI** (terminal / scripts), **agent skills** (this plugin), and the
**MCP server** (bundled in the plugin, or standalone).
### The macOS app (GUI, dual-track bundle)

For humans who want "drop a file → get an SRT" with no terminal: `bestASR.app`
is a SwiftUI GUI (file picker / drag-and-drop, language + effort + format,
elapsed-time progress, result preview + reveal-in-Finder) that calls the same
engine core as the CLI. The bundle is **dual-track** — `Contents/MacOS/` also
carries the MCP helper and the CLI (`bestasr-cli`; the `-cli` suffix exists
because default macOS filesystems are case-insensitive, where a `bestasr`
entry would collide with the `bestASR` GUI executable):

```bash
scripts/release-app.sh --assemble-only # unsigned local bundle in dist/
# maintainers: scripts/release-app.sh # sign + notarize + STAPLE + zip
# agents can use the bundled helper directly:
claude mcp add bestasr -- /Applications/bestASR.app/Contents/MacOS/bestasr-mcp
```

Unlike the bare `~/bin` binaries, the stapled bundle verifies offline with
Gatekeeper. Models still download on first use to the same cache. Progress is
stage + elapsed (the engines expose no percentage — no fake progress bars).

One project, four consumption surfaces sharing one benchmark store:
**CLI** (terminal / scripts), **agent skills** (this plugin), the
**MCP server** (bundled in the plugin, or standalone), and the
**macOS app** (GUI for humans, with the MCP helper and CLI riding inside).

## Quick start

Expand Down
39 changes: 39 additions & 0 deletions Sources/BestASRGUICore/GUIOptions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Foundation

import BestASRKit

/// Picker vocabularies + the production runner wiring for the GUI (#87).
/// Derived from the same core types the CLI uses so the surfaces cannot drift.
public enum GUIOptions {
/// Benchmark-backed languages plus auto-detect (design D3; free-form codes later).
public static let languages = ["auto", "zh", "ja", "en"]

/// "auto" resolves per machine state in CommandCore; ordinals come straight
/// from RouterProfile so a new profile appears here without GUI edits.
public static var efforts: [String] { ["auto"] + RouterProfile.allCases.map(\.rawValue) }

/// The formats TranscriptWriter already renders.
public static var formats: [String] { OutputFormat.allNames }

public static func requestedLanguage(fromSelection selection: String) -> String? {
selection == "auto" ? nil : selection
}
}

public enum GUITranscribe {
/// Production seam: one warm CommandCore per app process so engine pipeline
/// caches survive across GUI runs (same reuse rationale as the MCP server).
public static func coreRunner(core: CommandCore = .live()) -> TranscribeRunner {
{ request in
try await core.transcribe(
audioPath: request.audioPath,
selection: SelectionRequest(
profileName: request.profileName,
backendOverride: nil,
modelOverride: nil,
requestedLanguage: request.requestedLanguage),
formatName: request.formatName,
outputPath: request.outputPath)
}
}
}
142 changes: 142 additions & 0 deletions Sources/BestASRGUICore/TranscribeSession.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import Foundation
import Observation

import BestASRKit

/// One GUI transcription request (spec gui-app, #87). Value type so the view
/// layer can assemble it from pickers without touching core types.
public struct TranscribeRequest: Equatable, Sendable {
public var audioPath: String
/// nil = auto-detect (AudioProber decides).
public var requestedLanguage: String?
/// "auto" or a RouterProfile rawValue — resolved by CommandCore.
public var profileName: String
public var formatName: String
/// nil lets the core derive the path next to the input.
public var outputPath: String?

public init(
audioPath: String, requestedLanguage: String?, profileName: String,
formatName: String, outputPath: String? = nil
) {
self.audioPath = audioPath
self.requestedLanguage = requestedLanguage
self.profileName = profileName
self.formatName = formatName
self.outputPath = outputPath
}
}

/// The seam between the GUI and the engine world (design D4): the app injects
/// CommandCore, tests inject a fake.
public typealias TranscribeRunner = @Sendable (TranscribeRequest) async throws -> TranscribeOutcome

/// GUI transcription state machine. Phase moves idle → running → done/failed;
/// `start` is single-flight and `cancel` returns to idle (the underlying engine
/// inference may keep running to completion — a documented v1 limitation, the
/// cancelled run's result is discarded via the generation guard).
@MainActor
@Observable
public final class TranscribeSession {
public enum Phase: Equatable {
case idle
case running(startedAt: Date)
case done(Completion)
case failed(String)
}

/// What the result view renders (narrow, Equatable — swiftui-specialist).
public struct Completion: Equatable, Sendable {
public let outputPath: String
public let formatName: String
public let explanation: String
public let preview: String

public init(outputPath: String, formatName: String, explanation: String, preview: String) {
self.outputPath = outputPath
self.formatName = formatName
self.explanation = explanation
self.preview = preview
}
}

public private(set) var phase: Phase = .idle

private let runner: TranscribeRunner
private let previewLimit: Int
private var task: Task<Void, Never>?
/// Bumped on every start/cancel so a stale in-flight completion (from a
/// cancelled run) can never overwrite the current phase.
private var generation = 0

public init(runner: @escaping TranscribeRunner, previewLimit: Int = 20_000) {
self.runner = runner
self.previewLimit = previewLimit
}

public var isRunning: Bool {
if case .running = phase { return true }
return false
}

/// Single-flight: a start while running is a no-op (spec gui-app).
public func start(_ request: TranscribeRequest) {
guard !isRunning else { return }
generation += 1
let gen = generation
phase = .running(startedAt: Date())
let runner = self.runner
let limit = previewLimit
task = Task { [weak self] in
do {
let outcome = try await runner(request)
let preview = await Self.loadPreview(path: outcome.outputPath, limit: limit)
guard let self, self.generation == gen, self.isRunning else { return }
self.phase = .done(
Completion(
outputPath: outcome.outputPath, formatName: outcome.format,
explanation: outcome.explanation, preview: preview))
} catch {
guard let self, self.generation == gen, self.isRunning else { return }
if error is CancellationError {
self.phase = .idle
} else {
self.phase = .failed(Self.message(for: error))
}
}
}
}

/// Returns the UI to idle. The awaiting task is cancelled; if the engine
/// does not observe cancellation its eventual result is dropped by the
/// generation guard rather than resurrecting a stale phase.
public func cancel() {
guard isRunning else { return }
generation += 1
task?.cancel()
task = nil
phase = .idle
}

/// Clears a terminal phase so the user can start over.
public func reset() {
guard !isRunning else { return }
phase = .idle
}

static func message(for error: Error) -> String {
if let localized = (error as? LocalizedError)?.errorDescription { return localized }
return String(describing: error)
}

/// Off-main-thread read of the written transcript for the preview pane.
nonisolated static func loadPreview(path: String, limit: Int) async -> String {
await Task.detached(priority: .utility) {
guard let content = try? String(contentsOfFile: path, encoding: .utf8) else {
return ""
}
guard content.count > limit else { return content }
return String(content.prefix(limit)) + "\n…(preview truncated — full output on disk)"
}.value
}
}
12 changes: 12 additions & 0 deletions Sources/bestasr-gui/BestASRApp.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import SwiftUI

import BestASRGUICore

@main
struct BestASRApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
Loading
Loading