|
|
|
@@ -1,5 +1,6 @@
|
|
|
|
import Cocoa
|
|
|
|
import Cocoa
|
|
|
|
import AVFoundation
|
|
|
|
@preconcurrency import AVFoundation
|
|
|
|
|
|
|
|
@preconcurrency import Speech
|
|
|
|
import Carbon.HIToolbox
|
|
|
|
import Carbon.HIToolbox
|
|
|
|
import ServiceManagement
|
|
|
|
import ServiceManagement
|
|
|
|
import ApplicationServices
|
|
|
|
import ApplicationServices
|
|
|
|
@@ -11,6 +12,36 @@ struct Defaults {
|
|
|
|
static let modelPath = "whisperModelPath"
|
|
|
|
static let modelPath = "whisperModelPath"
|
|
|
|
static let playSounds = "playSounds"
|
|
|
|
static let playSounds = "playSounds"
|
|
|
|
static let replacements = "customReplacements"
|
|
|
|
static let replacements = "customReplacements"
|
|
|
|
|
|
|
|
static let livePreview = "livePreview"
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Icons
|
|
|
|
|
|
|
|
// SF Symbols: vector icons that stay sharp at any scale. Template symbols are
|
|
|
|
|
|
|
|
// tinted by macOS (menu bar, menus, buttons) and follow light/dark appearance.
|
|
|
|
|
|
|
|
enum Icons {
|
|
|
|
|
|
|
|
static func symbol(_ name: String, pointSize: CGFloat = 13, weight: NSFont.Weight = .regular, description: String? = nil) -> NSImage? {
|
|
|
|
|
|
|
|
let image = NSImage(systemSymbolName: name, accessibilityDescription: description)?
|
|
|
|
|
|
|
|
.withSymbolConfiguration(.init(pointSize: pointSize, weight: weight))
|
|
|
|
|
|
|
|
image?.isTemplate = true
|
|
|
|
|
|
|
|
return image
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Fixed colour, for states that need it (recording) and for custom drawing (HUD).
|
|
|
|
|
|
|
|
// Tints the monochrome symbol so knockouts stay transparent (the tick in
|
|
|
|
|
|
|
|
// checkmark.circle.fill); a one-colour palette would fill them in. Drawn
|
|
|
|
|
|
|
|
// lazily, so it stays sharp on any display scale.
|
|
|
|
|
|
|
|
static func coloredSymbol(_ name: String, pointSize: CGFloat, weight: NSFont.Weight = .regular, color: NSColor, description: String? = nil) -> NSImage? {
|
|
|
|
|
|
|
|
guard let symbol = symbol(name, pointSize: pointSize, weight: weight, description: description) else { return nil }
|
|
|
|
|
|
|
|
let image = NSImage(size: symbol.size, flipped: false) { rect in
|
|
|
|
|
|
|
|
symbol.draw(in: rect)
|
|
|
|
|
|
|
|
color.set()
|
|
|
|
|
|
|
|
rect.fill(using: .sourceAtop)
|
|
|
|
|
|
|
|
return true
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
image.accessibilityDescription = description
|
|
|
|
|
|
|
|
image.isTemplate = false
|
|
|
|
|
|
|
|
return image
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Whisper Models
|
|
|
|
// MARK: - Whisper Models
|
|
|
|
@@ -411,6 +442,208 @@ final class WhisperServer {
|
|
|
|
// A small floating, non-interactive panel shown while recording. Doubles as a
|
|
|
|
// A small floating, non-interactive panel shown while recording. Doubles as a
|
|
|
|
// diagnostic: if the input stays silent it flags "no input" — exactly the
|
|
|
|
// diagnostic: if the input stays silent it flags "no input" — exactly the
|
|
|
|
// failure that silently produced empty recordings + whisper hallucinations.
|
|
|
|
// failure that silently produced empty recordings + whisper hallucinations.
|
|
|
|
|
|
|
|
// MARK: - Live Preview (on-device Apple dictation model)
|
|
|
|
|
|
|
|
// Streams a rough live transcript into the recording HUD using the system's
|
|
|
|
|
|
|
|
// on-device dictation model (SpeechAnalyzer + DictationTranscriber, macOS 26).
|
|
|
|
|
|
|
|
// It is only a preview: the pasted text still comes from Whisper. It captures
|
|
|
|
|
|
|
|
// the microphone with its own AVAudioEngine so the AVAudioRecorder path used
|
|
|
|
|
|
|
|
// for Whisper stays untouched; if anything here fails there is simply no preview.
|
|
|
|
|
|
|
|
final class LivePreview {
|
|
|
|
|
|
|
|
// Called on the main thread with the transcript so far
|
|
|
|
|
|
|
|
var onText: ((String) -> Void)?
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private var session = 0
|
|
|
|
|
|
|
|
private var engine: AVAudioEngine?
|
|
|
|
|
|
|
|
private var analyzer: SpeechAnalyzer?
|
|
|
|
|
|
|
|
private var input: AsyncStream<AnalyzerInput>.Continuation?
|
|
|
|
|
|
|
|
private var resultsTask: Task<Void, Never>?
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// App language code -> supported dictation locale ("hu" -> hu-HU); nil if unsupported
|
|
|
|
|
|
|
|
static func locale(for languageCode: String) async -> Locale? {
|
|
|
|
|
|
|
|
let requested = languageCode == SupportedLanguages.autoDetect ? Locale.current : Locale(identifier: languageCode)
|
|
|
|
|
|
|
|
return await DictationTranscriber.supportedLocale(equivalentTo: requested)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private static func makeTranscriber(locale: Locale) -> DictationTranscriber {
|
|
|
|
|
|
|
|
DictationTranscriber(locale: locale, contentHints: [], transcriptionOptions: [.punctuation],
|
|
|
|
|
|
|
|
reportingOptions: [.volatileResults, .frequentFinalization], attributeOptions: [])
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Download the on-device model for this language in the background if it's missing
|
|
|
|
|
|
|
|
static func installModelIfNeeded(languageCode: String) {
|
|
|
|
|
|
|
|
Task {
|
|
|
|
|
|
|
|
guard let locale = await locale(for: languageCode) else { return }
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
|
|
|
if let request = try await AssetInventory.assetInstallationRequest(supporting: [makeTranscriber(locale: locale)]) {
|
|
|
|
|
|
|
|
NSLog("Live preview: installing dictation model for \(locale.identifier)")
|
|
|
|
|
|
|
|
try await request.downloadAndInstall()
|
|
|
|
|
|
|
|
NSLog("Live preview: dictation model installed for \(locale.identifier)")
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
|
|
NSLog("Live preview: model install failed: \(error)")
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func start(languageCode: String, contextualStrings: [String]) {
|
|
|
|
|
|
|
|
stop()
|
|
|
|
|
|
|
|
let current = session
|
|
|
|
|
|
|
|
Task { @MainActor in
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
|
|
|
guard let locale = await LivePreview.locale(for: languageCode) else {
|
|
|
|
|
|
|
|
NSLog("Live preview: no on-device dictation model for '\(languageCode)'")
|
|
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
let transcriber = LivePreview.makeTranscriber(locale: locale)
|
|
|
|
|
|
|
|
// Even when the model is already on disk the app may first have to register it;
|
|
|
|
|
|
|
|
// that takes a fraction of a second. A real download just means no preview this time.
|
|
|
|
|
|
|
|
_ = try? await AssetInventory.reserve(locale: locale)
|
|
|
|
|
|
|
|
if await AssetInventory.status(forModules: [transcriber]) != .installed,
|
|
|
|
|
|
|
|
let request = try await AssetInventory.assetInstallationRequest(supporting: [transcriber]) {
|
|
|
|
|
|
|
|
NSLog("Live preview: preparing dictation model for \(locale.identifier)")
|
|
|
|
|
|
|
|
try await request.downloadAndInstall()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
guard current == self.session,
|
|
|
|
|
|
|
|
let analyzerFormat = await SpeechAnalyzer.bestAvailableAudioFormat(compatibleWith: [transcriber]) else { return }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Start the microphone first; the stream buffers audio until the analyzer is running,
|
|
|
|
|
|
|
|
// so the first words aren't lost while the model spins up.
|
|
|
|
|
|
|
|
let (stream, continuation) = AsyncStream<AnalyzerInput>.makeStream()
|
|
|
|
|
|
|
|
let engine = AVAudioEngine()
|
|
|
|
|
|
|
|
let micFormat = engine.inputNode.outputFormat(forBus: 0)
|
|
|
|
|
|
|
|
guard micFormat.sampleRate > 0, current == self.session,
|
|
|
|
|
|
|
|
let monoFormat = AVAudioFormat(standardFormatWithSampleRate: micFormat.sampleRate, channels: 1),
|
|
|
|
|
|
|
|
let converter = AVAudioConverter(from: monoFormat, to: analyzerFormat) else { return }
|
|
|
|
|
|
|
|
NSLog("Live preview: mic %d ch @ %.0f Hz -> %@", micFormat.channelCount, micFormat.sampleRate, analyzerFormat.description)
|
|
|
|
|
|
|
|
engine.inputNode.installTap(onBus: 0, bufferSize: 4096, format: micFormat,
|
|
|
|
|
|
|
|
block: LivePreview.tapBlock(converter: converter, format: analyzerFormat, continuation: continuation))
|
|
|
|
|
|
|
|
try engine.start()
|
|
|
|
|
|
|
|
self.engine = engine
|
|
|
|
|
|
|
|
self.input = continuation
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let analyzer = SpeechAnalyzer(modules: [transcriber])
|
|
|
|
|
|
|
|
self.analyzer = analyzer
|
|
|
|
|
|
|
|
if !contextualStrings.isEmpty {
|
|
|
|
|
|
|
|
let context = AnalysisContext()
|
|
|
|
|
|
|
|
context.contextualStrings[.general] = contextualStrings
|
|
|
|
|
|
|
|
try await analyzer.setContext(context)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
self.resultsTask = Task { @MainActor [weak self] in
|
|
|
|
|
|
|
|
// Finalized segments accumulate; the volatile tail is replaced on every update
|
|
|
|
|
|
|
|
var finalized = ""
|
|
|
|
|
|
|
|
do {
|
|
|
|
|
|
|
|
for try await result in transcriber.results {
|
|
|
|
|
|
|
|
let text = String(result.text.characters)
|
|
|
|
|
|
|
|
let shown = LivePreview.join(finalized, text)
|
|
|
|
|
|
|
|
if result.isFinal { finalized = shown }
|
|
|
|
|
|
|
|
guard let self = self, current == self.session else { return }
|
|
|
|
|
|
|
|
self.onText?(shown)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
|
|
NSLog("Live preview: results ended: \(error)")
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
guard current == self.session else { return } // stop() already tore everything down
|
|
|
|
|
|
|
|
try await analyzer.start(inputSequence: stream)
|
|
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
|
|
NSLog("Live preview failed: \(error)")
|
|
|
|
|
|
|
|
if current == self.session { self.stop() }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func stop() {
|
|
|
|
|
|
|
|
session += 1
|
|
|
|
|
|
|
|
if let engine = engine {
|
|
|
|
|
|
|
|
engine.inputNode.removeTap(onBus: 0)
|
|
|
|
|
|
|
|
engine.stop()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
engine = nil
|
|
|
|
|
|
|
|
input?.finish()
|
|
|
|
|
|
|
|
input = nil
|
|
|
|
|
|
|
|
resultsTask?.cancel()
|
|
|
|
|
|
|
|
resultsTask = nil
|
|
|
|
|
|
|
|
if let analyzer = analyzer {
|
|
|
|
|
|
|
|
Task { await analyzer.cancelAndFinishNow() }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
analyzer = nil
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Segments don't always carry a separating space
|
|
|
|
|
|
|
|
static func join(_ head: String, _ tail: String) -> String {
|
|
|
|
|
|
|
|
guard let last = head.last, let first = tail.first, !last.isWhitespace, !first.isWhitespace else { return head + tail }
|
|
|
|
|
|
|
|
return head + " " + tail
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Runs on the audio thread: take the voice channel as mono, convert it to the
|
|
|
|
|
|
|
|
// analyzer's format. Built in a static context so it carries no actor assumptions.
|
|
|
|
|
|
|
|
private static func tapBlock(converter: AVAudioConverter, format: AVAudioFormat,
|
|
|
|
|
|
|
|
continuation: AsyncStream<AnalyzerInput>.Continuation) -> AVAudioNodeTapBlock {
|
|
|
|
|
|
|
|
let downmixer = VoiceChannelPicker()
|
|
|
|
|
|
|
|
return { micBuffer, _ in
|
|
|
|
|
|
|
|
guard let buffer = downmixer.mono(from: micBuffer) else { return }
|
|
|
|
|
|
|
|
let ratio = format.sampleRate / buffer.format.sampleRate
|
|
|
|
|
|
|
|
let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 64
|
|
|
|
|
|
|
|
guard let out = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: capacity) else { return }
|
|
|
|
|
|
|
|
var consumed = false
|
|
|
|
|
|
|
|
var error: NSError?
|
|
|
|
|
|
|
|
converter.convert(to: out, error: &error) { _, status in
|
|
|
|
|
|
|
|
if consumed {
|
|
|
|
|
|
|
|
status.pointee = .noDataNow
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
consumed = true
|
|
|
|
|
|
|
|
status.pointee = .haveData
|
|
|
|
|
|
|
|
return buffer
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if error == nil, out.frameLength > 0 {
|
|
|
|
|
|
|
|
continuation.yield(AnalyzerInput(buffer: out))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Picks the input channel that carries the voice and returns it as a mono buffer.
|
|
|
|
|
|
|
|
// AVAudioConverter keeps only channel 1 when downmixing stereo, and outputs pure
|
|
|
|
|
|
|
|
// silence for layout-less multi-channel inputs (e.g. a 4-input USB interface),
|
|
|
|
|
|
|
|
// so the live preview model would hear nothing. The loudest channel is chosen on
|
|
|
|
|
|
|
|
// smoothed energy, and only replaced by one that is clearly (6 dB) louder.
|
|
|
|
|
|
|
|
final class VoiceChannelPicker {
|
|
|
|
|
|
|
|
private var energy: [Float] = []
|
|
|
|
|
|
|
|
private var active = 0
|
|
|
|
|
|
|
|
private var monoFormat: AVAudioFormat?
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func mono(from buffer: AVAudioPCMBuffer) -> AVAudioPCMBuffer? {
|
|
|
|
|
|
|
|
let channels = Int(buffer.format.channelCount)
|
|
|
|
|
|
|
|
let frames = Int(buffer.frameLength)
|
|
|
|
|
|
|
|
guard frames > 0, let data = buffer.floatChannelData else { return nil }
|
|
|
|
|
|
|
|
if channels == 1 { return buffer }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if energy.count != channels {
|
|
|
|
|
|
|
|
energy = Array(repeating: 0, count: channels)
|
|
|
|
|
|
|
|
active = 0
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
for c in 0..<channels {
|
|
|
|
|
|
|
|
var sum: Float = 0
|
|
|
|
|
|
|
|
for i in 0..<frames { sum += data[c][i] * data[c][i] }
|
|
|
|
|
|
|
|
energy[c] = energy[c] * 0.9 + sum / Float(frames) * 0.1
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if let loudest = energy.indices.max(by: { energy[$0] < energy[$1] }), energy[loudest] > energy[active] * 4 {
|
|
|
|
|
|
|
|
active = loudest
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if monoFormat?.sampleRate != buffer.format.sampleRate {
|
|
|
|
|
|
|
|
monoFormat = AVAudioFormat(standardFormatWithSampleRate: buffer.format.sampleRate, channels: 1)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
guard let format = monoFormat,
|
|
|
|
|
|
|
|
let out = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(frames)),
|
|
|
|
|
|
|
|
let dest = out.floatChannelData else { return nil }
|
|
|
|
|
|
|
|
dest[0].update(from: data[active], count: frames)
|
|
|
|
|
|
|
|
out.frameLength = AVAudioFrameCount(frames)
|
|
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
final class MeterView: NSView {
|
|
|
|
final class MeterView: NSView {
|
|
|
|
enum Mode { case recording, transcribing }
|
|
|
|
enum Mode { case recording, transcribing }
|
|
|
|
enum Notice { case loadingModel, modelLoaded }
|
|
|
|
enum Notice { case loadingModel, modelLoaded }
|
|
|
|
@@ -419,15 +652,55 @@ final class MeterView: NSView {
|
|
|
|
private var silent = false
|
|
|
|
private var silent = false
|
|
|
|
var mode = Mode.recording { didSet { needsDisplay = true } }
|
|
|
|
var mode = Mode.recording { didSet { needsDisplay = true } }
|
|
|
|
var notice: Notice? { didSet { needsDisplay = true } }
|
|
|
|
var notice: Notice? { didSet { needsDisplay = true } }
|
|
|
|
|
|
|
|
var previewText: String? { didSet { if previewText != oldValue { cachedPreview = nil; needsDisplay = true } } }
|
|
|
|
|
|
|
|
var hasPreview: Bool { previewBlock() != nil }
|
|
|
|
|
|
|
|
|
|
|
|
// Height for the current mode + notice
|
|
|
|
private static let maxPreviewLines = 3
|
|
|
|
|
|
|
|
private static let previewFont = NSFont.systemFont(ofSize: 13)
|
|
|
|
|
|
|
|
private var cachedPreview: (width: CGFloat, text: NSAttributedString, height: CGFloat)?
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private var noticeHeight: CGFloat {
|
|
|
|
|
|
|
|
switch notice {
|
|
|
|
|
|
|
|
case .loadingModel?: return 35
|
|
|
|
|
|
|
|
case .modelLoaded?: return 19
|
|
|
|
|
|
|
|
case nil: return 0
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Height for the current mode, notice and live preview text
|
|
|
|
var preferredHeight: CGFloat {
|
|
|
|
var preferredHeight: CGFloat {
|
|
|
|
let base: CGFloat = mode == .recording ? 72 : 44
|
|
|
|
let base: CGFloat = mode == .recording ? 72 : 44
|
|
|
|
switch notice {
|
|
|
|
let preview = previewBlock().map { 6 + $0.height } ?? 0
|
|
|
|
case .loadingModel?: return base + 34
|
|
|
|
return base + noticeHeight + preview
|
|
|
|
case .modelLoaded?: return base + 20
|
|
|
|
|
|
|
|
case nil: return base
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// The tail of the live transcript that fits in a few lines; older words scroll off the top
|
|
|
|
|
|
|
|
private func previewBlock() -> (text: NSAttributedString, height: CGFloat)? {
|
|
|
|
|
|
|
|
guard let raw = previewText, !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
|
|
|
|
|
|
|
|
let width = bounds.width - 32
|
|
|
|
|
|
|
|
if let cached = cachedPreview, cached.width == width { return (cached.text, cached.height) }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let attrs: [NSAttributedString.Key: Any] = [
|
|
|
|
|
|
|
|
.font: MeterView.previewFont,
|
|
|
|
|
|
|
|
.foregroundColor: NSColor(calibratedWhite: 1, alpha: 0.92)
|
|
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
func measure(_ string: NSAttributedString) -> CGFloat {
|
|
|
|
|
|
|
|
ceil(string.boundingRect(with: NSSize(width: width, height: 10_000), options: [.usesLineFragmentOrigin, .usesFontLeading]).height)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
let lineHeight = measure(NSAttributedString(string: "Ag", attributes: attrs))
|
|
|
|
|
|
|
|
let maxHeight = lineHeight * CGFloat(MeterView.maxPreviewLines) + 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let words = raw.split(whereSeparator: { $0.isWhitespace })
|
|
|
|
|
|
|
|
var dropped = 0
|
|
|
|
|
|
|
|
var text = NSAttributedString(string: words.joined(separator: " "), attributes: attrs)
|
|
|
|
|
|
|
|
var height = measure(text)
|
|
|
|
|
|
|
|
while height > maxHeight && dropped < words.count - 1 {
|
|
|
|
|
|
|
|
dropped += 1
|
|
|
|
|
|
|
|
text = NSAttributedString(string: "…" + words[dropped...].joined(separator: " "), attributes: attrs)
|
|
|
|
|
|
|
|
height = measure(text)
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
cachedPreview = (width, text, height)
|
|
|
|
|
|
|
|
return (text, height)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func set(level: CGFloat, silent: Bool) {
|
|
|
|
func set(level: CGFloat, silent: Bool) {
|
|
|
|
@@ -435,7 +708,16 @@ final class MeterView: NSView {
|
|
|
|
self.silent = silent
|
|
|
|
self.silent = silent
|
|
|
|
needsDisplay = true
|
|
|
|
needsDisplay = true
|
|
|
|
}
|
|
|
|
}
|
|
|
|
func reset() { level = 0; silent = false; mode = .recording; notice = nil; needsDisplay = true }
|
|
|
|
func reset() { level = 0; silent = false; mode = .recording; notice = nil; previewText = nil; needsDisplay = true }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Draws a symbol centred on a text line drawn at `origin`; returns the x where the text starts
|
|
|
|
|
|
|
|
private static func drawSymbol(_ name: String, color: NSColor, font: NSFont, at origin: NSPoint) -> CGFloat {
|
|
|
|
|
|
|
|
guard let image = Icons.coloredSymbol(name, pointSize: font.pointSize, weight: .medium, color: color) else { return origin.x }
|
|
|
|
|
|
|
|
let lineHeight = ceil(font.ascender - font.descender)
|
|
|
|
|
|
|
|
image.draw(in: NSRect(x: origin.x, y: origin.y + (lineHeight - image.size.height) / 2,
|
|
|
|
|
|
|
|
width: image.size.width, height: image.size.height))
|
|
|
|
|
|
|
|
return origin.x + image.size.width + 5
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
override func draw(_ dirtyRect: NSRect) {
|
|
|
|
override func draw(_ dirtyRect: NSRect) {
|
|
|
|
let bg = NSBezierPath(roundedRect: bounds, xRadius: 12, yRadius: 12)
|
|
|
|
let bg = NSBezierPath(roundedRect: bounds, xRadius: 12, yRadius: 12)
|
|
|
|
@@ -443,18 +725,25 @@ final class MeterView: NSView {
|
|
|
|
bg.fill()
|
|
|
|
bg.fill()
|
|
|
|
|
|
|
|
|
|
|
|
let warn = mode == .recording && silent
|
|
|
|
let warn = mode == .recording && silent
|
|
|
|
let label = mode == .transcribing ? "⏳ Transcribing…" : warn ? "🎤 No audio input?" : "🎤 Recording…"
|
|
|
|
let titleFont = NSFont.systemFont(ofSize: 12, weight: .medium)
|
|
|
|
|
|
|
|
let (symbol, symbolColor, label): (String, NSColor, String) =
|
|
|
|
|
|
|
|
mode == .transcribing ? ("hourglass", .white, "Transcribing…")
|
|
|
|
|
|
|
|
: warn ? ("mic.slash.fill", .systemRed, "No audio input?")
|
|
|
|
|
|
|
|
: ("mic.fill", .systemRed, "Recording…")
|
|
|
|
var textY = bounds.height - 26
|
|
|
|
var textY = bounds.height - 26
|
|
|
|
label.draw(at: NSPoint(x: 16, y: textY), withAttributes: [
|
|
|
|
let titleX = MeterView.drawSymbol(symbol, color: symbolColor, font: titleFont, at: NSPoint(x: 16, y: textY))
|
|
|
|
.font: NSFont.systemFont(ofSize: 12, weight: .medium),
|
|
|
|
label.draw(at: NSPoint(x: titleX, y: textY), withAttributes: [
|
|
|
|
|
|
|
|
.font: titleFont,
|
|
|
|
.foregroundColor: warn ? NSColor.systemRed : NSColor.white
|
|
|
|
.foregroundColor: warn ? NSColor.systemRed : NSColor.white
|
|
|
|
])
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
switch notice {
|
|
|
|
switch notice {
|
|
|
|
case .loadingModel?:
|
|
|
|
case .loadingModel?:
|
|
|
|
textY -= 19
|
|
|
|
textY -= 19
|
|
|
|
"Loading model into memory…".draw(at: NSPoint(x: 16, y: textY), withAttributes: [
|
|
|
|
let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
|
|
|
|
.font: NSFont.systemFont(ofSize: 11, weight: .semibold),
|
|
|
|
let x = MeterView.drawSymbol("memorychip", color: .systemYellow, font: font, at: NSPoint(x: 16, y: textY))
|
|
|
|
|
|
|
|
"Loading model into memory…".draw(at: NSPoint(x: x, y: textY), withAttributes: [
|
|
|
|
|
|
|
|
.font: font,
|
|
|
|
.foregroundColor: NSColor.systemYellow
|
|
|
|
.foregroundColor: NSColor.systemYellow
|
|
|
|
])
|
|
|
|
])
|
|
|
|
textY -= 16
|
|
|
|
textY -= 16
|
|
|
|
@@ -464,14 +753,22 @@ final class MeterView: NSView {
|
|
|
|
])
|
|
|
|
])
|
|
|
|
case .modelLoaded?:
|
|
|
|
case .modelLoaded?:
|
|
|
|
textY -= 19
|
|
|
|
textY -= 19
|
|
|
|
"✓ Model loaded".draw(at: NSPoint(x: 16, y: textY), withAttributes: [
|
|
|
|
let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
|
|
|
|
.font: NSFont.systemFont(ofSize: 11, weight: .semibold),
|
|
|
|
let x = MeterView.drawSymbol("checkmark.circle.fill", color: .systemGreen, font: font, at: NSPoint(x: 16, y: textY))
|
|
|
|
|
|
|
|
"Model loaded".draw(at: NSPoint(x: x, y: textY), withAttributes: [
|
|
|
|
|
|
|
|
.font: font,
|
|
|
|
.foregroundColor: NSColor.systemGreen
|
|
|
|
.foregroundColor: NSColor.systemGreen
|
|
|
|
])
|
|
|
|
])
|
|
|
|
case nil:
|
|
|
|
case nil:
|
|
|
|
break
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if let preview = previewBlock() {
|
|
|
|
|
|
|
|
let top = textY - 6
|
|
|
|
|
|
|
|
preview.text.draw(with: NSRect(x: 16, y: top - preview.height, width: bounds.width - 32, height: preview.height),
|
|
|
|
|
|
|
|
options: [.usesLineFragmentOrigin, .usesFontLeading])
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
guard mode == .recording else { return }
|
|
|
|
guard mode == .recording else { return }
|
|
|
|
|
|
|
|
|
|
|
|
let segs = 20
|
|
|
|
let segs = 20
|
|
|
|
@@ -496,7 +793,7 @@ final class MeterView: NSView {
|
|
|
|
|
|
|
|
|
|
|
|
final class RecordingHUD {
|
|
|
|
final class RecordingHUD {
|
|
|
|
private var window: NSWindow?
|
|
|
|
private var window: NSWindow?
|
|
|
|
private let meter = MeterView(frame: NSRect(x: 0, y: 0, width: 320, height: 72))
|
|
|
|
private let meter = MeterView(frame: NSRect(x: 0, y: 0, width: 420, height: 72))
|
|
|
|
|
|
|
|
|
|
|
|
func show() {
|
|
|
|
func show() {
|
|
|
|
meter.reset()
|
|
|
|
meter.reset()
|
|
|
|
@@ -512,6 +809,13 @@ final class RecordingHUD {
|
|
|
|
layout()
|
|
|
|
layout()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func setPreview(_ text: String?) {
|
|
|
|
|
|
|
|
meter.previewText = text
|
|
|
|
|
|
|
|
layout()
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
var hasPreview: Bool { meter.hasPreview }
|
|
|
|
|
|
|
|
|
|
|
|
// Stay up after recording while a slow (model-loading) transcription runs
|
|
|
|
// Stay up after recording while a slow (model-loading) transcription runs
|
|
|
|
func showTranscribing(notice: MeterView.Notice?) {
|
|
|
|
func showTranscribing(notice: MeterView.Notice?) {
|
|
|
|
makeWindowIfNeeded()
|
|
|
|
makeWindowIfNeeded()
|
|
|
|
@@ -533,7 +837,7 @@ final class RecordingHUD {
|
|
|
|
|
|
|
|
|
|
|
|
private func makeWindowIfNeeded() {
|
|
|
|
private func makeWindowIfNeeded() {
|
|
|
|
if window == nil {
|
|
|
|
if window == nil {
|
|
|
|
let w = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 320, height: 72),
|
|
|
|
let w = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 420, height: 72),
|
|
|
|
styleMask: [.borderless, .nonactivatingPanel],
|
|
|
|
styleMask: [.borderless, .nonactivatingPanel],
|
|
|
|
backing: .buffered, defer: false)
|
|
|
|
backing: .buffered, defer: false)
|
|
|
|
w.isOpaque = false
|
|
|
|
w.isOpaque = false
|
|
|
|
@@ -561,6 +865,7 @@ final class DownloadProgressWindow: NSObject {
|
|
|
|
private var phase = Phase.downloading
|
|
|
|
private var phase = Phase.downloading
|
|
|
|
|
|
|
|
|
|
|
|
private var window: NSWindow?
|
|
|
|
private var window: NSWindow?
|
|
|
|
|
|
|
|
private let titleIcon = NSImageView()
|
|
|
|
private let titleLabel = NSTextField(labelWithString: "")
|
|
|
|
private let titleLabel = NSTextField(labelWithString: "")
|
|
|
|
// Two indicators instead of toggling isIndeterminate on one: after an
|
|
|
|
// Two indicators instead of toggling isIndeterminate on one: after an
|
|
|
|
// indeterminate phase, NSProgressIndicator stops drawing determinate values.
|
|
|
|
// indeterminate phase, NSProgressIndicator stops drawing determinate values.
|
|
|
|
@@ -614,6 +919,7 @@ final class DownloadProgressWindow: NSObject {
|
|
|
|
bytesPerSecond = 0
|
|
|
|
bytesPerSecond = 0
|
|
|
|
lastRender = .distantPast
|
|
|
|
lastRender = .distantPast
|
|
|
|
titleLabel.stringValue = "Downloading \(modelName)"
|
|
|
|
titleLabel.stringValue = "Downloading \(modelName)"
|
|
|
|
|
|
|
|
setTitleIcon("arrow.down.circle.fill", color: .controlAccentColor)
|
|
|
|
setBar(indeterminate: true)
|
|
|
|
setBar(indeterminate: true)
|
|
|
|
setDetail("Connecting…")
|
|
|
|
setDetail("Connecting…")
|
|
|
|
hintLabel.isHidden = false
|
|
|
|
hintLabel.isHidden = false
|
|
|
|
@@ -659,6 +965,7 @@ final class DownloadProgressWindow: NSObject {
|
|
|
|
func showLoading(modelName: String) {
|
|
|
|
func showLoading(modelName: String) {
|
|
|
|
phase = .loading
|
|
|
|
phase = .loading
|
|
|
|
titleLabel.stringValue = "Loading \(modelName)…"
|
|
|
|
titleLabel.stringValue = "Loading \(modelName)…"
|
|
|
|
|
|
|
|
setTitleIcon("memorychip.fill", color: .controlAccentColor)
|
|
|
|
setBar(indeterminate: true)
|
|
|
|
setBar(indeterminate: true)
|
|
|
|
setDetail("Download complete. Loading the model into memory…")
|
|
|
|
setDetail("Download complete. Loading the model into memory…")
|
|
|
|
hintLabel.isHidden = true
|
|
|
|
hintLabel.isHidden = true
|
|
|
|
@@ -667,7 +974,8 @@ final class DownloadProgressWindow: NSObject {
|
|
|
|
|
|
|
|
|
|
|
|
func showReady(modelName: String) {
|
|
|
|
func showReady(modelName: String) {
|
|
|
|
phase = .ready
|
|
|
|
phase = .ready
|
|
|
|
titleLabel.stringValue = "✓ \(modelName) is ready"
|
|
|
|
titleLabel.stringValue = "\(modelName) is ready"
|
|
|
|
|
|
|
|
setTitleIcon("checkmark.circle.fill", color: .systemGreen)
|
|
|
|
setBar(indeterminate: false, value: 1)
|
|
|
|
setBar(indeterminate: false, value: 1)
|
|
|
|
setDetail("It is now the active model. Press ⌃⌥D to dictate.")
|
|
|
|
setDetail("It is now the active model. Press ⌃⌥D to dictate.")
|
|
|
|
hintLabel.isHidden = true
|
|
|
|
hintLabel.isHidden = true
|
|
|
|
@@ -676,7 +984,8 @@ final class DownloadProgressWindow: NSObject {
|
|
|
|
|
|
|
|
|
|
|
|
func showFailed(modelName: String, message: String) {
|
|
|
|
func showFailed(modelName: String, message: String) {
|
|
|
|
phase = .failed
|
|
|
|
phase = .failed
|
|
|
|
titleLabel.stringValue = "⚠️ Could not download \(modelName)"
|
|
|
|
titleLabel.stringValue = "Could not download \(modelName)"
|
|
|
|
|
|
|
|
setTitleIcon("exclamationmark.triangle.fill", color: .systemYellow)
|
|
|
|
busyBar.stopAnimation(nil)
|
|
|
|
busyBar.stopAnimation(nil)
|
|
|
|
busyBar.isHidden = true
|
|
|
|
busyBar.isHidden = true
|
|
|
|
bar.isHidden = true
|
|
|
|
bar.isHidden = true
|
|
|
|
@@ -694,7 +1003,11 @@ final class DownloadProgressWindow: NSObject {
|
|
|
|
|
|
|
|
|
|
|
|
let v = NSView(frame: NSRect(x: 0, y: 0, width: 440, height: 170))
|
|
|
|
let v = NSView(frame: NSRect(x: 0, y: 0, width: 440, height: 170))
|
|
|
|
|
|
|
|
|
|
|
|
titleLabel.frame = NSRect(x: 20, y: 130, width: 400, height: 20)
|
|
|
|
titleIcon.frame = NSRect(x: 20, y: 131, width: 18, height: 18)
|
|
|
|
|
|
|
|
titleIcon.symbolConfiguration = .init(pointSize: 14, weight: .semibold)
|
|
|
|
|
|
|
|
v.addSubview(titleIcon)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
titleLabel.frame = NSRect(x: 44, y: 130, width: 376, height: 20)
|
|
|
|
titleLabel.font = .boldSystemFont(ofSize: 13)
|
|
|
|
titleLabel.font = .boldSystemFont(ofSize: 13)
|
|
|
|
titleLabel.lineBreakMode = .byTruncatingTail
|
|
|
|
titleLabel.lineBreakMode = .byTruncatingTail
|
|
|
|
v.addSubview(titleLabel)
|
|
|
|
v.addSubview(titleLabel)
|
|
|
|
@@ -738,6 +1051,11 @@ final class DownloadProgressWindow: NSObject {
|
|
|
|
return w
|
|
|
|
return w
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
private func setTitleIcon(_ name: String, color: NSColor) {
|
|
|
|
|
|
|
|
titleIcon.image = NSImage(systemSymbolName: name, accessibilityDescription: nil)
|
|
|
|
|
|
|
|
titleIcon.contentTintColor = color
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private func setBar(indeterminate: Bool, value: Double = 0) {
|
|
|
|
private func setBar(indeterminate: Bool, value: Double = 0) {
|
|
|
|
bar.isHidden = indeterminate
|
|
|
|
bar.isHidden = indeterminate
|
|
|
|
busyBar.isHidden = !indeterminate
|
|
|
|
busyBar.isHidden = !indeterminate
|
|
|
|
@@ -786,6 +1104,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
var settingsWindowController: NSWindowController?
|
|
|
|
var settingsWindowController: NSWindowController?
|
|
|
|
var whisperServer: WhisperServer?
|
|
|
|
var whisperServer: WhisperServer?
|
|
|
|
let recordingHUD = RecordingHUD()
|
|
|
|
let recordingHUD = RecordingHUD()
|
|
|
|
|
|
|
|
let livePreview = LivePreview()
|
|
|
|
var meterTimer: Timer?
|
|
|
|
var meterTimer: Timer?
|
|
|
|
var silenceStart: Date?
|
|
|
|
var silenceStart: Date?
|
|
|
|
weak var dictTable: NSTableView?
|
|
|
|
weak var dictTable: NSTableView?
|
|
|
|
@@ -814,6 +1133,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
set { UserDefaults.standard.set(newValue, forKey: Defaults.playSounds) }
|
|
|
|
set { UserDefaults.standard.set(newValue, forKey: Defaults.playSounds) }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
var livePreviewEnabled: Bool {
|
|
|
|
|
|
|
|
get { UserDefaults.standard.object(forKey: Defaults.livePreview) as? Bool ?? true }
|
|
|
|
|
|
|
|
set { UserDefaults.standard.set(newValue, forKey: Defaults.livePreview) }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Custom dictionary: ordered list of ["from": recognised, "to": replacement].
|
|
|
|
// Custom dictionary: ordered list of ["from": recognised, "to": replacement].
|
|
|
|
var replacements: [[String: String]] {
|
|
|
|
var replacements: [[String: String]] {
|
|
|
|
get { (UserDefaults.standard.array(forKey: Defaults.replacements) as? [[String: String]]) ?? [] }
|
|
|
|
get { (UserDefaults.standard.array(forKey: Defaults.replacements) as? [[String: String]]) ?? [] }
|
|
|
|
@@ -823,11 +1147,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
// Whisper initial prompt built from the desired ("to") terms, to bias
|
|
|
|
// Whisper initial prompt built from the desired ("to") terms, to bias
|
|
|
|
// recognition toward the user's names/jargon. Returns nil if empty.
|
|
|
|
// recognition toward the user's names/jargon. Returns nil if empty.
|
|
|
|
func customPrompt() -> String? {
|
|
|
|
func customPrompt() -> String? {
|
|
|
|
let terms = replacements.compactMap { $0["to"] }
|
|
|
|
let terms = dictionaryTerms()
|
|
|
|
|
|
|
|
return terms.isEmpty ? nil : terms.joined(separator: ", ")
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// The desired ("to") dictionary terms, also given to the live preview model
|
|
|
|
|
|
|
|
func dictionaryTerms() -> [String] {
|
|
|
|
|
|
|
|
replacements.compactMap { $0["to"] }
|
|
|
|
.map { $0.replacingOccurrences(of: "\\n", with: " ") }
|
|
|
|
.map { $0.replacingOccurrences(of: "\\n", with: " ") }
|
|
|
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
|
|
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
|
|
|
.filter { !$0.isEmpty }
|
|
|
|
.filter { !$0.isEmpty }
|
|
|
|
return terms.isEmpty ? nil : terms.joined(separator: ", ")
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Apply the dictionary find/replace pairs to a transcript. "\n" in a
|
|
|
|
// Apply the dictionary find/replace pairs to a transcript. "\n" in a
|
|
|
|
@@ -858,6 +1187,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
checkAccessibilityPermission()
|
|
|
|
checkAccessibilityPermission()
|
|
|
|
|
|
|
|
if livePreviewEnabled { LivePreview.installModelIfNeeded(languageCode: language) }
|
|
|
|
|
|
|
|
|
|
|
|
NSLog("WhisperDictate started. Press ⌃⌥D to toggle recording.")
|
|
|
|
NSLog("WhisperDictate started. Press ⌃⌥D to toggle recording.")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@@ -893,7 +1223,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
if alert.runModal() == .alertFirstButtonReturn {
|
|
|
|
if alert.runModal() == .alertFirstButtonReturn {
|
|
|
|
downloadModel(WhisperModels.available[popup.indexOfSelectedItem])
|
|
|
|
downloadModel(WhisperModels.available[popup.indexOfSelectedItem])
|
|
|
|
} else {
|
|
|
|
} else {
|
|
|
|
updateStatus("⚠️ No model selected")
|
|
|
|
updateStatus("No model selected", warning: true)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@@ -911,7 +1241,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
return
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
guard let url = URL(string: model.url) else {
|
|
|
|
guard let url = URL(string: model.url) else {
|
|
|
|
updateStatus("⚠️ Invalid URL")
|
|
|
|
updateStatus("Invalid URL", warning: true)
|
|
|
|
return
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@@ -955,7 +1285,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
func failDownload(_ message: String) {
|
|
|
|
func failDownload(_ message: String) {
|
|
|
|
let name = currentDownloadModel?.name ?? "model"
|
|
|
|
let name = currentDownloadModel?.name ?? "model"
|
|
|
|
clearDownloadState()
|
|
|
|
clearDownloadState()
|
|
|
|
updateStatus("⚠️ Download failed")
|
|
|
|
updateStatus("Download failed", warning: true)
|
|
|
|
if playSounds { NSSound(named: "Basso")?.play() }
|
|
|
|
if playSounds { NSSound(named: "Basso")?.play() }
|
|
|
|
downloadWindow.showFailed(modelName: name, message: message)
|
|
|
|
downloadWindow.showFailed(modelName: name, message: message)
|
|
|
|
downloadWindow.show()
|
|
|
|
downloadWindow.show()
|
|
|
|
@@ -968,24 +1298,43 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
updateDownloadIndicators()
|
|
|
|
updateDownloadIndicators()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Menu bar icon when idle: download progress while a model downloads, else the mic
|
|
|
|
// Menu bar icon. While idle it shows the download progress if a model is downloading.
|
|
|
|
var idleIcon: String {
|
|
|
|
enum MenuBarIcon { case idle, recording, busy }
|
|
|
|
downloadTask != nil ? "⬇️ \(downloadPercent)%" : "🎤"
|
|
|
|
var menuBarIcon = MenuBarIcon.idle { didSet { refreshMenuBarIcon() } }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
func refreshMenuBarIcon() {
|
|
|
|
|
|
|
|
guard let button = statusItem?.button else { return }
|
|
|
|
|
|
|
|
let downloading = menuBarIcon == .idle && downloadTask != nil
|
|
|
|
|
|
|
|
switch menuBarIcon {
|
|
|
|
|
|
|
|
case .idle:
|
|
|
|
|
|
|
|
button.image = downloading
|
|
|
|
|
|
|
|
? Icons.symbol("arrow.down.circle", pointSize: 15, description: "WhisperDictate: downloading a model")
|
|
|
|
|
|
|
|
: Icons.symbol("mic", pointSize: 15, description: "WhisperDictate")
|
|
|
|
|
|
|
|
case .recording:
|
|
|
|
|
|
|
|
button.image = Icons.coloredSymbol("mic.fill", pointSize: 15, color: .systemRed, description: "WhisperDictate: recording")
|
|
|
|
|
|
|
|
case .busy:
|
|
|
|
|
|
|
|
button.image = Icons.symbol("hourglass", pointSize: 15, description: "WhisperDictate: working")
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
button.imagePosition = .imageLeading
|
|
|
|
|
|
|
|
button.title = downloading ? "\(downloadPercent)%" : ""
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Sync the menu bar icon, the menu's download line and the Settings button
|
|
|
|
// Sync the menu bar icon, the menu's download line and the Settings button
|
|
|
|
// with the download state, without overriding the recording/transcribing icon.
|
|
|
|
// with the download state, without overriding the recording/transcribing icon.
|
|
|
|
func updateDownloadIndicators() {
|
|
|
|
func updateDownloadIndicators() {
|
|
|
|
if let title = statusItem.button?.title, title == "🎤" || title.hasPrefix("⬇️") {
|
|
|
|
refreshMenuBarIcon()
|
|
|
|
statusItem.button?.title = idleIcon
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
if let item = statusItem.menu?.item(withTag: 101) {
|
|
|
|
if let item = statusItem.menu?.item(withTag: 101) {
|
|
|
|
item.isHidden = currentDownloadModel == nil
|
|
|
|
item.isHidden = currentDownloadModel == nil
|
|
|
|
if let model = currentDownloadModel {
|
|
|
|
if let model = currentDownloadModel {
|
|
|
|
item.title = "⬇️ Downloading \(model.name): \(downloadPercent)% — Show Progress…"
|
|
|
|
item.title = "Downloading \(model.name): \(downloadPercent)% — Show Progress…"
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
settingsDownloadButton?.title = downloadTask != nil ? "⬇️ \(downloadPercent)%" : "Download..."
|
|
|
|
if let button = settingsDownloadButton {
|
|
|
|
|
|
|
|
let downloading = downloadTask != nil
|
|
|
|
|
|
|
|
button.image = downloading ? Icons.symbol("arrow.down.circle", pointSize: 11) : nil
|
|
|
|
|
|
|
|
button.imagePosition = .imageLeading
|
|
|
|
|
|
|
|
button.title = downloading ? "\(downloadPercent)%" : "Download..."
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - URLSessionDownloadDelegate
|
|
|
|
// MARK: - URLSessionDownloadDelegate
|
|
|
|
@@ -1055,7 +1404,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
// MARK: - Status Item
|
|
|
|
// MARK: - Status Item
|
|
|
|
func setupStatusItem() {
|
|
|
|
func setupStatusItem() {
|
|
|
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
|
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
|
|
|
statusItem.button?.title = "🎤"
|
|
|
|
refreshMenuBarIcon()
|
|
|
|
|
|
|
|
|
|
|
|
let menu = NSMenu()
|
|
|
|
let menu = NSMenu()
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1067,6 +1416,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
menu.addItem(statusMenuItem)
|
|
|
|
menu.addItem(statusMenuItem)
|
|
|
|
|
|
|
|
|
|
|
|
let downloadMenuItem = NSMenuItem(title: "", action: #selector(showDownloadWindow), keyEquivalent: "")
|
|
|
|
let downloadMenuItem = NSMenuItem(title: "", action: #selector(showDownloadWindow), keyEquivalent: "")
|
|
|
|
|
|
|
|
downloadMenuItem.image = Icons.symbol("arrow.down.circle")
|
|
|
|
downloadMenuItem.target = self
|
|
|
|
downloadMenuItem.target = self
|
|
|
|
downloadMenuItem.tag = 101
|
|
|
|
downloadMenuItem.tag = 101
|
|
|
|
downloadMenuItem.isHidden = true
|
|
|
|
downloadMenuItem.isHidden = true
|
|
|
|
@@ -1091,7 +1441,7 @@ 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: 540),
|
|
|
|
contentRect: NSRect(x: 0, y: 0, width: 450, height: 580),
|
|
|
|
styleMask: [.titled, .closable],
|
|
|
|
styleMask: [.titled, .closable],
|
|
|
|
backing: .buffered,
|
|
|
|
backing: .buffered,
|
|
|
|
defer: false
|
|
|
|
defer: false
|
|
|
|
@@ -1102,7 +1452,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
|
|
|
|
|
|
|
|
let contentView = NSView(frame: window.contentView!.bounds)
|
|
|
|
let contentView = NSView(frame: window.contentView!.bounds)
|
|
|
|
|
|
|
|
|
|
|
|
var y: CGFloat = 498
|
|
|
|
var y: CGFloat = 538
|
|
|
|
let labelWidth: CGFloat = 120
|
|
|
|
let labelWidth: CGFloat = 120
|
|
|
|
let controlX: CGFloat = 140
|
|
|
|
let controlX: CGFloat = 140
|
|
|
|
let controlWidth: CGFloat = 280
|
|
|
|
let controlWidth: CGFloat = 280
|
|
|
|
@@ -1180,6 +1530,15 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
loginCheck.state = isLaunchAtLoginEnabled() ? .on : .off
|
|
|
|
loginCheck.state = isLaunchAtLoginEnabled() ? .on : .off
|
|
|
|
contentView.addSubview(loginCheck)
|
|
|
|
contentView.addSubview(loginCheck)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
y -= 40
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Live preview
|
|
|
|
|
|
|
|
let previewCheck = NSButton(checkboxWithTitle: "Live preview while recording", target: self, action: #selector(livePreviewChanged(_:)))
|
|
|
|
|
|
|
|
previewCheck.frame = NSRect(x: controlX, y: y, width: controlWidth, height: 24)
|
|
|
|
|
|
|
|
previewCheck.state = livePreviewEnabled ? .on : .off
|
|
|
|
|
|
|
|
previewCheck.toolTip = "Shows a rough live transcript in the recording overlay using macOS's on-device dictation model. The pasted text still comes from Whisper."
|
|
|
|
|
|
|
|
contentView.addSubview(previewCheck)
|
|
|
|
|
|
|
|
|
|
|
|
// Custom dictionary (recognised -> replacement)
|
|
|
|
// Custom dictionary (recognised -> replacement)
|
|
|
|
let dictLabel = NSTextField(labelWithString: "Dictionary (heard → replacement):")
|
|
|
|
let dictLabel = NSTextField(labelWithString: "Dictionary (heard → replacement):")
|
|
|
|
dictLabel.frame = NSRect(x: 20, y: 304, width: 300, height: 20)
|
|
|
|
dictLabel.frame = NSRect(x: 20, y: 304, width: 300, height: 20)
|
|
|
|
@@ -1247,6 +1606,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
@objc func languageChanged(_ sender: NSPopUpButton) {
|
|
|
|
@objc func languageChanged(_ sender: NSPopUpButton) {
|
|
|
|
if let code = sender.selectedItem?.representedObject as? String {
|
|
|
|
if let code = sender.selectedItem?.representedObject as? String {
|
|
|
|
language = code
|
|
|
|
language = code
|
|
|
|
|
|
|
|
if livePreviewEnabled { LivePreview.installModelIfNeeded(languageCode: code) }
|
|
|
|
NSLog("Language changed to: \(language)")
|
|
|
|
NSLog("Language changed to: \(language)")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@@ -1330,6 +1690,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@objc func livePreviewChanged(_ sender: NSButton) {
|
|
|
|
|
|
|
|
livePreviewEnabled = sender.state == .on
|
|
|
|
|
|
|
|
if livePreviewEnabled { LivePreview.installModelIfNeeded(languageCode: language) }
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@objc func playSoundsChanged(_ sender: NSButton) {
|
|
|
|
@objc func playSoundsChanged(_ sender: NSButton) {
|
|
|
|
playSounds = sender.state == .on
|
|
|
|
playSounds = sender.state == .on
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@@ -1450,20 +1815,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
func checkModelExists() {
|
|
|
|
func checkModelExists() {
|
|
|
|
let validation = isValidModelPath(modelPath)
|
|
|
|
let validation = isValidModelPath(modelPath)
|
|
|
|
if !validation.valid {
|
|
|
|
if !validation.valid {
|
|
|
|
updateStatus("⚠️ \(validation.error ?? "Invalid model")")
|
|
|
|
updateStatus(validation.error ?? "Invalid model", warning: true)
|
|
|
|
} else {
|
|
|
|
} else {
|
|
|
|
updateStatus("Ready")
|
|
|
|
updateStatus("Ready")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func updateStatus(_ status: String) {
|
|
|
|
func updateStatus(_ status: String, warning: Bool = false) {
|
|
|
|
if let menu = statusItem.menu {
|
|
|
|
guard let item = statusItem.menu?.item(withTag: 100) else { return }
|
|
|
|
for item in menu.items {
|
|
|
|
|
|
|
|
if item.tag == 100 {
|
|
|
|
|
|
|
|
item.title = "Status: \(status)"
|
|
|
|
item.title = "Status: \(status)"
|
|
|
|
}
|
|
|
|
item.image = warning ? Icons.coloredSymbol("exclamationmark.triangle.fill", pointSize: 13, color: .systemYellow) : nil
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Permissions
|
|
|
|
// MARK: - Permissions
|
|
|
|
@@ -1556,12 +1917,13 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
audioRecorder?.isMeteringEnabled = true
|
|
|
|
audioRecorder?.isMeteringEnabled = true
|
|
|
|
audioRecorder?.record()
|
|
|
|
audioRecorder?.record()
|
|
|
|
isRecording = true
|
|
|
|
isRecording = true
|
|
|
|
statusItem.button?.title = "🔴"
|
|
|
|
menuBarIcon = .recording
|
|
|
|
updateStatus("Recording...")
|
|
|
|
updateStatus("Recording...")
|
|
|
|
if playSounds { NSSound(named: "Tink")?.play() }
|
|
|
|
if playSounds { NSSound(named: "Tink")?.play() }
|
|
|
|
NSLog("Recording started")
|
|
|
|
NSLog("Recording started")
|
|
|
|
startMeter()
|
|
|
|
startMeter()
|
|
|
|
prepareModelForDictation()
|
|
|
|
prepareModelForDictation()
|
|
|
|
|
|
|
|
startLivePreview()
|
|
|
|
} catch {
|
|
|
|
} catch {
|
|
|
|
NSLog("Recording failed: \(error)")
|
|
|
|
NSLog("Recording failed: \(error)")
|
|
|
|
if playSounds { NSSound(named: "Basso")?.play() }
|
|
|
|
if playSounds { NSSound(named: "Basso")?.play() }
|
|
|
|
@@ -1603,6 +1965,16 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
checkModelLoaded()
|
|
|
|
checkModelLoaded()
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Live preview
|
|
|
|
|
|
|
|
func startLivePreview() {
|
|
|
|
|
|
|
|
guard livePreviewEnabled else { return }
|
|
|
|
|
|
|
|
livePreview.onText = { [weak self] text in
|
|
|
|
|
|
|
|
guard let self = self, self.isRecording else { return }
|
|
|
|
|
|
|
|
self.recordingHUD.setPreview(self.applyReplacements(text))
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
livePreview.start(languageCode: language, contextualStrings: dictionaryTerms())
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// MARK: - Model warm-up
|
|
|
|
// MARK: - Model warm-up
|
|
|
|
// Start getting the model into memory as soon as recording starts, so loading
|
|
|
|
// Start getting the model into memory as soon as recording starts, so loading
|
|
|
|
// overlaps with the user talking instead of following it. Only if that turns
|
|
|
|
// overlaps with the user talking instead of following it. Only if that turns
|
|
|
|
@@ -1659,11 +2031,14 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
audioRecorder?.stop()
|
|
|
|
audioRecorder?.stop()
|
|
|
|
isRecording = false
|
|
|
|
isRecording = false
|
|
|
|
isTranscribing = true
|
|
|
|
isTranscribing = true
|
|
|
|
// Model still loading and already announced: keep explaining the wait
|
|
|
|
livePreview.stop()
|
|
|
|
let keepHUD = modelLoading && loadNoticeShown
|
|
|
|
// Keep the HUD up while Whisper works if there is something to show:
|
|
|
|
|
|
|
|
// the live preview text, or why this transcription is slower
|
|
|
|
|
|
|
|
let explainLoading = modelLoading && loadNoticeShown
|
|
|
|
|
|
|
|
let keepHUD = explainLoading || recordingHUD.hasPreview
|
|
|
|
stopMeter(hideHUD: !keepHUD)
|
|
|
|
stopMeter(hideHUD: !keepHUD)
|
|
|
|
if keepHUD { recordingHUD.showTranscribing(notice: .loadingModel) }
|
|
|
|
if keepHUD { recordingHUD.showTranscribing(notice: explainLoading ? .loadingModel : nil) }
|
|
|
|
statusItem.button?.title = "⏳"
|
|
|
|
menuBarIcon = .busy
|
|
|
|
updateStatus("Transcribing...")
|
|
|
|
updateStatus("Transcribing...")
|
|
|
|
if playSounds { NSSound(named: "Pop")?.play() }
|
|
|
|
if playSounds { NSSound(named: "Pop")?.play() }
|
|
|
|
NSLog("Recording stopped, transcribing...")
|
|
|
|
NSLog("Recording stopped, transcribing...")
|
|
|
|
@@ -1733,7 +2108,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if !isRecording && !isTranscribing {
|
|
|
|
if !isRecording && !isTranscribing {
|
|
|
|
statusItem.button?.title = "⏳"
|
|
|
|
menuBarIcon = .busy
|
|
|
|
updateStatus("Loading model…")
|
|
|
|
updateStatus("Loading model…")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1745,7 +2120,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
defer { completion?() }
|
|
|
|
defer { completion?() }
|
|
|
|
guard let self = self, self.whisperServer === server else { return }
|
|
|
|
guard let self = self, self.whisperServer === server else { return }
|
|
|
|
let busy = self.isRecording || self.isTranscribing
|
|
|
|
let busy = self.isRecording || self.isTranscribing
|
|
|
|
if !busy { self.statusItem.button?.title = self.idleIcon }
|
|
|
|
if !busy { self.menuBarIcon = .idle }
|
|
|
|
if ok {
|
|
|
|
if ok {
|
|
|
|
if !busy { self.updateStatus("Ready") }
|
|
|
|
if !busy { self.updateStatus("Ready") }
|
|
|
|
NSLog("whisper-server ready on port \(server.port)")
|
|
|
|
NSLog("whisper-server ready on port \(server.port)")
|
|
|
|
@@ -1763,8 +2138,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
let modelValidation = isValidModelPath(modelPath)
|
|
|
|
let modelValidation = isValidModelPath(modelPath)
|
|
|
|
guard modelValidation.valid else {
|
|
|
|
guard modelValidation.valid else {
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.statusItem.button?.title = self.idleIcon
|
|
|
|
self.menuBarIcon = .idle
|
|
|
|
self.updateStatus("⚠️ \(modelValidation.error ?? "Invalid model")")
|
|
|
|
self.updateStatus(modelValidation.error ?? "Invalid model", warning: true)
|
|
|
|
if self.playSounds { NSSound(named: "Basso")?.play() }
|
|
|
|
if self.playSounds { NSSound(named: "Basso")?.play() }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return
|
|
|
|
return
|
|
|
|
@@ -1772,8 +2147,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
|
|
|
|
|
|
|
|
guard SupportedLanguages.isValid(language) else {
|
|
|
|
guard SupportedLanguages.isValid(language) else {
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
DispatchQueue.main.async {
|
|
|
|
self.statusItem.button?.title = self.idleIcon
|
|
|
|
self.menuBarIcon = .idle
|
|
|
|
self.updateStatus("⚠️ Invalid language")
|
|
|
|
self.updateStatus("Invalid language", warning: true)
|
|
|
|
if self.playSounds { NSSound(named: "Basso")?.play() }
|
|
|
|
if self.playSounds { NSSound(named: "Basso")?.play() }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return
|
|
|
|
return
|
|
|
|
@@ -1800,7 +2175,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
if let finalText = finalText, !finalText.isEmpty {
|
|
|
|
if let finalText = finalText, !finalText.isEmpty {
|
|
|
|
self.pasteText(finalText)
|
|
|
|
self.pasteText(finalText)
|
|
|
|
} else {
|
|
|
|
} else {
|
|
|
|
self.statusItem.button?.title = self.idleIcon
|
|
|
|
self.menuBarIcon = .idle
|
|
|
|
self.updateStatus("Ready")
|
|
|
|
self.updateStatus("Ready")
|
|
|
|
if self.playSounds { NSSound(named: "Basso")?.play() }
|
|
|
|
if self.playSounds { NSSound(named: "Basso")?.play() }
|
|
|
|
NSLog("No speech recognized")
|
|
|
|
NSLog("No speech recognized")
|
|
|
|
@@ -1813,8 +2188,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
func transcribeWithCLI(prompt: String? = nil) -> String? {
|
|
|
|
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.idleIcon
|
|
|
|
self.menuBarIcon = .idle
|
|
|
|
self.updateStatus("⚠️ whisper-cli not found")
|
|
|
|
self.updateStatus("whisper-cli not found", warning: true)
|
|
|
|
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")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@@ -1889,7 +2264,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, URLSessionDownloadDelegate,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
self.statusItem.button?.title = self.idleIcon
|
|
|
|
self.menuBarIcon = .idle
|
|
|
|
self.updateStatus("Ready")
|
|
|
|
self.updateStatus("Ready")
|
|
|
|
if self.playSounds { NSSound(named: "Glass")?.play() }
|
|
|
|
if self.playSounds { NSSound(named: "Glass")?.play() }
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|