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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MiniWhisper + 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. ![macOS 14.0+](https://img.shields.io/badge/macOS-14.0%2B-blue) ![Swift 6.0+](https://img.shields.io/badge/Swift-6.0%2B-orange) @@ -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}}"