Commitok összehasonlítása
7 Commit-ok
865b194eeb
...
main
| Szerző | SHA1 | Dátum | |
|---|---|---|---|
|
|
29d0c0e503 | ||
|
|
fdf8bf9743 | ||
|
|
aa241f55c8 | ||
|
|
913ce5ca1a | ||
|
|
636a4acf97 | ||
|
|
4c680f9e4d | ||
|
|
21be80ddb4 |
17
README.md
17
README.md
@@ -12,17 +12,26 @@ A simple menu bar app for voice dictation using OpenAI Whisper (local, offline).
|
|||||||
|
|
||||||
## macOS
|
## macOS
|
||||||
|
|
||||||
|
### What's new in 1.3.0
|
||||||
|
|
||||||
|
- ⚡ **Warm transcription server** — the Whisper model stays loaded in a background `whisper-server`, so each dictation is a fast local call (~0.5s) instead of reloading the 1.5–1.6 GB model on every utterance
|
||||||
|
- 📊 **Live mic-level HUD** while recording, which warns ("No audio input?") if the microphone stays silent
|
||||||
|
- 📖 **Custom dictionary** — an editable *Heard → Replacement* table that fixes names/jargon in the transcript and also biases recognition
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- 🎤 Global hotkey (⌃⌥D) to start/stop recording
|
- 🎤 Global hotkey (⌃⌥D) to start/stop recording
|
||||||
|
- ⚡ Fast transcription via a warm whisper-server (model stays loaded — no per-dictation reload), with automatic fallback to `whisper-cli`
|
||||||
|
- 📊 Live mic-level meter while recording, with a "no audio input" warning
|
||||||
|
- 📖 Custom dictionary (heard → replacement) that also biases recognition
|
||||||
- 🔒 Fully offline - uses local Whisper model
|
- 🔒 Fully offline - uses local Whisper model
|
||||||
- ⚡ Automatic paste into any focused app
|
- ⌨️ Automatic paste into any focused app
|
||||||
- 📋 Clipboard preservation - your copied content is restored after paste
|
- 📋 Clipboard preservation - your copied content is restored after paste
|
||||||
- ⚙️ Settings window with model selection dropdown
|
- ⚙️ Settings window with model selection dropdown
|
||||||
- 📥 Built-in model downloader with progress indicator
|
- 📥 Built-in model downloader with progress indicator
|
||||||
- 🚀 Launch at login support
|
- 🚀 Launch at login support
|
||||||
- 🔊 Sound feedback (optional)
|
- 🔊 Sound feedback (optional)
|
||||||
- 📦 Self-contained - whisper-cli bundled in app
|
- 📦 Self-contained - whisper-cli and whisper-server bundled in the app
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
@@ -75,6 +84,7 @@ make install
|
|||||||
Click the menu bar icon → Settings to configure:
|
Click the menu bar icon → Settings to configure:
|
||||||
- **Language**: Auto-detect or 31 supported languages
|
- **Language**: Auto-detect or 31 supported languages
|
||||||
- **Model**: Select from installed models or download new ones
|
- **Model**: Select from installed models or download new ones
|
||||||
|
- **Dictionary**: Add *Heard → Replacement* pairs to fix names/jargon (use `\n` for a newline); the replacement terms also improve recognition
|
||||||
- **Sound feedback**: Toggle audio feedback on/off
|
- **Sound feedback**: Toggle audio feedback on/off
|
||||||
- **Launch at login**: Start automatically when you log in
|
- **Launch at login**: Start automatically when you log in
|
||||||
|
|
||||||
@@ -93,6 +103,8 @@ Download models directly from the app or manually:
|
|||||||
|
|
||||||
Models are stored in `~/.whisper-models/`
|
Models are stored in `~/.whisper-models/`
|
||||||
|
|
||||||
|
> With the warm server (1.3.0+), the selected model loads once at startup (a few seconds); after that each dictation skips the reload, so the speeds above are roughly the per-dictation transcription time.
|
||||||
|
|
||||||
### Audio Feedback
|
### Audio Feedback
|
||||||
|
|
||||||
- 🔔 **Tink** - Recording started
|
- 🔔 **Tink** - Recording started
|
||||||
@@ -114,6 +126,7 @@ Grant these in System Settings → Privacy & Security:
|
|||||||
## Security
|
## Security
|
||||||
|
|
||||||
- All processing is done locally - no data leaves your device
|
- All processing is done locally - no data leaves your device
|
||||||
|
- The bundled `whisper-server` is bound to `127.0.0.1` (loopback only) and is never exposed on the network
|
||||||
- Audio files are stored in private temp directory and deleted after transcription
|
- Audio files are stored in private temp directory and deleted after transcription
|
||||||
- Clipboard is cleared after paste (transcript doesn't remain accessible)
|
- Clipboard is cleared after paste (transcript doesn't remain accessible)
|
||||||
- Original clipboard content is preserved and restored after paste
|
- Original clipboard content is preserved and restored after paste
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
APP_NAME = WhisperDictate
|
APP_NAME = WhisperDictate
|
||||||
APP_BUNDLE = $(APP_NAME).app
|
APP_BUNDLE = $(APP_NAME).app
|
||||||
VERSION = 1.2.1
|
VERSION = 1.3.0
|
||||||
|
|
||||||
# Directories
|
# Directories
|
||||||
SRC_DIR = src
|
SRC_DIR = src
|
||||||
|
|||||||
@@ -19,13 +19,18 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.2.1</string>
|
<string>1.3.0</string>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>2</string>
|
<string>3</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>13.0</string>
|
<string>13.0</string>
|
||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsLocalNetworking</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
<key>NSAppleEventsUsageDescription</key>
|
<key>NSAppleEventsUsageDescription</key>
|
||||||
<string>WhisperDictate needs accessibility access to paste transcribed text.</string>
|
<string>WhisperDictate needs accessibility access to paste transcribed text.</string>
|
||||||
<key>NSHighResolutionCapable</key>
|
<key>NSHighResolutionCapable</key>
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ mkdir -p "$FRAMEWORKS_DIR"
|
|||||||
cp "$WHISPER_CLI_REAL" "$MACOS_DIR/whisper-cli"
|
cp "$WHISPER_CLI_REAL" "$MACOS_DIR/whisper-cli"
|
||||||
chmod +x "$MACOS_DIR/whisper-cli"
|
chmod +x "$MACOS_DIR/whisper-cli"
|
||||||
|
|
||||||
|
# Copy whisper-server (warm, preloaded-model HTTP backend) from the same bin dir
|
||||||
|
WHISPER_BIN_DIR=$(dirname "$WHISPER_CLI_REAL")
|
||||||
|
WHISPER_SERVER_REAL="$WHISPER_BIN_DIR/whisper-server"
|
||||||
|
if [ -f "$WHISPER_SERVER_REAL" ]; then
|
||||||
|
cp "$WHISPER_SERVER_REAL" "$MACOS_DIR/whisper-server"
|
||||||
|
chmod +x "$MACOS_DIR/whisper-server"
|
||||||
|
echo "Copied: whisper-server"
|
||||||
|
else
|
||||||
|
echo "Warning: whisper-server not found at $WHISPER_SERVER_REAL"
|
||||||
|
fi
|
||||||
|
|
||||||
# List of dylibs to copy
|
# List of dylibs to copy
|
||||||
DYLIBS=(
|
DYLIBS=(
|
||||||
"libwhisper.1.dylib"
|
"libwhisper.1.dylib"
|
||||||
@@ -49,9 +60,12 @@ for dylib in "${DYLIBS[@]}"; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Fix dylib paths in whisper-cli
|
# Fix dylib paths in the bundled executables (whisper-cli + whisper-server)
|
||||||
for dylib in "${DYLIBS[@]}"; do
|
for exe in whisper-cli whisper-server; do
|
||||||
install_name_tool -change "@rpath/$dylib" "@executable_path/../Frameworks/$dylib" "$MACOS_DIR/whisper-cli" 2>/dev/null || true
|
[ -f "$MACOS_DIR/$exe" ] || continue
|
||||||
|
for dylib in "${DYLIBS[@]}"; do
|
||||||
|
install_name_tool -change "@rpath/$dylib" "@executable_path/../Frameworks/$dylib" "$MACOS_DIR/$exe" 2>/dev/null || true
|
||||||
|
done
|
||||||
done
|
done
|
||||||
|
|
||||||
# Fix dylib paths in each dylib (they reference each other)
|
# Fix dylib paths in each dylib (they reference each other)
|
||||||
@@ -67,8 +81,10 @@ for dylib in "${DYLIBS[@]}"; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
# Sign everything
|
# Sign everything (ad-hoc; the release flow re-signs with Developer ID afterward)
|
||||||
codesign --force --sign - "$MACOS_DIR/whisper-cli" 2>/dev/null || true
|
for exe in whisper-cli whisper-server; do
|
||||||
|
[ -f "$MACOS_DIR/$exe" ] && codesign --force --sign - "$MACOS_DIR/$exe" 2>/dev/null || true
|
||||||
|
done
|
||||||
for dylib in "${DYLIBS[@]}"; do
|
for dylib in "${DYLIBS[@]}"; do
|
||||||
codesign --force --sign - "$FRAMEWORKS_DIR/$dylib" 2>/dev/null || true
|
codesign --force --sign - "$FRAMEWORKS_DIR/$dylib" 2>/dev/null || true
|
||||||
done
|
done
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import AVFoundation
|
|||||||
import Carbon.HIToolbox
|
import Carbon.HIToolbox
|
||||||
import ServiceManagement
|
import ServiceManagement
|
||||||
import ApplicationServices
|
import ApplicationServices
|
||||||
|
import Darwin
|
||||||
|
|
||||||
// MARK: - User Defaults Keys
|
// MARK: - User Defaults Keys
|
||||||
struct Defaults {
|
struct Defaults {
|
||||||
static let language = "whisperLanguage"
|
static let language = "whisperLanguage"
|
||||||
static let modelPath = "whisperModelPath"
|
static let modelPath = "whisperModelPath"
|
||||||
static let playSounds = "playSounds"
|
static let playSounds = "playSounds"
|
||||||
|
static let replacements = "customReplacements"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Whisper Models
|
// MARK: - Whisper Models
|
||||||
@@ -150,12 +152,250 @@ struct SupportedLanguages {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Whisper Server (warm, preloaded-model backend)
|
||||||
|
// Keeps a whisper-server child process alive with the model already loaded, so
|
||||||
|
// each dictation is a fast local HTTP call (~0.5s) instead of a multi-second
|
||||||
|
// model + Metal reload on every utterance (what plain whisper-cli does).
|
||||||
|
final class WhisperServer {
|
||||||
|
private let serverPath: String
|
||||||
|
private let modelPath: String
|
||||||
|
private let threads: Int
|
||||||
|
private var process: Process?
|
||||||
|
private var stopping = false
|
||||||
|
private(set) var port: Int = 0
|
||||||
|
private(set) var isReady = false
|
||||||
|
|
||||||
|
init(serverPath: String, modelPath: String, threads: Int) {
|
||||||
|
self.serverPath = serverPath
|
||||||
|
self.modelPath = modelPath
|
||||||
|
self.threads = threads
|
||||||
|
}
|
||||||
|
|
||||||
|
func start(onReady: @escaping (Bool) -> Void) {
|
||||||
|
port = WhisperServer.findFreePort()
|
||||||
|
let task = Process()
|
||||||
|
task.executableURL = URL(fileURLWithPath: serverPath)
|
||||||
|
task.arguments = [
|
||||||
|
"-m", modelPath,
|
||||||
|
"--host", "127.0.0.1",
|
||||||
|
"--port", String(port),
|
||||||
|
"-t", String(threads),
|
||||||
|
"-nt", // no timestamps -> plain text response
|
||||||
|
"-sns", // suppress non-speech tokens -> fewer hallucinations
|
||||||
|
]
|
||||||
|
task.standardOutput = FileHandle.nullDevice
|
||||||
|
task.standardError = FileHandle.nullDevice
|
||||||
|
task.terminationHandler = { [weak self] _ in self?.isReady = false }
|
||||||
|
do {
|
||||||
|
try task.run()
|
||||||
|
process = task
|
||||||
|
} catch {
|
||||||
|
NSLog("WhisperServer: failed to launch: \(error)")
|
||||||
|
onReady(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Model load can take ~10s (incl. Metal init); poll the port off-thread.
|
||||||
|
DispatchQueue.global(qos: .userInitiated).async { [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
let deadline = Date().addingTimeInterval(120)
|
||||||
|
while Date() < deadline {
|
||||||
|
if self.stopping || !(self.process?.isRunning ?? false) { break }
|
||||||
|
if WhisperServer.canConnect(port: self.port) {
|
||||||
|
self.isReady = true
|
||||||
|
onReady(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Thread.sleep(forTimeInterval: 0.3)
|
||||||
|
}
|
||||||
|
onReady(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stop() {
|
||||||
|
stopping = true
|
||||||
|
isReady = false
|
||||||
|
process?.terminationHandler = nil
|
||||||
|
process?.terminate()
|
||||||
|
process = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Synchronous transcription via the warm server. Call OFF the main thread.
|
||||||
|
func transcribe(wavPath: String, language: String, prompt: String?) -> String? {
|
||||||
|
guard isReady,
|
||||||
|
let url = URL(string: "http://127.0.0.1:\(port)/inference"),
|
||||||
|
let wavData = try? Data(contentsOf: URL(fileURLWithPath: wavPath)) else { return nil }
|
||||||
|
|
||||||
|
let boundary = "----WhisperDictate\(UInt32.random(in: 0 ... .max))"
|
||||||
|
var req = URLRequest(url: url)
|
||||||
|
req.httpMethod = "POST"
|
||||||
|
req.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||||
|
req.timeoutInterval = 120
|
||||||
|
|
||||||
|
var body = Data()
|
||||||
|
let nl = "\r\n"
|
||||||
|
func raw(_ s: String) { body.append(s.data(using: .utf8)!) }
|
||||||
|
// audio file part
|
||||||
|
raw("--\(boundary)\(nl)")
|
||||||
|
raw("Content-Disposition: form-data; name=\"file\"; filename=\"audio.wav\"\(nl)")
|
||||||
|
raw("Content-Type: audio/wav\(nl)\(nl)")
|
||||||
|
body.append(wavData)
|
||||||
|
raw(nl)
|
||||||
|
// text fields
|
||||||
|
func field(_ name: String, _ value: String) {
|
||||||
|
raw("--\(boundary)\(nl)")
|
||||||
|
raw("Content-Disposition: form-data; name=\"\(name)\"\(nl)\(nl)")
|
||||||
|
raw("\(value)\(nl)")
|
||||||
|
}
|
||||||
|
field("response_format", "json")
|
||||||
|
field("temperature", "0")
|
||||||
|
if language.lowercased() != SupportedLanguages.autoDetect { field("language", language.lowercased()) }
|
||||||
|
if let prompt = prompt, !prompt.isEmpty { field("prompt", prompt) }
|
||||||
|
raw("--\(boundary)--\(nl)")
|
||||||
|
req.httpBody = body
|
||||||
|
|
||||||
|
let sem = DispatchSemaphore(value: 0)
|
||||||
|
var result: String?
|
||||||
|
URLSession.shared.dataTask(with: req) { data, _, error in
|
||||||
|
defer { sem.signal() }
|
||||||
|
if let error = error { NSLog("WhisperServer: request error: \(error)"); return }
|
||||||
|
guard let data = data,
|
||||||
|
let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||||
|
let text = obj["text"] as? String else { return }
|
||||||
|
result = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
}.resume()
|
||||||
|
sem.wait()
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: socket helpers (find a free loopback port + readiness probe)
|
||||||
|
static func findFreePort() -> Int {
|
||||||
|
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
||||||
|
if fd < 0 { return 8123 }
|
||||||
|
defer { close(fd) }
|
||||||
|
var addr = sockaddr_in()
|
||||||
|
addr.sin_family = sa_family_t(AF_INET)
|
||||||
|
addr.sin_addr.s_addr = inet_addr("127.0.0.1")
|
||||||
|
addr.sin_port = 0
|
||||||
|
let bound = withUnsafePointer(to: &addr) {
|
||||||
|
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||||
|
Darwin.bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard bound == 0 else { return 8123 }
|
||||||
|
var len = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||||||
|
_ = withUnsafeMutablePointer(to: &addr) {
|
||||||
|
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||||
|
getsockname(fd, $0, &len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Int(UInt16(bigEndian: addr.sin_port))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func canConnect(port: Int) -> Bool {
|
||||||
|
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
||||||
|
if fd < 0 { return false }
|
||||||
|
defer { close(fd) }
|
||||||
|
var addr = sockaddr_in()
|
||||||
|
addr.sin_family = sa_family_t(AF_INET)
|
||||||
|
addr.sin_port = in_port_t(UInt16(port)).bigEndian
|
||||||
|
addr.sin_addr.s_addr = inet_addr("127.0.0.1")
|
||||||
|
let r = withUnsafePointer(to: &addr) {
|
||||||
|
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||||
|
Darwin.connect(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Recording HUD (live mic level + silence warning)
|
||||||
|
// A small floating, non-interactive panel shown while recording. Doubles as a
|
||||||
|
// diagnostic: if the input stays silent it flags "no input" — exactly the
|
||||||
|
// failure that silently produced empty recordings + whisper hallucinations.
|
||||||
|
final class MeterView: NSView {
|
||||||
|
private var level: CGFloat = 0
|
||||||
|
private var silent = false
|
||||||
|
|
||||||
|
func set(level: CGFloat, silent: Bool) {
|
||||||
|
self.level = max(0, min(1, level))
|
||||||
|
self.silent = silent
|
||||||
|
needsDisplay = true
|
||||||
|
}
|
||||||
|
func reset() { level = 0; silent = false; needsDisplay = true }
|
||||||
|
|
||||||
|
override func draw(_ dirtyRect: NSRect) {
|
||||||
|
let bg = NSBezierPath(roundedRect: bounds, xRadius: 12, yRadius: 12)
|
||||||
|
NSColor(calibratedWhite: 0.10, alpha: 0.93).setFill()
|
||||||
|
bg.fill()
|
||||||
|
|
||||||
|
let label = silent ? "🎤 No audio input?" : "🎤 Recording…"
|
||||||
|
label.draw(at: NSPoint(x: 16, y: bounds.height - 26), withAttributes: [
|
||||||
|
.font: NSFont.systemFont(ofSize: 12, weight: .medium),
|
||||||
|
.foregroundColor: silent ? NSColor.systemRed : NSColor.white
|
||||||
|
])
|
||||||
|
|
||||||
|
let segs = 20
|
||||||
|
let margin: CGFloat = 16, gap: CGFloat = 3, segH: CGFloat = 14, y: CGFloat = 14
|
||||||
|
let segW = (bounds.width - margin * 2 - gap * CGFloat(segs - 1)) / CGFloat(segs)
|
||||||
|
let lit = Int((level * CGFloat(segs)).rounded())
|
||||||
|
for i in 0..<segs {
|
||||||
|
let rect = NSRect(x: margin + CGFloat(i) * (segW + gap), y: y, width: segW, height: segH)
|
||||||
|
let path = NSBezierPath(roundedRect: rect, xRadius: 2, yRadius: 2)
|
||||||
|
if i < lit {
|
||||||
|
let frac = CGFloat(i) / CGFloat(segs)
|
||||||
|
(silent ? NSColor.systemRed
|
||||||
|
: frac > 0.85 ? .systemRed
|
||||||
|
: frac > 0.6 ? .systemYellow : .systemGreen).setFill()
|
||||||
|
} else {
|
||||||
|
NSColor(calibratedWhite: 1, alpha: 0.12).setFill()
|
||||||
|
}
|
||||||
|
path.fill()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class RecordingHUD {
|
||||||
|
private var window: NSWindow?
|
||||||
|
private let meter = MeterView(frame: NSRect(x: 0, y: 0, width: 260, height: 72))
|
||||||
|
|
||||||
|
func show() {
|
||||||
|
if window == nil {
|
||||||
|
let w = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 260, height: 72),
|
||||||
|
styleMask: [.borderless, .nonactivatingPanel],
|
||||||
|
backing: .buffered, defer: false)
|
||||||
|
w.isOpaque = false
|
||||||
|
w.backgroundColor = .clear
|
||||||
|
w.hasShadow = true
|
||||||
|
w.level = .statusBar
|
||||||
|
w.ignoresMouseEvents = true
|
||||||
|
w.isFloatingPanel = true
|
||||||
|
w.collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary, .ignoresCycle]
|
||||||
|
w.contentView = meter
|
||||||
|
window = w
|
||||||
|
}
|
||||||
|
if let screen = NSScreen.main, let window = window {
|
||||||
|
let vf = screen.visibleFrame
|
||||||
|
window.setFrameOrigin(NSPoint(x: vf.midX - window.frame.width / 2, y: vf.minY + 120))
|
||||||
|
}
|
||||||
|
meter.reset()
|
||||||
|
window?.orderFrontRegardless()
|
||||||
|
}
|
||||||
|
|
||||||
|
func update(level: CGFloat, silent: Bool) { meter.set(level: level, silent: silent) }
|
||||||
|
func hide() { window?.orderOut(nil) }
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - App Delegate
|
// MARK: - App Delegate
|
||||||
class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
|
class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate, NSTableViewDataSource, NSTableViewDelegate, NSTextFieldDelegate, NSWindowDelegate {
|
||||||
var statusItem: NSStatusItem!
|
var statusItem: NSStatusItem!
|
||||||
var audioRecorder: AVAudioRecorder?
|
var audioRecorder: AVAudioRecorder?
|
||||||
var isRecording = false
|
var isRecording = false
|
||||||
var settingsWindowController: NSWindowController?
|
var settingsWindowController: NSWindowController?
|
||||||
|
var whisperServer: WhisperServer?
|
||||||
|
let recordingHUD = RecordingHUD()
|
||||||
|
var meterTimer: Timer?
|
||||||
|
var silenceStart: Date?
|
||||||
|
weak var dictTable: NSTableView?
|
||||||
|
|
||||||
// Use private temp directory with unique filename
|
// Use private temp directory with unique filename
|
||||||
var audioFilePath: String {
|
var audioFilePath: String {
|
||||||
@@ -178,19 +418,51 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
|
|||||||
set { UserDefaults.standard.set(newValue, forKey: Defaults.playSounds) }
|
set { UserDefaults.standard.set(newValue, forKey: Defaults.playSounds) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Custom dictionary: ordered list of ["from": recognised, "to": replacement].
|
||||||
|
var replacements: [[String: String]] {
|
||||||
|
get { (UserDefaults.standard.array(forKey: Defaults.replacements) as? [[String: String]]) ?? [] }
|
||||||
|
set { UserDefaults.standard.set(newValue, forKey: Defaults.replacements) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whisper initial prompt built from the desired ("to") terms, to bias
|
||||||
|
// recognition toward the user's names/jargon. Returns nil if empty.
|
||||||
|
func customPrompt() -> String? {
|
||||||
|
let terms = replacements.compactMap { $0["to"] }
|
||||||
|
.map { $0.replacingOccurrences(of: "\\n", with: " ") }
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
.filter { !$0.isEmpty }
|
||||||
|
return terms.isEmpty ? nil : terms.joined(separator: ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the dictionary find/replace pairs to a transcript. "\n" in a
|
||||||
|
// replacement becomes a real newline (e.g. spoken-command → line break).
|
||||||
|
func applyReplacements(_ text: String) -> String {
|
||||||
|
var result = text
|
||||||
|
for pair in replacements {
|
||||||
|
guard let from = pair["from"], !from.isEmpty else { continue }
|
||||||
|
let to = (pair["to"] ?? "").replacingOccurrences(of: "\\n", with: "\n")
|
||||||
|
result = result.replacingOccurrences(of: from, with: to, options: [.caseInsensitive])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
setupStatusItem()
|
setupStatusItem()
|
||||||
registerHotkey()
|
registerHotkey()
|
||||||
requestMicrophonePermission()
|
requestMicrophonePermission()
|
||||||
checkAccessibilityPermission()
|
|
||||||
|
|
||||||
// First-run: check if model exists, if not show setup wizard
|
// Start warming the model server BEFORE the (blocking) accessibility
|
||||||
|
// prompt, so the multi-second model load overlaps with the user
|
||||||
|
// granting permissions instead of running after it.
|
||||||
if !hasAnyModel() {
|
if !hasAnyModel() {
|
||||||
showFirstRunWizard()
|
showFirstRunWizard()
|
||||||
} else {
|
} else {
|
||||||
checkModelExists()
|
checkModelExists()
|
||||||
|
startWhisperServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
checkAccessibilityPermission()
|
||||||
|
|
||||||
NSLog("WhisperDictate started. Press ⌃⌥D to toggle recording.")
|
NSLog("WhisperDictate started. Press ⌃⌥D to toggle recording.")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,6 +559,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
|
|||||||
updateStatus("Ready - \(model.name)")
|
updateStatus("Ready - \(model.name)")
|
||||||
if playSounds { NSSound(named: "Glass")?.play() }
|
if playSounds { NSSound(named: "Glass")?.play() }
|
||||||
NSLog("Model downloaded: \(model.name)")
|
NSLog("Model downloaded: \(model.name)")
|
||||||
|
startWhisperServer()
|
||||||
} catch {
|
} catch {
|
||||||
statusItem.button?.title = "🎤"
|
statusItem.button?.title = "🎤"
|
||||||
updateStatus("⚠️ Save failed")
|
updateStatus("⚠️ Save failed")
|
||||||
@@ -310,6 +583,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
|
whisperServer?.stop()
|
||||||
cleanupAudioFile()
|
cleanupAudioFile()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,17 +624,18 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
|
|||||||
|
|
||||||
func createSettingsWindow() -> NSWindow {
|
func createSettingsWindow() -> NSWindow {
|
||||||
let window = NSWindow(
|
let window = NSWindow(
|
||||||
contentRect: NSRect(x: 0, y: 0, width: 450, height: 280),
|
contentRect: NSRect(x: 0, y: 0, width: 450, height: 540),
|
||||||
styleMask: [.titled, .closable],
|
styleMask: [.titled, .closable],
|
||||||
backing: .buffered,
|
backing: .buffered,
|
||||||
defer: false
|
defer: false
|
||||||
)
|
)
|
||||||
window.title = "WhisperDictate Settings"
|
window.title = "WhisperDictate Settings"
|
||||||
window.center()
|
window.center()
|
||||||
|
window.delegate = self
|
||||||
|
|
||||||
let contentView = NSView(frame: window.contentView!.bounds)
|
let contentView = NSView(frame: window.contentView!.bounds)
|
||||||
|
|
||||||
var y: CGFloat = 230
|
var y: CGFloat = 498
|
||||||
let labelWidth: CGFloat = 120
|
let labelWidth: CGFloat = 120
|
||||||
let controlX: CGFloat = 140
|
let controlX: CGFloat = 140
|
||||||
let controlWidth: CGFloat = 280
|
let controlWidth: CGFloat = 280
|
||||||
@@ -453,6 +728,44 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
|
|||||||
loginCheck.state = isLaunchAtLoginEnabled() ? .on : .off
|
loginCheck.state = isLaunchAtLoginEnabled() ? .on : .off
|
||||||
contentView.addSubview(loginCheck)
|
contentView.addSubview(loginCheck)
|
||||||
|
|
||||||
|
// Custom dictionary (recognised -> replacement)
|
||||||
|
let dictLabel = NSTextField(labelWithString: "Dictionary (heard → replacement):")
|
||||||
|
dictLabel.frame = NSRect(x: 20, y: 304, width: 300, height: 20)
|
||||||
|
contentView.addSubview(dictLabel)
|
||||||
|
|
||||||
|
let scroll = NSScrollView(frame: NSRect(x: 20, y: 104, width: 410, height: 192))
|
||||||
|
scroll.hasVerticalScroller = true
|
||||||
|
scroll.borderType = .bezelBorder
|
||||||
|
let table = NSTableView(frame: scroll.bounds)
|
||||||
|
table.usesAlternatingRowBackgroundColors = true
|
||||||
|
table.rowHeight = 22
|
||||||
|
let colFrom = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("from"))
|
||||||
|
colFrom.title = "Heard"; colFrom.width = 195; colFrom.isEditable = true
|
||||||
|
let colTo = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("to"))
|
||||||
|
colTo.title = "Replacement"; colTo.width = 195
|
||||||
|
table.addTableColumn(colFrom)
|
||||||
|
table.addTableColumn(colTo)
|
||||||
|
table.dataSource = self
|
||||||
|
table.delegate = self
|
||||||
|
scroll.documentView = table
|
||||||
|
contentView.addSubview(scroll)
|
||||||
|
dictTable = table
|
||||||
|
|
||||||
|
let addBtn = NSButton(title: "+", target: self, action: #selector(addReplacement))
|
||||||
|
addBtn.frame = NSRect(x: 20, y: 70, width: 34, height: 26)
|
||||||
|
addBtn.bezelStyle = .rounded
|
||||||
|
contentView.addSubview(addBtn)
|
||||||
|
let delBtn = NSButton(title: "–", target: self, action: #selector(removeReplacement))
|
||||||
|
delBtn.frame = NSRect(x: 56, y: 70, width: 34, height: 26)
|
||||||
|
delBtn.bezelStyle = .rounded
|
||||||
|
contentView.addSubview(delBtn)
|
||||||
|
|
||||||
|
let dictHint = NSTextField(labelWithString: "The Replacement column also improves recognition. \\n = newline.")
|
||||||
|
dictHint.frame = NSRect(x: 100, y: 74, width: 330, height: 18)
|
||||||
|
dictHint.font = NSFont.systemFont(ofSize: 10)
|
||||||
|
dictHint.textColor = .tertiaryLabelColor
|
||||||
|
contentView.addSubview(dictHint)
|
||||||
|
|
||||||
// Models directory hint
|
// Models directory hint
|
||||||
let hintLabel = NSTextField(labelWithString: "Models stored in: ~/.whisper-models/")
|
let hintLabel = NSTextField(labelWithString: "Models stored in: ~/.whisper-models/")
|
||||||
hintLabel.frame = NSRect(x: 20, y: 15, width: 410, height: 20)
|
hintLabel.frame = NSRect(x: 20, y: 15, width: 410, height: 20)
|
||||||
@@ -487,6 +800,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
|
|||||||
let path = installedModels[index].path
|
let path = installedModels[index].path
|
||||||
modelPath = path
|
modelPath = path
|
||||||
checkModelExists()
|
checkModelExists()
|
||||||
|
startWhisperServer()
|
||||||
NSLog("Model changed to: \(path)")
|
NSLog("Model changed to: \(path)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -549,6 +863,72 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
|
|||||||
setLaunchAtLogin(sender.state == .on)
|
setLaunchAtLogin(sender.state == .on)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Custom dictionary table (view-based, editable text fields)
|
||||||
|
func numberOfRows(in tableView: NSTableView) -> Int { replacements.count }
|
||||||
|
|
||||||
|
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
|
||||||
|
guard let column = tableColumn, row < replacements.count else { return nil }
|
||||||
|
let id = column.identifier
|
||||||
|
let field: NSTextField
|
||||||
|
if let reused = tableView.makeView(withIdentifier: id, owner: self) as? NSTextField {
|
||||||
|
field = reused
|
||||||
|
} else {
|
||||||
|
field = NSTextField()
|
||||||
|
field.identifier = id
|
||||||
|
field.isBordered = false
|
||||||
|
field.drawsBackground = false
|
||||||
|
field.isEditable = true
|
||||||
|
field.isSelectable = true
|
||||||
|
field.font = NSFont.systemFont(ofSize: 12)
|
||||||
|
field.lineBreakMode = .byTruncatingTail
|
||||||
|
field.delegate = self
|
||||||
|
field.placeholderString = id.rawValue == "from" ? "heard word" : "replacement (\\n = newline)"
|
||||||
|
}
|
||||||
|
field.stringValue = replacements[row][id.rawValue] ?? ""
|
||||||
|
return field
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit an edited cell back into the model (fires on Enter, Tab, focus loss).
|
||||||
|
func controlTextDidEndEditing(_ obj: Notification) {
|
||||||
|
guard let field = obj.object as? NSTextField, let table = dictTable else { return }
|
||||||
|
let row = table.row(for: field), col = table.column(for: field)
|
||||||
|
guard row >= 0, row < replacements.count, col >= 0, col < table.tableColumns.count else { return }
|
||||||
|
let id = table.tableColumns[col].identifier.rawValue
|
||||||
|
var rows = replacements
|
||||||
|
rows[row][id] = field.stringValue
|
||||||
|
replacements = rows
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func addReplacement() {
|
||||||
|
pruneEmptyReplacements()
|
||||||
|
var rows = replacements
|
||||||
|
rows.append(["from": "", "to": ""])
|
||||||
|
replacements = rows
|
||||||
|
dictTable?.reloadData()
|
||||||
|
dictTable?.editColumn(0, row: rows.count - 1, with: nil, select: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc func removeReplacement() {
|
||||||
|
guard let table = dictTable, table.selectedRow >= 0, table.selectedRow < replacements.count else { return }
|
||||||
|
var rows = replacements
|
||||||
|
rows.remove(at: table.selectedRow)
|
||||||
|
replacements = rows
|
||||||
|
table.reloadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop rows where both columns are blank so empty rows aren't persisted.
|
||||||
|
func pruneEmptyReplacements() {
|
||||||
|
let cleaned = replacements.filter {
|
||||||
|
!(($0["from"] ?? "").trimmingCharacters(in: .whitespaces).isEmpty
|
||||||
|
&& ($0["to"] ?? "").trimmingCharacters(in: .whitespaces).isEmpty)
|
||||||
|
}
|
||||||
|
if cleaned.count != replacements.count { replacements = cleaned }
|
||||||
|
}
|
||||||
|
|
||||||
|
func windowWillClose(_ notification: Notification) {
|
||||||
|
pruneEmptyReplacements()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Launch at Login
|
// MARK: - Launch at Login
|
||||||
func isLaunchAtLoginEnabled() -> Bool {
|
func isLaunchAtLoginEnabled() -> Bool {
|
||||||
if #available(macOS 13.0, *) {
|
if #available(macOS 13.0, *) {
|
||||||
@@ -698,21 +1078,58 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
audioRecorder = try AVAudioRecorder(url: audioURL, settings: settings)
|
audioRecorder = try AVAudioRecorder(url: audioURL, settings: settings)
|
||||||
|
audioRecorder?.isMeteringEnabled = true
|
||||||
audioRecorder?.record()
|
audioRecorder?.record()
|
||||||
isRecording = true
|
isRecording = true
|
||||||
statusItem.button?.title = "🔴"
|
statusItem.button?.title = "🔴"
|
||||||
updateStatus("Recording...")
|
updateStatus("Recording...")
|
||||||
if playSounds { NSSound(named: "Tink")?.play() }
|
if playSounds { NSSound(named: "Tink")?.play() }
|
||||||
NSLog("Recording started")
|
NSLog("Recording started")
|
||||||
|
startMeter()
|
||||||
} catch {
|
} catch {
|
||||||
NSLog("Recording failed: \(error)")
|
NSLog("Recording failed: \(error)")
|
||||||
if playSounds { NSSound(named: "Basso")?.play() }
|
if playSounds { NSSound(named: "Basso")?.play() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Live mic meter (HUD)
|
||||||
|
func startMeter() {
|
||||||
|
silenceStart = nil
|
||||||
|
recordingHUD.show()
|
||||||
|
meterTimer?.invalidate()
|
||||||
|
meterTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
||||||
|
self?.updateMeter()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopMeter() {
|
||||||
|
meterTimer?.invalidate()
|
||||||
|
meterTimer = nil
|
||||||
|
silenceStart = nil
|
||||||
|
recordingHUD.hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateMeter() {
|
||||||
|
guard isRecording, let recorder = audioRecorder else { return }
|
||||||
|
recorder.updateMeters()
|
||||||
|
let avg = recorder.averagePower(forChannel: 0) // dBFS, ~-160...0
|
||||||
|
let peak = recorder.peakPower(forChannel: 0)
|
||||||
|
let level = max(0, min(1, CGFloat((avg + 50) / 50)))
|
||||||
|
|
||||||
|
// Flag "no input" if the peak stays at silence for over ~1.2s.
|
||||||
|
if peak < -50 {
|
||||||
|
if silenceStart == nil { silenceStart = Date() }
|
||||||
|
} else {
|
||||||
|
silenceStart = nil
|
||||||
|
}
|
||||||
|
let silent = silenceStart.map { Date().timeIntervalSince($0) > 1.2 } ?? false
|
||||||
|
recordingHUD.update(level: level, silent: silent)
|
||||||
|
}
|
||||||
|
|
||||||
func stopRecordingAndTranscribe() {
|
func stopRecordingAndTranscribe() {
|
||||||
audioRecorder?.stop()
|
audioRecorder?.stop()
|
||||||
isRecording = false
|
isRecording = false
|
||||||
|
stopMeter()
|
||||||
statusItem.button?.title = "⏳"
|
statusItem.button?.title = "⏳"
|
||||||
updateStatus("Transcribing...")
|
updateStatus("Transcribing...")
|
||||||
if playSounds { NSSound(named: "Pop")?.play() }
|
if playSounds { NSSound(named: "Pop")?.play() }
|
||||||
@@ -749,9 +1166,57 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func findWhisperServer() -> String? {
|
||||||
|
if let bundlePath = Bundle.main.executablePath {
|
||||||
|
let bundled = (bundlePath as NSString).deletingLastPathComponent + "/whisper-server"
|
||||||
|
if FileManager.default.isExecutableFile(atPath: bundled) { return bundled }
|
||||||
|
}
|
||||||
|
let paths = [
|
||||||
|
"/opt/homebrew/bin/whisper-server",
|
||||||
|
"/usr/local/bin/whisper-server",
|
||||||
|
"/opt/local/bin/whisper-server",
|
||||||
|
NSHomeDirectory() + "/bin/whisper-server"
|
||||||
|
]
|
||||||
|
for path in paths where FileManager.default.isExecutableFile(atPath: path) { return path }
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// (Re)start the warm whisper-server for the current model. Falls back to
|
||||||
|
// per-call whisper-cli if the server binary is missing or fails to come up.
|
||||||
|
func startWhisperServer() {
|
||||||
|
whisperServer?.stop()
|
||||||
|
whisperServer = nil
|
||||||
|
|
||||||
|
guard let serverPath = findWhisperServer() else {
|
||||||
|
NSLog("whisper-server not found — using per-call whisper-cli")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard isValidModelPath(modelPath).valid else { return }
|
||||||
|
|
||||||
|
statusItem.button?.title = "⏳"
|
||||||
|
updateStatus("Loading model…")
|
||||||
|
|
||||||
|
let threads = min(8, max(2, ProcessInfo.processInfo.activeProcessorCount - 2))
|
||||||
|
let server = WhisperServer(serverPath: serverPath, modelPath: modelPath, threads: threads)
|
||||||
|
whisperServer = server
|
||||||
|
server.start { [weak self] ok in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
guard let self = self, self.whisperServer === server else { return }
|
||||||
|
self.statusItem.button?.title = "🎤"
|
||||||
|
if ok {
|
||||||
|
self.updateStatus("Ready")
|
||||||
|
NSLog("whisper-server ready on port \(server.port)")
|
||||||
|
} else {
|
||||||
|
self.updateStatus("Ready (CLI fallback)")
|
||||||
|
self.whisperServer = nil
|
||||||
|
NSLog("whisper-server failed to start — falling back to whisper-cli")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Transcription
|
// MARK: - Transcription
|
||||||
func transcribe() {
|
func transcribe() {
|
||||||
// Validate inputs before execution
|
|
||||||
let modelValidation = isValidModelPath(modelPath)
|
let modelValidation = isValidModelPath(modelPath)
|
||||||
guard modelValidation.valid else {
|
guard modelValidation.valid else {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
@@ -771,6 +1236,34 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Prefer the warm server (~0.5s); fall back to a per-call whisper-cli spawn.
|
||||||
|
let prompt = customPrompt()
|
||||||
|
let result: String?
|
||||||
|
if let server = whisperServer, server.isReady {
|
||||||
|
result = server.transcribe(wavPath: audioFilePath, language: language, prompt: prompt)
|
||||||
|
} else {
|
||||||
|
result = transcribeWithCLI(prompt: prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.cleanupAudioFile()
|
||||||
|
|
||||||
|
let finalText = result.map { applyReplacements($0) }
|
||||||
|
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let finalText = finalText, !finalText.isEmpty {
|
||||||
|
self.pasteText(finalText)
|
||||||
|
} else {
|
||||||
|
self.statusItem.button?.title = "🎤"
|
||||||
|
self.updateStatus("Ready")
|
||||||
|
if self.playSounds { NSSound(named: "Basso")?.play() }
|
||||||
|
NSLog("No speech recognized")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-call transcription via whisper-cli (reloads the model every time).
|
||||||
|
// Fallback for when the warm server is unavailable.
|
||||||
|
func transcribeWithCLI(prompt: String? = nil) -> String? {
|
||||||
guard let whisperPath = findWhisperCLI() else {
|
guard let whisperPath = findWhisperCLI() else {
|
||||||
DispatchQueue.main.async {
|
DispatchQueue.main.async {
|
||||||
self.statusItem.button?.title = "🎤"
|
self.statusItem.button?.title = "🎤"
|
||||||
@@ -778,17 +1271,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
|
|||||||
if self.playSounds { NSSound(named: "Basso")?.play() }
|
if self.playSounds { NSSound(named: "Basso")?.play() }
|
||||||
NSLog("whisper-cli not found in PATH")
|
NSLog("whisper-cli not found in PATH")
|
||||||
}
|
}
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let task = Process()
|
let task = Process()
|
||||||
task.executableURL = URL(fileURLWithPath: whisperPath)
|
task.executableURL = URL(fileURLWithPath: whisperPath)
|
||||||
|
|
||||||
// Build arguments - skip language flag for auto-detect
|
|
||||||
var args = ["-m", modelPath]
|
var args = ["-m", modelPath]
|
||||||
if language.lowercased() != SupportedLanguages.autoDetect {
|
if language.lowercased() != SupportedLanguages.autoDetect {
|
||||||
args += ["-l", language.lowercased()]
|
args += ["-l", language.lowercased()]
|
||||||
}
|
}
|
||||||
|
if let prompt = prompt, !prompt.isEmpty { args += ["--prompt", prompt] }
|
||||||
args += ["-f", audioFilePath]
|
args += ["-f", audioFilePath]
|
||||||
task.arguments = args
|
task.arguments = args
|
||||||
|
|
||||||
@@ -799,45 +1291,18 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
|
|||||||
do {
|
do {
|
||||||
try task.run()
|
try task.run()
|
||||||
task.waitUntilExit()
|
task.waitUntilExit()
|
||||||
|
|
||||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
let output = String(data: data, encoding: .utf8) ?? ""
|
let output = String(data: data, encoding: .utf8) ?? ""
|
||||||
|
|
||||||
let lines = output.components(separatedBy: "\n")
|
|
||||||
var result = ""
|
var result = ""
|
||||||
for line in lines {
|
for line in output.components(separatedBy: "\n") where line.hasPrefix("[") {
|
||||||
if line.hasPrefix("[") {
|
if let range = line.range(of: "]") {
|
||||||
if let range = line.range(of: "]") {
|
result += String(line[range.upperBound...]).trimmingCharacters(in: .whitespaces) + " "
|
||||||
let text = String(line[range.upperBound...]).trimmingCharacters(in: .whitespaces)
|
|
||||||
result += text + " "
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result = result.trimmingCharacters(in: .whitespaces)
|
|
||||||
|
|
||||||
// Cleanup audio file after transcription
|
|
||||||
self.cleanupAudioFile()
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if !result.isEmpty {
|
|
||||||
self.pasteText(result)
|
|
||||||
} else {
|
|
||||||
self.statusItem.button?.title = "🎤"
|
|
||||||
self.updateStatus("Ready")
|
|
||||||
if self.playSounds { NSSound(named: "Basso")?.play() }
|
|
||||||
NSLog("No speech recognized")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return result.trimmingCharacters(in: .whitespaces)
|
||||||
} catch {
|
} catch {
|
||||||
// Cleanup even on error
|
NSLog("Transcription failed: \(error)")
|
||||||
self.cleanupAudioFile()
|
return nil
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.statusItem.button?.title = "🎤"
|
|
||||||
self.updateStatus("Error")
|
|
||||||
if self.playSounds { NSSound(named: "Basso")?.play() }
|
|
||||||
NSLog("Transcription failed: \(error)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user