@@ -3,12 +3,14 @@ import AVFoundation
import Carbon . HIToolbox
import ServiceManagement
import ApplicationServices
import Darwin
// MARK: - U s e r D e f a u l t s K e y s
struct Defaults {
static let language = " whisperLanguage "
static let modelPath = " whisperModelPath "
static let playSounds = " playSounds "
static let replacements = " customReplacements "
}
// MARK: - W h i s p e r M o d e l s
@@ -150,12 +152,250 @@ struct SupportedLanguages {
}
}
// MARK: - W h i s p e r S e r v e r ( w a r m , p r e l o a d e d - m o d e l b a c k e n d )
// K e e p s a w h i s p e r - s e r v e r c h i l d p r o c e s s a l i v e w i t h t h e m o d e l a l r e a d y l o a d e d , s o
// e a c h d i c t a t i o n i s a f a s t l o c a l H T T P c a l l ( ~ 0 . 5 s ) i n s t e a d o f a m u l t i - s e c o n d
// m o d e l + M e t a l r e l o a d o n e v e r y u t t e r a n c e ( w h a t p l a i n w h i s p e r - c l i d o e s ) .
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 " , // n o t i m e s t a m p s - > p l a i n t e x t r e s p o n s e
" -sns " , // s u p p r e s s n o n - s p e e c h t o k e n s - > f e w e r h a l l u c i n a t i o n s
]
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
}
// M o d e l l o a d c a n t a k e ~ 1 0 s ( i n c l . M e t a l i n i t ) ; p o l l t h e p o r t o f f - t h r e a d .
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
}
// S y n c h r o n o u s t r a n s c r i p t i o n v i a t h e w a r m s e r v e r . C a l l O F F t h e m a i n t h r e a d .
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 ) ! ) }
// a u d i o f i l e p a r t
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 )
// t e x t f i e l d s
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: s o c k e t h e l p e r s ( f i n d a f r e e l o o p b a c k p o r t + r e a d i n e s s p r o b e )
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: - R e c o r d i n g H U D ( l i v e m i c l e v e l + s i l e n c e w a r n i n g )
// A s m a l l f l o a t i n g , n o n - i n t e r a c t i v e p a n e l s h o w n w h i l e r e c o r d i n g . D o u b l e s a s a
// d i a g n o s t i c : i f t h e i n p u t s t a y s s i l e n t i t f l a g s " n o i n p u t " — e x a c t l y t h e
// f a i l u r e t h a t s i l e n t l y p r o d u c e d e m p t y r e c o r d i n g s + w h i s p e r h a l l u c i n a t i o n s .
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: - A p p D e l e g a t e
class AppDelegate : NSObject , NSApplicationDelegate , URLSessionDownloadDelegate {
class AppDelegate : NSObject , NSApplicationDelegate , URLSessionDownloadDelegate , NSTableViewDataSource , NSTableViewDelegate , NSTextFieldDelegate , NSWindowDelegate {
var statusItem : NSStatusItem !
var audioRecorder : AVAudioRecorder ?
var isRecording = false
var settingsWindowController : NSWindowController ?
var whisperServer : WhisperServer ?
let recordingHUD = RecordingHUD ( )
var meterTimer : Timer ?
var silenceStart : Date ?
weak var dictTable : NSTableView ?
// U s e p r i v a t e t e m p d i r e c t o r y w i t h u n i q u e f i l e n a m e
var audioFilePath : String {
@@ -178,19 +418,51 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
set { UserDefaults . standard . set ( newValue , forKey : Defaults . playSounds ) }
}
// C u s t o m d i c t i o n a r y : o r d e r e d l i s t o f [ " f r o m " : r e c o g n i s e d , " t o " : r e p l a c e m e n t ] .
var replacements : [ [ String : String ] ] {
get { ( UserDefaults . standard . array ( forKey : Defaults . replacements ) as ? [ [ String : String ] ] ) ? ? [ ] }
set { UserDefaults . standard . set ( newValue , forKey : Defaults . replacements ) }
}
// W h i s p e r i n i t i a l p r o m p t b u i l t f r o m t h e d e s i r e d ( " t o " ) t e r m s , t o b i a s
// r e c o g n i t i o n t o w a r d t h e u s e r ' s n a m e s / j a r g o n . R e t u r n s n i l i f e m p t y .
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 : " , " )
}
// A p p l y t h e d i c t i o n a r y f i n d / r e p l a c e p a i r s t o a t r a n s c r i p t . " \ n " i n a
// r e p l a c e m e n t b e c o m e s a r e a l n e w l i n e ( e . g . s p o k e n - c o m m a n d → l i n e b r e a k ) .
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 ) {
setupStatusItem ( )
registerHotkey ( )
requestMicrophonePermission ( )
checkAccessibilityPermission ( )
// F i r s t - r u n : c h e c k i f m o d e l e x i s t s , i f n o t s h o w s e t u p w i z a r d
// S t a r t w a r m i n g t h e m o d e l s e r v e r B E F O R E t h e ( b l o c k i n g ) a c c e s s i b i l i t y
// p r o m p t , s o t h e m u l t i - s e c o n d m o d e l l o a d o v e r l a p s w i t h t h e u s e r
// g r a n t i n g p e r m i s s i o n s i n s t e a d o f r u n n i n g a f t e r i t .
if ! hasAnyModel ( ) {
showFirstRunWizard ( )
} else {
checkModelExists ( )
startWhisperServer ( )
}
checkAccessibilityPermission ( )
NSLog ( " WhisperDictate started. Press ⌃⌥D to toggle recording. " )
}
@@ -287,6 +559,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
updateStatus ( " Ready - \( model . name ) " )
if playSounds { NSSound ( named : " Glass " ) ? . play ( ) }
NSLog ( " Model downloaded: \( model . name ) " )
startWhisperServer ( )
} catch {
statusItem . button ? . title = " 🎤 "
updateStatus ( " ⚠️ Save failed " )
@@ -310,6 +583,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
}
func applicationWillTerminate ( _ notification : Notification ) {
whisperServer ? . stop ( )
cleanupAudioFile ( )
}
@@ -350,17 +624,18 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
func createSettingsWindow ( ) -> NSWindow {
let window = NSWindow (
contentRect : NSRect ( x : 0 , y : 0 , width : 450 , height : 28 0) ,
contentRect : NSRect ( x : 0 , y : 0 , width : 450 , height : 54 0) ,
styleMask : [ . titled , . closable ] ,
backing : . buffered ,
defer : false
)
window . title = " WhisperDictate Settings "
window . center ( )
window . delegate = self
let contentView = NSView ( frame : window . contentView ! . bounds )
var y : CGFloat = 230
var y : CGFloat = 498
let labelWidth : CGFloat = 120
let controlX : CGFloat = 140
let controlWidth : CGFloat = 280
@@ -453,6 +728,44 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
loginCheck . state = isLaunchAtLoginEnabled ( ) ? . on : . off
contentView . addSubview ( loginCheck )
// C u s t o m d i c t i o n a r y ( r e c o g n i s e d - > r e p l a c e m e n t )
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 )
// M o d e l s d i r e c t o r y h i n t
let hintLabel = NSTextField ( labelWithString : " Models stored in: ~/.whisper-models/ " )
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
modelPath = path
checkModelExists ( )
startWhisperServer ( )
NSLog ( " Model changed to: \( path ) " )
}
}
@@ -549,6 +863,72 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
setLaunchAtLogin ( sender . state = = . on )
}
// MARK: - C u s t o m d i c t i o n a r y t a b l e ( v i e w - b a s e d , e d i t a b l e t e x t f i e l d s )
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
}
// C o m m i t a n e d i t e d c e l l b a c k i n t o t h e m o d e l ( f i r e s o n E n t e r , T a b , f o c u s l o s s ) .
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 ( )
}
// D r o p r o w s w h e r e b o t h c o l u m n s a r e b l a n k s o e m p t y r o w s a r e n ' t p e r s i s t e d .
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: - L a u n c h a t L o g i n
func isLaunchAtLoginEnabled ( ) -> Bool {
if #available ( macOS 13.0 , * ) {
@@ -698,21 +1078,58 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
do {
audioRecorder = try AVAudioRecorder ( url : audioURL , settings : settings )
audioRecorder ? . isMeteringEnabled = true
audioRecorder ? . record ( )
isRecording = true
statusItem . button ? . title = " 🔴 "
updateStatus ( " Recording... " )
if playSounds { NSSound ( named : " Tink " ) ? . play ( ) }
NSLog ( " Recording started " )
startMeter ( )
} catch {
NSLog ( " Recording failed: \( error ) " )
if playSounds { NSSound ( named : " Basso " ) ? . play ( ) }
}
}
// MARK: - L i v e m i c m e t e r ( H U D )
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 ) // d B F S , ~ - 1 6 0 . . . 0
let peak = recorder . peakPower ( forChannel : 0 )
let level = max ( 0 , min ( 1 , CGFloat ( ( avg + 50 ) / 50 ) ) )
// F l a g " n o i n p u t " i f t h e p e a k s t a y s a t s i l e n c e f o r o v e r ~ 1 . 2 s .
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 ( ) {
audioRecorder ? . stop ( )
isRecording = false
stopMeter ( )
statusItem . button ? . title = " ⏳ "
updateStatus ( " Transcribing... " )
if playSounds { NSSound ( named : " Pop " ) ? . play ( ) }
@@ -749,9 +1166,57 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
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
}
// ( R e ) s t a r t t h e w a r m w h i s p e r - s e r v e r f o r t h e c u r r e n t m o d e l . F a l l s b a c k t o
// p e r - c a l l w h i s p e r - c l i i f t h e s e r v e r b i n a r y i s m i s s i n g o r f a i l s t o c o m e u p .
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: - T r a n s c r i p t i o n
func transcribe ( ) {
// V a l i d a t e i n p u t s b e f o r e e x e c u t i o n
let modelValidation = isValidModelPath ( modelPath )
guard modelValidation . valid else {
DispatchQueue . main . async {
@@ -771,6 +1236,34 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
return
}
// P r e f e r t h e w a r m s e r v e r ( ~ 0 . 5 s ) ; f a l l b a c k t o a p e r - c a l l w h i s p e r - c l i s p a w n .
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 " )
}
}
}
// P e r - c a l l t r a n s c r i p t i o n v i a w h i s p e r - c l i ( r e l o a d s t h e m o d e l e v e r y t i m e ) .
// F a l l b a c k f o r w h e n t h e w a r m s e r v e r i s u n a v a i l a b l e .
func transcribeWithCLI ( prompt : String ? = nil ) -> String ? {
guard let whisperPath = findWhisperCLI ( ) else {
DispatchQueue . main . async {
self . statusItem . button ? . title = " 🎤 "
@@ -778,17 +1271,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
if self . playSounds { NSSound ( named : " Basso " ) ? . play ( ) }
NSLog ( " whisper-cli not found in PATH " )
}
return
return nil
}
let task = Process ( )
task . executableURL = URL ( fileURLWithPath : whisperPath )
// B u i l d a r g u m e n t s - s k i p l a n g u a g e f l a g f o r a u t o - d e t e c t
var args = [ " -m " , modelPath ]
if language . lowercased ( ) != SupportedLanguages . autoDetect {
args += [ " -l " , language . lowercased ( ) ]
}
if let prompt = prompt , ! prompt . isEmpty { args += [ " --prompt " , prompt ] }
args += [ " -f " , audioFilePath ]
task . arguments = args
@@ -799,45 +1291,18 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate {
do {
try task . run ( )
task . waitUntilExit ( )
let data = pipe . fileHandleForReading . readDataToEndOfFile ( )
let output = String ( data : data , encoding : . utf8 ) ? ? " "
let lines = output . components ( separatedBy : " \n " )
var result = " "
for line in lines {
if line . hasPrefix ( " [ " ) {
for line in output . components ( separatedBy : " \n " ) where line . hasPrefix ( " [ " ) {
if let range = line . range ( of : " ] " ) {
let text = String ( line [ range . upperBound . . . ] ) . trimmingCharacters ( in : . whitespaces )
result += text + " "
}
}
}
result = result . trimmingCharacters ( in : . whitespaces )
// C l e a n u p a u d i o f i l e a f t e r t r a n s c r i p t i o n
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 " )
result + = String ( line [ range . upperBound . . . ] ) . trimmingCharacters ( in : . whitespaces ) + " "
}
}
return result . trimmingCharacters ( in : . whitespaces )
} catch {
// C l e a n u p e v e n o n e r r o r
self . cleanupAudioFile ( )
DispatchQueue . main . async {
self . statusItem . button ? . title = " 🎤 "
self . updateStatus ( " Error " )
if self . playSounds { NSSound ( named : " Basso " ) ? . play ( ) }
NSLog ( " Transcription failed: \( error ) " )
}
return nil
}
}