diff --git a/.github/MiniWhisper-wordmark.svg b/.github/MiniWhisper-wordmark.svg
new file mode 100644
index 0000000..7ce1541
--- /dev/null
+++ b/.github/MiniWhisper-wordmark.svg
@@ -0,0 +1,56 @@
+
diff --git a/.github/icon.svg b/.github/icon.svg
new file mode 100644
index 0000000..fb04056
--- /dev/null
+++ b/.github/icon.svg
@@ -0,0 +1,68 @@
+
\ No newline at end of file
diff --git a/Package.swift b/Package.swift
index 869e10e..1917090 100644
--- a/Package.swift
+++ b/Package.swift
@@ -17,7 +17,8 @@ let package = Package(
.executableTarget(
name: "MiniWhisper",
dependencies: [
- "FluidAudio"
+ "FluidAudio",
+ "whisper"
],
path: "Sources/MiniWhisper",
exclude: ["Resources"],
@@ -25,6 +26,11 @@ let package = Package(
.enableExperimentalFeature("StrictConcurrency")
]
),
+ .binaryTarget(
+ name: "whisper",
+ url: "https://github.com/andyhtran/MiniWhisper/releases/download/whisper-xcframework-1.0/whisper.xcframework.zip",
+ checksum: "866b43e4a3f31d1f898c7300d36e786841723e7be5a0fcdaa5879daea2f4389d"
+ ),
.testTarget(
name: "MiniWhisperTests",
dependencies: ["MiniWhisper"],
diff --git a/README.md b/README.md
index 3e0a410..3f90f18 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# MiniWhisper
-A minimal macOS menu bar app for voice-to-text using the [Parakeet](https://github.com/FluidInference/FluidAudio) model. Press a hotkey, speak, and the transcription is automatically pasted into the active app.
+A minimal macOS menu bar app for voice-to-text with fast English transcription via [Parakeet](https://github.com/FluidInference/FluidAudio) and multilingual transcription via [whisper.cpp](https://github.com/ggml-org/whisper.cpp). Press a hotkey, speak, and the transcription is automatically pasted into the active app.


@@ -51,7 +51,8 @@ just dev
- **Text replacements** — auto-correct words or phrases after transcription
- **Recording history** — browse and copy recent transcriptions
- **Usage stats** — track recordings, speaking time, word count, and average WPM
-- **On-device** — all processing happens locally via the Parakeet model, nothing leaves your Mac
+- **Multiple models** — switch between fast English-only (Parakeet) and multilingual auto-detect (whisper.cpp)
+- **On-device** — all processing happens locally on your Mac, nothing leaves your device
## Build commands
diff --git a/Scripts/build-app.sh b/Scripts/build-app.sh
index d6c518c..72d1f05 100755
--- a/Scripts/build-app.sh
+++ b/Scripts/build-app.sh
@@ -30,6 +30,22 @@ mkdir -p "$APP_BUNDLE/Contents/Resources"
cp "$BUILD_DIR/$APP_NAME" "$APP_BUNDLE/Contents/MacOS/"
+# Embed whisper.framework with proper macOS versioned structure
+FRAMEWORKS_DIR="$APP_BUNDLE/Contents/Frameworks"
+if [ -d "$BUILD_DIR/whisper.framework" ]; then
+ mkdir -p "$FRAMEWORKS_DIR/whisper.framework/Versions/A"
+ cp -R "$BUILD_DIR/whisper.framework/Versions/A/Headers" "$FRAMEWORKS_DIR/whisper.framework/Versions/A/"
+ cp -R "$BUILD_DIR/whisper.framework/Versions/A/Modules" "$FRAMEWORKS_DIR/whisper.framework/Versions/A/"
+ cp -R "$BUILD_DIR/whisper.framework/Versions/A/Resources" "$FRAMEWORKS_DIR/whisper.framework/Versions/A/"
+ cp "$BUILD_DIR/whisper.framework/Versions/A/whisper" "$FRAMEWORKS_DIR/whisper.framework/Versions/A/"
+ ln -sf A "$FRAMEWORKS_DIR/whisper.framework/Versions/Current"
+ ln -sf Versions/Current/Headers "$FRAMEWORKS_DIR/whisper.framework/Headers"
+ ln -sf Versions/Current/Modules "$FRAMEWORKS_DIR/whisper.framework/Modules"
+ ln -sf Versions/Current/Resources "$FRAMEWORKS_DIR/whisper.framework/Resources"
+ ln -sf Versions/Current/whisper "$FRAMEWORKS_DIR/whisper.framework/whisper"
+ install_name_tool -add_rpath "@executable_path/../Frameworks" "$APP_BUNDLE/Contents/MacOS/$APP_NAME" 2>/dev/null || true
+fi
+
cat > "$APP_BUNDLE/Contents/Info.plist" << PLIST
diff --git a/Scripts/sign-and-notarize.sh b/Scripts/sign-and-notarize.sh
index 6deaff1..ef9c343 100755
--- a/Scripts/sign-and-notarize.sh
+++ b/Scripts/sign-and-notarize.sh
@@ -12,6 +12,11 @@ SIGNING_ID="${CODESIGN_IDENTITY:?Set CODESIGN_IDENTITY to your Developer ID Appl
echo "==> Building release..."
bash "$ROOT/Scripts/build-app.sh" release
+echo "==> Signing embedded frameworks..."
+for fw in "$APP_BUNDLE"/Contents/Frameworks/*.framework; do
+ [ -d "$fw" ] && codesign --force --timestamp --options runtime --sign "$SIGNING_ID" "$fw"
+done
+
echo "==> Signing with: $SIGNING_ID"
codesign --force --timestamp --options runtime \
--sign "$SIGNING_ID" \
diff --git a/Sources/MiniWhisper/AppState.swift b/Sources/MiniWhisper/AppState.swift
index a43c1c9..cfe0b13 100644
--- a/Sources/MiniWhisper/AppState.swift
+++ b/Sources/MiniWhisper/AppState.swift
@@ -8,6 +8,7 @@ import UserNotifications
final class AppState: Sendable {
let recorder = AudioRecorder()
let parakeet = ParakeetProvider()
+ let whisper = WhisperProvider()
let recordingStore = RecordingStore()
let analyticsStore = AnalyticsStore()
let permissions = PermissionsManager()
@@ -15,6 +16,7 @@ final class AppState: Sendable {
let toast = ToastWindowController.shared
var replacementSettings = ReplacementSettings.load()
+ var transcriptionMode: TranscriptionMode = TranscriptionModeStorage.load()
let maxRecordingDuration: TimeInterval = 600.0 // 10 minutes
var warningDuration: TimeInterval { maxRecordingDuration * 0.8 } // 8 minutes
@@ -23,18 +25,65 @@ final class AppState: Sendable {
private var durationCheckTimer: Timer?
private var currentRecordingId: String?
- // Callbacks for HotkeyManager to track recording state
var onRecordingStarted: (() -> Void)?
var onRecordingEnded: (() -> Void)?
- var isModelLoaded: Bool { parakeet.isInitialized }
+ var isModelLoaded: Bool {
+ switch transcriptionMode {
+ case .english: return parakeet.isInitialized
+ case .multilingual: return whisper.isInitialized
+ }
+ }
+
+ var isModelDownloading: Bool { whisper.isDownloading }
+ var modelDownloadProgress: Double { whisper.downloadProgress }
// MARK: - Initialization
func preloadModel() {
Task {
do {
- try await parakeet.initialize()
+ switch transcriptionMode {
+ case .english:
+ try await parakeet.initialize()
+ case .multilingual:
+ guard whisper.modelExists else { return }
+ try await whisper.initialize()
+ }
+ } catch {
+ toast.showError(title: "Model Load Failed", message: error.localizedDescription)
+ }
+ }
+ }
+
+ func switchTranscriptionMode(to mode: TranscriptionMode) {
+ guard mode != transcriptionMode else { return }
+
+ if recorder.state.isRecording {
+ toast.showError(title: "Cannot Switch", message: "Stop recording before switching models.")
+ return
+ }
+
+ if recorder.state == .processing {
+ return
+ }
+
+ switch transcriptionMode {
+ case .english: parakeet.unload()
+ case .multilingual: whisper.unload()
+ }
+
+ transcriptionMode = mode
+ TranscriptionModeStorage.save(mode)
+
+ Task {
+ do {
+ switch mode {
+ case .english:
+ try await parakeet.initialize()
+ case .multilingual:
+ try await whisper.initialize()
+ }
} catch {
toast.showError(title: "Model Load Failed", message: error.localizedDescription)
}
@@ -53,7 +102,7 @@ final class AppState: Sendable {
func startRecording() {
guard recorder.state.isIdle else { return }
- guard parakeet.isInitialized else {
+ guard isModelLoaded else {
toast.showError(title: "Model Not Ready", message: "Please wait for the model to finish loading.")
return
}
@@ -113,7 +162,13 @@ final class AppState: Sendable {
private func transcribe(audioURL: URL, recordingId: String, duration: TimeInterval, sampleRate: Double) async {
do {
- let result = try await parakeet.transcribe(audioURL: audioURL)
+ let result: TranscriptionResult
+ switch transcriptionMode {
+ case .english:
+ result = try await parakeet.transcribe(audioURL: audioURL)
+ case .multilingual:
+ result = try await whisper.transcribe(audioURL: audioURL)
+ }
// Guard against stale callback: if the user rapid-tapped and started a new
// recording while transcription was in-flight, the state has moved on.
@@ -156,8 +211,8 @@ final class AppState: Sendable {
transcriptionDuration: result.duration
),
configuration: RecordingConfiguration(
- voiceModel: "Parakeet",
- language: "en"
+ voiceModel: result.model,
+ language: result.language
)
)
@@ -186,7 +241,7 @@ final class AppState: Sendable {
),
transcription: nil,
configuration: RecordingConfiguration(
- voiceModel: "Parakeet",
+ voiceModel: transcriptionMode == .english ? "Parakeet" : "Whisper",
language: "en"
)
)
diff --git a/Sources/MiniWhisper/Models/CustomShortcut.swift b/Sources/MiniWhisper/Models/CustomShortcut.swift
index e92937a..f2e2f44 100644
--- a/Sources/MiniWhisper/Models/CustomShortcut.swift
+++ b/Sources/MiniWhisper/Models/CustomShortcut.swift
@@ -54,10 +54,10 @@ struct CustomShortcut: Codable, Equatable, Hashable {
var compactDisplayString: String {
var str = ""
- if control { str += "⌃" }
- if option { str += "⌥" }
- if shift { str += "⇧" }
- if command { str += "⌘" }
+ if control { str += "Ctrl+" }
+ if option { str += "Option+" }
+ if shift { str += "Shift+" }
+ if command { str += "Cmd+" }
if fn { str += "Fn+" }
str += keyCodeDisplayName
return str
@@ -215,7 +215,7 @@ final class CustomShortcutStorage {
static func defaultShortcuts() -> [CustomShortcutName: CustomShortcut] {
[
- .toggleRecording: CustomShortcut(keyCode: UInt16(kVK_ANSI_Grave), option: true), // Option + `
+ .toggleRecording: CustomShortcut(keyCode: UInt16(kVK_ANSI_R), option: true, shift: true),
.cancelRecording: CustomShortcut(keyCode: UInt16(kVK_Escape)), // Escape
]
}
diff --git a/Sources/MiniWhisper/Models/TranscriptionMode.swift b/Sources/MiniWhisper/Models/TranscriptionMode.swift
new file mode 100644
index 0000000..8c08f66
--- /dev/null
+++ b/Sources/MiniWhisper/Models/TranscriptionMode.swift
@@ -0,0 +1,22 @@
+import Foundation
+
+enum TranscriptionMode: String, Codable, Sendable {
+ case english
+ case multilingual
+}
+
+struct TranscriptionModeStorage: Sendable {
+ private static let storageKey = "TranscriptionMode"
+
+ static func load() -> TranscriptionMode {
+ guard let raw = UserDefaults.standard.string(forKey: storageKey),
+ let mode = TranscriptionMode(rawValue: raw) else {
+ return .english
+ }
+ return mode
+ }
+
+ static func save(_ mode: TranscriptionMode) {
+ UserDefaults.standard.set(mode.rawValue, forKey: storageKey)
+ }
+}
diff --git a/Sources/MiniWhisper/Services/ParakeetProvider.swift b/Sources/MiniWhisper/Services/ParakeetProvider.swift
index 18ba04b..016c578 100644
--- a/Sources/MiniWhisper/Services/ParakeetProvider.swift
+++ b/Sources/MiniWhisper/Services/ParakeetProvider.swift
@@ -9,6 +9,11 @@ final class ParakeetProvider {
var isInitialized: Bool { asrManager != nil }
+ func unload() {
+ asrManager = nil
+ initializationTask = nil
+ }
+
func initialize() async throws {
if asrManager != nil { return }
diff --git a/Sources/MiniWhisper/Services/WhisperProvider.swift b/Sources/MiniWhisper/Services/WhisperProvider.swift
new file mode 100644
index 0000000..23451d6
--- /dev/null
+++ b/Sources/MiniWhisper/Services/WhisperProvider.swift
@@ -0,0 +1,355 @@
+import Foundation
+@preconcurrency import AVFoundation
+import whisper
+
+struct WhisperTranscriptionOptions: Sendable {
+ let detectLanguage: Bool
+ let noTimestamps: Bool
+ let singleSegment: Bool
+ let threadCount: Int32
+
+ static func `default`() -> WhisperTranscriptionOptions {
+ WhisperTranscriptionOptions(
+ detectLanguage: false,
+ noTimestamps: true,
+ singleSegment: false,
+ threadCount: max(1, Int32(ProcessInfo.processInfo.activeProcessorCount - 2))
+ )
+ }
+}
+
+private final class AudioBufferInputState: @unchecked Sendable {
+ let buffer: AVAudioPCMBuffer
+ var consumed = false
+
+ init(buffer: AVAudioPCMBuffer) {
+ self.buffer = buffer
+ }
+}
+
+final class WhisperContext: @unchecked Sendable {
+ private let ctx: OpaquePointer
+
+ private init(ctx: OpaquePointer) {
+ self.ctx = ctx
+ }
+
+ static func load(from path: String) throws -> WhisperContext {
+ var params = whisper_context_default_params()
+ params.use_gpu = true
+
+ guard let ctx = whisper_init_from_file_with_params(path, params) else {
+ throw WhisperError.modelLoadFailed
+ }
+ return WhisperContext(ctx: ctx)
+ }
+
+ static func transcriptionOptions() -> WhisperTranscriptionOptions {
+ .default()
+ }
+
+ func transcribe(samples: [Float]) -> (text: String, language: String) {
+ let options = Self.transcriptionOptions()
+ var params = whisper_full_default_params(WHISPER_SAMPLING_GREEDY)
+ params.language = nil
+ params.detect_language = options.detectLanguage
+ params.print_special = false
+ params.print_progress = false
+ params.print_realtime = false
+ params.print_timestamps = false
+ params.no_timestamps = options.noTimestamps
+ params.single_segment = options.singleSegment
+ params.n_threads = options.threadCount
+
+ let result = samples.withUnsafeBufferPointer { buffer in
+ whisper_full(ctx, params, buffer.baseAddress, Int32(buffer.count))
+ }
+
+ guard result == 0 else {
+ return ("", "en")
+ }
+
+ let nSegments = whisper_full_n_segments(ctx)
+ var text = ""
+ for i in 0..?
+ private var downloadTask: URLSessionDownloadTask?
+
+ var isInitialized: Bool { context != nil }
+ var isDownloading = false
+ var downloadProgress: Double = 0.0
+
+ private static let modelFileName = "ggml-large-v3-turbo-q5_0.bin"
+ private static let modelURL = URL(string: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo-q5_0.bin")!
+
+ static var modelsDirectory: URL {
+ let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
+ return appSupport.appendingPathComponent("MiniWhisper/models")
+ }
+
+ static var modelPath: URL {
+ modelsDirectory.appendingPathComponent(modelFileName)
+ }
+
+ var modelExists: Bool {
+ FileManager.default.fileExists(atPath: Self.modelPath.path)
+ }
+
+ func initialize() async throws {
+ if context != nil { return }
+
+ if let existing = initTask {
+ try await existing.value
+ return
+ }
+
+ let task = Task {
+ if !modelExists {
+ try await downloadModel()
+ }
+
+ let path = Self.modelPath.path
+ let loaded = try WhisperContext.load(from: path)
+ context = loaded
+ }
+ initTask = task
+
+ do {
+ try await task.value
+ } catch {
+ initTask = nil
+ throw error
+ }
+ }
+
+ func transcribe(audioURL: URL) async throws -> TranscriptionResult {
+ if context == nil {
+ try await initialize()
+ }
+
+ guard let ctx = context else {
+ throw WhisperError.modelLoadFailed
+ }
+
+ let startTime = Date()
+ let samples = try resampleTo16kHz(audioURL: audioURL)
+ let capturedCtx = ctx
+ let result = await Task.detached {
+ capturedCtx.transcribe(samples: samples)
+ }.value
+ let processingTime = Date().timeIntervalSince(startTime)
+
+ let trimmed = result.text.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ return TranscriptionResult(
+ text: trimmed,
+ segments: [TranscriptionSegment(
+ start: 0,
+ end: processingTime,
+ text: trimmed,
+ words: nil
+ )],
+ language: result.language,
+ duration: processingTime,
+ model: "whisper-large-v3-turbo"
+ )
+ }
+
+ func unload() {
+ context = nil
+ initTask = nil
+ }
+
+ func cancelDownload() {
+ downloadTask?.cancel()
+ downloadTask = nil
+ isDownloading = false
+ downloadProgress = 0.0
+ initTask = nil
+ }
+
+ private func downloadModel() async throws {
+ let dir = Self.modelsDirectory
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+
+ isDownloading = true
+ downloadProgress = 0.0
+
+ defer {
+ isDownloading = false
+ downloadTask = nil
+ }
+
+ let delegate = WhisperDownloadDelegate()
+ delegate.onProgress = { [weak self] progress in
+ Task { @MainActor [weak self] in
+ self?.downloadProgress = progress
+ }
+ }
+
+ let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil)
+ let task = session.downloadTask(with: Self.modelURL)
+ downloadTask = task
+
+ let tempURL: URL = try await withCheckedThrowingContinuation { continuation in
+ delegate.onComplete = { result in
+ continuation.resume(with: result)
+ }
+ task.resume()
+ }
+
+ try FileManager.default.moveItem(at: tempURL, to: Self.modelPath)
+ session.invalidateAndCancel()
+ }
+
+ private nonisolated func resampleTo16kHz(audioURL: URL) throws -> [Float] {
+ let audioFile = try AVAudioFile(forReading: audioURL)
+
+ guard let inputFormat = AVAudioFormat(
+ commonFormat: .pcmFormatFloat32,
+ sampleRate: audioFile.fileFormat.sampleRate,
+ channels: 1,
+ interleaved: false
+ ) else {
+ throw WhisperError.resampleFailed
+ }
+
+ guard let outputFormat = AVAudioFormat(
+ commonFormat: .pcmFormatFloat32,
+ sampleRate: 16000,
+ channels: 1,
+ interleaved: false
+ ) else {
+ throw WhisperError.resampleFailed
+ }
+
+ let frameCount = AVAudioFrameCount(audioFile.length)
+ guard let inputBuffer = AVAudioPCMBuffer(pcmFormat: inputFormat, frameCapacity: frameCount) else {
+ throw WhisperError.resampleFailed
+ }
+
+ if audioFile.fileFormat.channelCount != 1 || audioFile.fileFormat.commonFormat != .pcmFormatFloat32 {
+ guard let converter = AVAudioConverter(from: audioFile.fileFormat, to: inputFormat) else {
+ throw WhisperError.resampleFailed
+ }
+ let readBuffer = AVAudioPCMBuffer(pcmFormat: audioFile.fileFormat, frameCapacity: frameCount)!
+ try audioFile.read(into: readBuffer)
+
+ inputBuffer.frameLength = frameCount
+ let inputState = AudioBufferInputState(buffer: readBuffer)
+ var error: NSError?
+ converter.convert(to: inputBuffer, error: &error) { _, outStatus in
+ if inputState.consumed {
+ outStatus.pointee = .endOfStream
+ return nil
+ }
+ inputState.consumed = true
+ outStatus.pointee = .haveData
+ return inputState.buffer
+ }
+ if let error { throw error }
+ } else {
+ try audioFile.read(into: inputBuffer)
+ }
+
+ if audioFile.fileFormat.sampleRate == 16000 {
+ let ptr = inputBuffer.floatChannelData![0]
+ return Array(UnsafeBufferPointer(start: ptr, count: Int(inputBuffer.frameLength)))
+ }
+
+ guard let resampler = AVAudioConverter(from: inputFormat, to: outputFormat) else {
+ throw WhisperError.resampleFailed
+ }
+
+ let ratio = 16000.0 / audioFile.fileFormat.sampleRate
+ let outputFrameCount = AVAudioFrameCount(Double(inputBuffer.frameLength) * ratio)
+ guard let outputBuffer = AVAudioPCMBuffer(pcmFormat: outputFormat, frameCapacity: outputFrameCount) else {
+ throw WhisperError.resampleFailed
+ }
+
+ let inputState = AudioBufferInputState(buffer: inputBuffer)
+ var error: NSError?
+ resampler.convert(to: outputBuffer, error: &error) { _, outStatus in
+ if inputState.consumed {
+ outStatus.pointee = .endOfStream
+ return nil
+ }
+ inputState.consumed = true
+ outStatus.pointee = .haveData
+ return inputState.buffer
+ }
+ if let error { throw error }
+
+ let ptr = outputBuffer.floatChannelData![0]
+ return Array(UnsafeBufferPointer(start: ptr, count: Int(outputBuffer.frameLength)))
+ }
+}
+
+final class WhisperDownloadDelegate: NSObject, URLSessionDownloadDelegate, Sendable {
+ nonisolated(unsafe) var onProgress: ((Double) -> Void)?
+ nonisolated(unsafe) var onComplete: ((Result) -> Void)?
+
+ func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
+ let tempDir = FileManager.default.temporaryDirectory
+ let tempFile = tempDir.appendingPathComponent(UUID().uuidString + ".bin")
+ do {
+ try FileManager.default.moveItem(at: location, to: tempFile)
+ onComplete?(.success(tempFile))
+ } catch {
+ onComplete?(.failure(error))
+ }
+ }
+
+ func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
+ guard totalBytesExpectedToWrite > 0 else { return }
+ let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
+ onProgress?(progress)
+ }
+
+ func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) {
+ if let error {
+ onComplete?(.failure(error))
+ }
+ }
+}
diff --git a/Sources/MiniWhisper/Views/MenuBarView.swift b/Sources/MiniWhisper/Views/MenuBarView.swift
index f0b1088..debd273 100644
--- a/Sources/MiniWhisper/Views/MenuBarView.swift
+++ b/Sources/MiniWhisper/Views/MenuBarView.swift
@@ -65,6 +65,16 @@ private struct RecordingHeaderView: View {
Text(formatDuration(appState.recorder.currentDuration))
.font(.system(size: 11, design: .monospaced))
.foregroundColor(.secondary)
+ } else if appState.isModelDownloading {
+ HStack(spacing: 6) {
+ ProgressView(value: appState.modelDownloadProgress)
+ .progressViewStyle(.linear)
+ .frame(width: 80)
+ .controlSize(.small)
+ Text("\(Int(appState.modelDownloadProgress * 100))%")
+ .font(.system(size: 10, design: .monospaced))
+ .foregroundColor(.secondary)
+ }
} else if !appState.isModelLoaded {
ProgressView()
.controlSize(.small)
@@ -102,6 +112,7 @@ private struct RecordingHeaderView: View {
}
switch appState.recorder.state {
case .idle:
+ if appState.isModelDownloading { return "Downloading Model..." }
return appState.isModelLoaded ? "Ready" : "Loading Model..."
case .recording: return "Recording"
case .processing: return "Transcribing..."
@@ -352,11 +363,26 @@ private struct FooterBarView: View {
@Environment(AppState.self) private var appState
@State private var showHistory = false
@State private var showReplacements = false
+ @State private var showModelPicker = false
var body: some View {
HStack(spacing: 16) {
Spacer()
+ Button {
+ showModelPicker.toggle()
+ } label: {
+ Image(systemName: appState.transcriptionMode == .multilingual ? "globe" : "waveform")
+ .font(.system(size: 14))
+ .foregroundColor(appState.transcriptionMode == .multilingual ? .accentColor : .secondary)
+ .frame(width: 28, height: 28)
+ }
+ .buttonStyle(.plain)
+ .help("Transcription Model")
+ .popover(isPresented: $showModelPicker, arrowEdge: .bottom) {
+ ModelPickerView()
+ }
+
Button {
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: Recording.baseDirectory.deletingLastPathComponent().path)
} label: {
diff --git a/Sources/MiniWhisper/Views/ModelPickerView.swift b/Sources/MiniWhisper/Views/ModelPickerView.swift
new file mode 100644
index 0000000..a376981
--- /dev/null
+++ b/Sources/MiniWhisper/Views/ModelPickerView.swift
@@ -0,0 +1,102 @@
+import SwiftUI
+
+struct ModelPickerView: View {
+ @Environment(AppState.self) private var appState
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Transcription Model")
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundColor(.secondary)
+ .textCase(.uppercase)
+ .tracking(0.5)
+ .padding(.horizontal, 10)
+
+ VStack(spacing: 2) {
+ ModelRow(
+ icon: "bolt.fill",
+ title: "English Only",
+ subtitle: "Fast · English",
+ badge: nil,
+ isSelected: appState.transcriptionMode == .english
+ ) {
+ appState.switchTranscriptionMode(to: .english)
+ }
+
+ ModelRow(
+ icon: "globe",
+ title: "Multilingual",
+ subtitle: "Auto-detect language",
+ badge: appState.whisper.modelExists ? nil : "547 MB",
+ isSelected: appState.transcriptionMode == .multilingual
+ ) {
+ appState.switchTranscriptionMode(to: .multilingual)
+ }
+ }
+ }
+ .padding(12)
+ .frame(width: 260)
+ }
+}
+
+private struct ModelRow: View {
+ let icon: String
+ let title: String
+ let subtitle: String
+ let badge: String?
+ let isSelected: Bool
+ let action: () -> Void
+ @State private var isHovering = false
+
+ var body: some View {
+ Button(action: action) {
+ HStack(spacing: 8) {
+ Image(systemName: icon)
+ .font(.system(size: 13))
+ .foregroundColor(isSelected ? .accentColor : .secondary)
+ .frame(width: 20)
+
+ VStack(alignment: .leading, spacing: 1) {
+ Text(title)
+ .font(.system(size: 13, weight: isSelected ? .semibold : .regular))
+ Text(subtitle)
+ .font(.system(size: 10))
+ .foregroundColor(.secondary)
+ }
+
+ Spacer()
+
+ if let badge {
+ Text(badge)
+ .font(.system(size: 10, weight: .medium))
+ .foregroundColor(.secondary)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background(
+ RoundedRectangle(cornerRadius: 4)
+ .fill(Color.primary.opacity(0.06))
+ )
+ }
+
+ if isSelected {
+ Image(systemName: "checkmark")
+ .font(.system(size: 11, weight: .semibold))
+ .foregroundColor(.accentColor)
+ }
+ }
+ .padding(.horizontal, 10)
+ .padding(.vertical, 8)
+ .background(
+ RoundedRectangle(cornerRadius: 10)
+ .fill(isHovering ? Color.primary.opacity(0.06) : Color.clear)
+ )
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .onHover { hovering in
+ withAnimation(.easeInOut(duration: 0.12)) {
+ isHovering = hovering
+ }
+ }
+ }
+}
diff --git a/Tests/MiniWhisperTests/CustomShortcutTests.swift b/Tests/MiniWhisperTests/CustomShortcutTests.swift
index 6c5b475..272128d 100644
--- a/Tests/MiniWhisperTests/CustomShortcutTests.swift
+++ b/Tests/MiniWhisperTests/CustomShortcutTests.swift
@@ -37,7 +37,7 @@ struct CustomShortcutMatchTests {
struct CustomShortcutDisplayTests {
@Test func optionGraveDisplayString() {
let shortcut = CustomShortcut(keyCode: UInt16(kVK_ANSI_Grave), option: true)
- #expect(shortcut.compactDisplayString == "⌥`")
+ #expect(shortcut.compactDisplayString == "Option+`")
}
@Test func allModifiersDisplayInOrder() {
@@ -45,7 +45,7 @@ struct CustomShortcutDisplayTests {
keyCode: UInt16(kVK_ANSI_K),
command: true, option: true, control: true, shift: true
)
- #expect(shortcut.compactDisplayString == "⌃⌥⇧⌘K")
+ #expect(shortcut.compactDisplayString == "Ctrl+Option+Shift+Cmd+K")
}
@Test func fnPrefixDisplayString() {
diff --git a/Tests/MiniWhisperTests/WhisperProviderTests.swift b/Tests/MiniWhisperTests/WhisperProviderTests.swift
new file mode 100644
index 0000000..82cc2d2
--- /dev/null
+++ b/Tests/MiniWhisperTests/WhisperProviderTests.swift
@@ -0,0 +1,13 @@
+import Testing
+@testable import MiniWhisper
+
+struct WhisperProviderTests {
+ @Test func transcriptionUsesAutoDetectWithoutDetectionOnlyMode() {
+ let options = WhisperContext.transcriptionOptions()
+
+ #expect(!options.detectLanguage)
+ #expect(options.noTimestamps)
+ #expect(!options.singleSegment)
+ #expect(options.threadCount >= 1)
+ }
+}
diff --git a/justfile b/justfile
index 1b11b6f..b0fdd2a 100644
--- a/justfile
+++ b/justfile
@@ -1,6 +1,7 @@
app_name := "MiniWhisper"
bundle_id := "com.miniwhisper.dev"
signing_id := env("CODESIGN_IDENTITY", "-")
+dev_signing_id := env("DEV_CODESIGN_IDENTITY", "-")
team_id := env("CODESIGN_TEAM_ID", "")
install_path := "/Applications/MiniWhisper Dev.app"
@@ -14,16 +15,13 @@ dev: kill build package
set -euo pipefail
rm -rf "{{install_path}}"
cp -R build/{{app_name}}.app "{{install_path}}"
- if [[ -n "{{team_id}}" ]]; then
- codesign --force --sign "{{signing_id}}" \
+ # Sign embedded frameworks
+ for fw in "{{install_path}}"/Contents/Frameworks/*.framework; do
+ [ -d "$fw" ] && codesign --force --sign "{{dev_signing_id}}" "$fw"
+ done
+ codesign --force --sign "{{dev_signing_id}}" \
--entitlements build/MiniWhisper.entitlements \
- -r='designated => anchor apple generic and identifier "{{bundle_id}}" and certificate leaf[subject.OU] = "{{team_id}}"' \
"{{install_path}}"
- else
- codesign --force --sign "{{signing_id}}" \
- --entitlements build/MiniWhisper.entitlements \
- "{{install_path}}"
- fi
rm -rf build/{{app_name}}.app
open "{{install_path}}"