Диктовка: нативный подчёркнутый черновик

This commit is contained in:
2026-07-29 15:09:17 +06:00
parent 5751743277
commit 81dc5db6e4
11 changed files with 607 additions and 21 deletions
+9
View File
@@ -15,6 +15,15 @@ let package = Package(
.linkedFramework("Security"), .linkedFramework("Security"),
] ]
), ),
.executableTarget(
name: "WhisperKeyInput",
path: "Sources/WhisperKeyInput",
linkerSettings: [
.linkedFramework("AppKit"),
.linkedFramework("Carbon"),
.linkedFramework("InputMethodKit"),
]
),
.testTarget(name: "WhisperKeyTests", dependencies: ["WhisperKey"]) .testTarget(name: "WhisperKeyTests", dependencies: ["WhisperKey"])
] ]
) )
+9 -2
View File
@@ -5,8 +5,9 @@
- `F5` — локальный whisper.cpp с полной несжатой моделью large-v3-turbo; - `F5` — локальный whisper.cpp с полной несжатой моделью large-v3-turbo;
- `Option+F5` — OpenAI Speech-to-Text API; - `Option+F5` — OpenAI Speech-to-Text API;
- модель постоянно загружена в фоновом локальном сервере, поэтому не перечитывается с диска после каждого F5; - модель постоянно загружена в фоновом локальном сервере, поэтому не перечитывается с диска после каждого F5;
- во время длинной диктовки готовые фразы вставляются после паузы около секунды, но не позднее 4 секунд непрерывной речи; - с системным источником ввода черновик появляется через 2–3 секунды и отображается как нативный подчёркнутый `marked text`;
- технические четырёхсекундные фрагменты склеиваются без искусственной точки и заглавной буквы; - после остановки вся реплика распознаётся целиком и заменяет подчёркнутый черновик финальным текстом;
- если системный источник не включён, работает прежняя безопасная вставка фрагментами;
- короткой нарезки по 1,5 секунды нет: ей не хватало контекста, и Whisper начинал домысливать; - короткой нарезки по 1,5 секунды нет: ей не хватало контекста, и Whisper начинал домысливать;
- после 5 секунд тишины запись завершается автоматически, повторное нажатие останавливает её вручную; - после 5 секунд тишины запись завершается автоматически, повторное нажатие останавливает её вручную;
- результат очищается и вставляется в активное текстовое поле; - результат очищается и вставляется в активное текстовое поле;
@@ -25,6 +26,12 @@ chmod +x scripts/*.sh
1. доступ к микрофону; 1. доступ к микрофону;
2. управление компьютером в «Конфиденциальность и безопасность → Универсальный доступ». 2. управление компьютером в «Конфиденциальность и безопасность → Универсальный доступ».
Установщик регистрирует системный источник `WhisperKey`. При F5 он выбирается
только на время диктовки, а после фиксации текста автоматически возвращается
предыдущая раскладка. Если macOS не разрешила автоматическое включение, откройте
`Системные настройки → Клавиатура → Ввод текста → Изменить…` и один раз
добавьте `WhisperKey` вручную.
Отключите штатное действие F5: Отключите штатное действие F5:
`Системные настройки → Клавиатура → Диктовка → Сочетание клавиш`. `Системные настройки → Клавиатура → Диктовка → Сочетание клавиш`.
+45
View File
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>ru</string>
<key>CFBundleDisplayName</key>
<string>WhisperKey</string>
<key>CFBundleExecutable</key>
<string>WhisperKeyInput</string>
<key>CFBundleIdentifier</key>
<string>com.romanv.inputmethod.WhisperKey</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>WhisperKey</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.2.0</string>
<key>CFBundleVersion</key>
<string>2</string>
<key>InputMethodConnectionName</key>
<string>com.romanv.inputmethod.WhisperKey_Connection</string>
<key>InputMethodServerControllerClass</key>
<string>WhisperKeyInputController</string>
<key>LSBackgroundOnly</key>
<true/>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
<true/>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>TISIntendedLanguage</key>
<string>ru</string>
<key>tsInputMethodCharacterRepertoireKey</key>
<array>
<string>Cyrl</string>
<string>Latn</string>
</array>
<key>tsInputMethodIconFileKey</key>
<string>WhisperKeyInput.tiff</string>
</dict>
</plist>
+18
View File
@@ -0,0 +1,18 @@
P1
16 16
0 0 0 0 0 0 1 1 1 1 0 0 0 0 0 0
0 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0
0 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0
0 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0
0 0 0 0 0 1 0 0 0 0 1 0 0 0 0 0
0 0 1 0 0 1 0 0 0 0 1 0 0 1 0 0
0 0 1 0 0 1 0 0 0 0 1 0 0 1 0 0
0 0 1 0 0 0 1 0 0 1 0 0 0 1 0 0
0 0 1 0 0 0 0 1 1 0 0 0 0 1 0 0
0 0 0 1 0 0 0 0 0 0 0 0 1 0 0 0
0 0 0 0 1 1 0 0 0 0 1 1 0 0 0 0
0 0 0 0 0 0 1 1 1 1 0 0 0 0 0 0
0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0
0 0 0 0 0 0 0 1 1 0 0 0 0 0 0 0
0 0 0 0 0 1 1 1 1 1 1 0 0 0 0 0
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
+98 -10
View File
@@ -7,19 +7,28 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private struct PendingChunk { private struct PendingChunk {
let url: URL let url: URL
let continuesPhrase: Bool let continuesPhrase: Bool
let isFinalSession: Bool
} }
private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength) private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
private let recorder = AudioRecorder() private let recorder = AudioRecorder()
private let hotKeys = HotKeyManager() private let hotKeys = HotKeyManager()
private let composition = NativeCompositionBridge()
private let settings = AppSettings.shared private let settings = AppSettings.shared
private var activeMode: TranscriptionMode? private var activeMode: TranscriptionMode?
private var startingSession = false
private var pendingChunks: [PendingChunk] = [] private var pendingChunks: [PendingChunk] = []
private var processingChunk = false private var processingChunk = false
private var stopRequested = false private var stopRequested = false
private var stitchNextChunk = false private var stitchNextChunk = false
private var nativeSession = false
private var nativeDraft = ""
func applicationDidFinishLaunching(_ notification: Notification) { func applicationDidFinishLaunching(_ notification: Notification) {
composition.onAvailabilityChange = { [weak self] in
self?.rebuildMenu()
}
composition.start()
configureStatusItem() configureStatusItem()
requestPermissions() requestPermissions()
hotKeys.onLocal = { [weak self] in Task { @MainActor in await self?.toggle(.local) } } hotKeys.onLocal = { [weak self] in Task { @MainActor in await self?.toggle(.local) } }
@@ -64,6 +73,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
menu.addItem(.separator()) menu.addItem(.separator())
menu.addItem(item("Настройки…", #selector(openSettings))) menu.addItem(item("Настройки…", #selector(openSettings)))
menu.addItem(item("Проверить локальный движок", #selector(checkLocalEngine))) menu.addItem(item("Проверить локальный движок", #selector(checkLocalEngine)))
let nativeState: String
if composition.isAvailable {
nativeState = "Нативный ввод — активен"
} else if composition.isInstalled {
nativeState = "Нативный ввод — готов"
} else {
nativeState = "Установить нативный ввод…"
}
menu.addItem(item(nativeState, #selector(openInputSources)))
menu.addItem(.separator()) menu.addItem(.separator())
menu.addItem(item("Выход", #selector(quit), key: "q")) menu.addItem(item("Выход", #selector(quit), key: "q"))
statusItem.menu = menu statusItem.menu = menu
@@ -91,6 +109,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
await stopAndTranscribe() await stopAndTranscribe()
return return
} }
guard !startingSession else { return }
startingSession = true
defer { startingSession = false }
var nativeSelected = composition.activateForDictation()
if !nativeSelected {
try? await Task.sleep(for: .milliseconds(250))
nativeSelected = composition.activateForDictation()
}
try? await Task.sleep(for: .milliseconds(180))
do { do {
activeMode = mode activeMode = mode
lastMode = mode lastMode = mode
@@ -98,7 +125,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
processingChunk = false processingChunk = false
stopRequested = false stopRequested = false
stitchNextChunk = false stitchNextChunk = false
nativeDraft = ""
nativeSession = nativeSelected && composition.isAvailable
if nativeSession {
composition.cancel(restoreInputSource: false)
} else {
composition.restoreInputSource()
}
try recorder.start( try recorder.start(
chunkTiming: nativeSession ? .nativeDraft : .committed,
onChunk: { [weak self] chunk in onChunk: { [weak self] chunk in
Task { @MainActor in self?.enqueue(chunk) } Task { @MainActor in self?.enqueue(chunk) }
}, },
@@ -125,20 +160,39 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
DictationSounds.playStop() DictationSounds.playStop()
setStatus("обрабатываю…") setStatus("обрабатываю…")
do { do {
if let final = try recorder.stop() { let result = try recorder.stop()
enqueue(RecordedChunk(url: final, continuesPhrase: false)) if nativeSession, let fullSession = result.fullSessionURL {
if let finalChunk = result.finalChunkURL {
try? FileManager.default.removeItem(at: finalChunk)
}
enqueue(
RecordedChunk(url: fullSession, continuesPhrase: false),
isFinalSession: true
)
} else {
if let fullSession = result.fullSessionURL {
try? FileManager.default.removeItem(at: fullSession)
}
if let finalChunk = result.finalChunkURL {
enqueue(RecordedChunk(url: finalChunk, continuesPhrase: false))
}
}
if pendingChunks.isEmpty, !processingChunk {
if nativeSession { composition.cancel() }
completeLiveSession()
} }
if pendingChunks.isEmpty, !processingChunk { completeLiveSession() }
} catch { } catch {
composition.cancel()
setStatus("ошибка") setStatus("ошибка")
showError("Диктовка не удалась", error.localizedDescription) showError("Диктовка не удалась", error.localizedDescription)
} }
} }
private func enqueue(_ chunk: RecordedChunk) { private func enqueue(_ chunk: RecordedChunk, isFinalSession: Bool = false) {
pendingChunks.append(PendingChunk( pendingChunks.append(PendingChunk(
url: chunk.url, url: chunk.url,
continuesPhrase: chunk.continuesPhrase continuesPhrase: chunk.continuesPhrase,
isFinalSession: isFinalSession
)) ))
processNextChunk() processNextChunk()
} }
@@ -157,7 +211,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
Task { Task {
do { do {
let wav = try AudioConverter.toWhisperWAV(recording) let wav = try AudioConverter.toWhisperWAV(recording)
saveDiagnosticRecording(wav) if chunk.isFinalSession { saveDiagnosticRecording(wav) }
defer { defer {
try? FileManager.default.removeItem(at: recording) try? FileManager.default.removeItem(at: recording)
try? FileManager.default.removeItem(at: wav) try? FileManager.default.removeItem(at: wav)
@@ -168,18 +222,43 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
case .cloud: raw = try await OpenAITranscriber.transcribe(wav: wav, settings: settings) case .cloud: raw = try await OpenAITranscriber.transcribe(wav: wav, settings: settings)
} }
let cleaned = TextCleaner.clean(raw, removeFillers: settings.removeFillers, glossary: settings.glossary) let cleaned = TextCleaner.clean(raw, removeFillers: settings.removeFillers, glossary: settings.glossary)
if !cleaned.isEmpty { if chunk.isFinalSession {
if cleaned.isEmpty {
composition.cancel()
} else if !composition.commit(cleaned + " ") {
try TextInserter.insert(cleaned + " ")
}
nativeDraft = ""
stitchNextChunk = false
} else if !cleaned.isEmpty {
let joined = TranscriptJoiner.prepare( let joined = TranscriptJoiner.prepare(
cleaned, cleaned,
continuesPrevious: stitchNextChunk, continuesPrevious: stitchNextChunk,
continuesNext: chunk.continuesPhrase, continuesNext: chunk.continuesPhrase,
glossary: settings.glossary glossary: settings.glossary
) )
if !joined.isEmpty { try TextInserter.insert(joined + " ") } if !joined.isEmpty {
if nativeSession {
nativeDraft += (nativeDraft.isEmpty ? "" : " ") + joined
_ = composition.updateDraft(nativeDraft)
} else {
try TextInserter.insert(joined + " ")
}
}
stitchNextChunk = chunk.continuesPhrase stitchNextChunk = chunk.continuesPhrase
} }
} catch { } catch {
showError("Фрагмент диктовки не распознан", error.localizedDescription) if chunk.isFinalSession, !nativeDraft.isEmpty {
if !composition.commit(nativeDraft + " ") {
try? TextInserter.insert(nativeDraft + " ")
}
}
showError(
chunk.isFinalSession
? "Финальная диктовка не распознана"
: "Фрагмент диктовки не распознан",
error.localizedDescription
)
} }
processingChunk = false processingChunk = false
if pendingChunks.isEmpty { if pendingChunks.isEmpty {
@@ -208,12 +287,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
stopRequested = false stopRequested = false
lastMode = nil lastMode = nil
stitchNextChunk = false stitchNextChunk = false
nativeSession = false
nativeDraft = ""
setStatus("готово") setStatus("готово")
} }
@objc private func startLocal() { Task { await toggle(.local) } } @objc private func startLocal() { Task { await toggle(.local) } }
@objc private func startCloud() { Task { await toggle(.cloud) } } @objc private func startCloud() { Task { await toggle(.cloud) } }
@objc private func openSettings() { SettingsWindowController.shared.show() } @objc private func openSettings() { SettingsWindowController.shared.show() }
@objc private func openInputSources() {
let url = URL(string: "x-apple.systempreferences:com.apple.Keyboard-Settings.extension")!
NSWorkspace.shared.open(url)
}
@objc private func checkLocalEngine() { @objc private func checkLocalEngine() {
do { do {
try LocalWhisper.validate(settings: settings) try LocalWhisper.validate(settings: settings)
@@ -222,7 +307,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
showError("Локальный движок не готов", error.localizedDescription) showError("Локальный движок не готов", error.localizedDescription)
} }
} }
@objc private func quit() { NSApp.terminate(nil) } @objc private func quit() {
composition.cancel()
NSApp.terminate(nil)
}
@objc private func handleWake() { @objc private func handleWake() {
do { do {
+67 -8
View File
@@ -6,12 +6,37 @@ struct RecordedChunk: Sendable {
let continuesPhrase: Bool let continuesPhrase: Bool
} }
struct RecordingResult: Sendable {
let finalChunkURL: URL?
let fullSessionURL: URL?
}
struct ChunkTiming: Sendable {
let minimumDuration: TimeInterval
let pauseDuration: TimeInterval
let maximumDuration: TimeInterval
static let committed = ChunkTiming(
minimumDuration: 2.5,
pauseDuration: 1.0,
maximumDuration: 4.0
)
static let nativeDraft = ChunkTiming(
minimumDuration: 1.5,
pauseDuration: 0.65,
maximumDuration: 2.25
)
}
final class AudioRecorder: @unchecked Sendable { final class AudioRecorder: @unchecked Sendable {
private var engine: AVAudioEngine? private var engine: AVAudioEngine?
private let lock = NSLock() private let lock = NSLock()
private var noSpeechTimeout: DispatchWorkItem? private var noSpeechTimeout: DispatchWorkItem?
private var currentURL: URL? private var currentURL: URL?
private var outputFile: AVAudioFile? private var outputFile: AVAudioFile?
private var sessionURL: URL?
private var sessionFile: AVAudioFile?
private var sessionFrames: AVAudioFramePosition = 0
private var currentFrames: AVAudioFramePosition = 0 private var currentFrames: AVAudioFramePosition = 0
private var currentChunkHasSpeech = false private var currentChunkHasSpeech = false
private var heardSpeech = false private var heardSpeech = false
@@ -21,8 +46,10 @@ final class AudioRecorder: @unchecked Sendable {
private var onChunk: (@Sendable (RecordedChunk) -> Void)? private var onChunk: (@Sendable (RecordedChunk) -> Void)?
private var onSilence: (@Sendable () -> Void)? private var onSilence: (@Sendable () -> Void)?
private var recordingFormat: AVAudioFormat? private var recordingFormat: AVAudioFormat?
private var chunkTiming = ChunkTiming.committed
func start( func start(
chunkTiming: ChunkTiming,
onChunk: @escaping @Sendable (RecordedChunk) -> Void, onChunk: @escaping @Sendable (RecordedChunk) -> Void,
onSilence: @escaping @Sendable () -> Void onSilence: @escaping @Sendable () -> Void
) throws { ) throws {
@@ -33,8 +60,17 @@ final class AudioRecorder: @unchecked Sendable {
let format = input.outputFormat(forBus: 0) let format = input.outputFormat(forBus: 0)
self.onChunk = onChunk self.onChunk = onChunk
self.onSilence = onSilence self.onSilence = onSilence
self.chunkTiming = chunkTiming
recordingFormat = format recordingFormat = format
try beginChunk(format: format) do {
try beginSession(format: format)
try beginChunk(format: format)
} catch {
sessionFile = nil
if let sessionURL { try? FileManager.default.removeItem(at: sessionURL) }
self.sessionURL = nil
throw error
}
input.installTap(onBus: 0, bufferSize: 4096, format: format) { [weak self] buffer, _ in input.installTap(onBus: 0, bufferSize: 4096, format: format) { [weak self] buffer, _ in
self?.consume(buffer, format: format) self?.consume(buffer, format: format)
} }
@@ -47,13 +83,16 @@ final class AudioRecorder: @unchecked Sendable {
engine.reset() engine.reset()
self.engine = nil self.engine = nil
if let currentURL { try? FileManager.default.removeItem(at: currentURL) } if let currentURL { try? FileManager.default.removeItem(at: currentURL) }
if let sessionURL { try? FileManager.default.removeItem(at: sessionURL) }
outputFile = nil outputFile = nil
currentURL = nil currentURL = nil
sessionFile = nil
sessionURL = nil
throw error throw error
} }
} }
func stop() throws -> URL? { func stop() throws -> RecordingResult {
noSpeechTimeout?.cancel() noSpeechTimeout?.cancel()
noSpeechTimeout = nil noSpeechTimeout = nil
if let engine { if let engine {
@@ -66,19 +105,29 @@ final class AudioRecorder: @unchecked Sendable {
} }
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
let finalURL = currentFrames > 0 && currentChunkHasSpeech ? currentURL : nil let finalChunkURL = currentFrames > 0 && currentChunkHasSpeech ? currentURL : nil
if finalURL == nil, let currentURL { let fullSessionURL = sessionFrames > 0 && heardSpeech ? sessionURL : nil
if finalChunkURL == nil, let currentURL {
try? FileManager.default.removeItem(at: currentURL) try? FileManager.default.removeItem(at: currentURL)
} }
if fullSessionURL == nil, let sessionURL {
try? FileManager.default.removeItem(at: sessionURL)
}
outputFile = nil outputFile = nil
currentURL = nil currentURL = nil
sessionFile = nil
sessionURL = nil
sessionFrames = 0
currentFrames = 0 currentFrames = 0
currentChunkHasSpeech = false currentChunkHasSpeech = false
heardSpeech = false heardSpeech = false
silentFrames = 0 silentFrames = 0
chunkSilentFrames = 0 chunkSilentFrames = 0
silenceTriggered = false silenceTriggered = false
return finalURL return RecordingResult(
finalChunkURL: finalChunkURL,
fullSessionURL: fullSessionURL
)
} }
private func scheduleNoSpeechTimeout() { private func scheduleNoSpeechTimeout() {
@@ -103,12 +152,22 @@ final class AudioRecorder: @unchecked Sendable {
chunkSilentFrames = 0 chunkSilentFrames = 0
} }
private func beginSession(format: AVAudioFormat) throws {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("whisperkey-session-\(UUID().uuidString).caf")
sessionFile = try AVAudioFile(forWriting: url, settings: format.settings)
sessionURL = url
sessionFrames = 0
}
private func consume(_ buffer: AVAudioPCMBuffer, format: AVAudioFormat) { private func consume(_ buffer: AVAudioPCMBuffer, format: AVAudioFormat) {
lock.lock() lock.lock()
defer { lock.unlock() } defer { lock.unlock() }
guard let activeOutputFile = outputFile else { return } guard let activeOutputFile = outputFile else { return }
try? activeOutputFile.write(from: buffer) try? activeOutputFile.write(from: buffer)
try? sessionFile?.write(from: buffer)
currentFrames += AVAudioFramePosition(buffer.frameLength) currentFrames += AVAudioFramePosition(buffer.frameLength)
sessionFrames += AVAudioFramePosition(buffer.frameLength)
let rms = bufferRMS(buffer) let rms = bufferRMS(buffer)
if rms > 0.006 { if rms > 0.006 {
@@ -133,9 +192,9 @@ final class AudioRecorder: @unchecked Sendable {
// Keep enough context for Whisper, but insert completed phrases while // Keep enough context for Whisper, but insert completed phrases while
// the user is still dictating. Prefer a short natural pause; cap a // the user is still dictating. Prefer a short natural pause; cap a
// continuous phrase so the input never stays blank indefinitely. // continuous phrase so the input never stays blank indefinitely.
let minimumChunk = AVAudioFramePosition(format.sampleRate * 2.5) let minimumChunk = AVAudioFramePosition(format.sampleRate * chunkTiming.minimumDuration)
let pauseBoundary = AVAudioFramePosition(format.sampleRate * 1.0) let pauseBoundary = AVAudioFramePosition(format.sampleRate * chunkTiming.pauseDuration)
let maximumChunk = AVAudioFramePosition(format.sampleRate * 4.0) let maximumChunk = AVAudioFramePosition(format.sampleRate * chunkTiming.maximumDuration)
let reachedPause = let reachedPause =
currentFrames >= minimumChunk && chunkSilentFrames >= pauseBoundary currentFrames >= minimumChunk && chunkSilentFrames >= pauseBoundary
let reachedMaximum = currentFrames >= maximumChunk let reachedMaximum = currentFrames >= maximumChunk
@@ -0,0 +1,171 @@
import AppKit
import Carbon
import Foundation
@MainActor
final class NativeCompositionBridge: NSObject {
private enum Message {
static let ping = Notification.Name("com.romanv.WhisperKeyInput.ping")
static let ready = Notification.Name("com.romanv.WhisperKeyInput.ready")
static let draft = Notification.Name("com.romanv.WhisperKeyInput.draft")
static let commit = Notification.Name("com.romanv.WhisperKeyInput.commit")
static let cancel = Notification.Name("com.romanv.WhisperKeyInput.cancel")
static let textKey = "text"
static let activeKey = "active"
}
private static let inputSourceID = "com.romanv.inputmethod.WhisperKey"
private static let installedBundle = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent("Library/Input Methods/WhisperKeyInput.app", isDirectory: true)
var onAvailabilityChange: (() -> Void)?
private var previousInputSourceID: String?
private var restoreWorkItem: DispatchWorkItem?
private(set) var inputMethodActive = false {
didSet {
if oldValue != inputMethodActive { onAvailabilityChange?() }
}
}
var isAvailable: Bool {
inputMethodActive && currentInputSourceID() == Self.inputSourceID
}
var isInstalled: Bool {
FileManager.default.fileExists(atPath: Self.installedBundle.path)
}
func start() {
DistributedNotificationCenter.default().addObserver(
self,
selector: #selector(handleReady),
name: Message.ready,
object: nil
)
registerInstalledInputMethod()
launchInputMethod()
ping()
}
func activateForDictation() -> Bool {
restoreWorkItem?.cancel()
restoreWorkItem = nil
registerInstalledInputMethod()
launchInputMethod()
guard let source = inputSource(withID: Self.inputSourceID) else { return false }
let currentID = currentInputSourceID()
if currentID != Self.inputSourceID {
previousInputSourceID = currentID
}
guard TISEnableInputSource(source) == noErr,
TISSelectInputSource(source) == noErr else {
restoreInputSource()
return false
}
ping()
return true
}
func ping() {
DistributedNotificationCenter.default().post(
name: Message.ping,
object: nil,
userInfo: nil
)
}
@discardableResult
func updateDraft(_ text: String) -> Bool {
guard isAvailable else { return false }
post(name: Message.draft, text: text)
return true
}
@discardableResult
func commit(_ text: String) -> Bool {
let available = isAvailable
if available { post(name: Message.commit, text: text) }
scheduleInputSourceRestore()
return available
}
func cancel(restoreInputSource: Bool = true) {
if inputMethodActive {
DistributedNotificationCenter.default().post(
name: Message.cancel,
object: nil,
userInfo: nil
)
}
if restoreInputSource { scheduleInputSourceRestore() }
}
func restoreInputSource() {
restoreWorkItem?.cancel()
restoreWorkItem = nil
guard let previousInputSourceID else { return }
self.previousInputSourceID = nil
guard let source = inputSource(withID: previousInputSourceID) else { return }
_ = TISSelectInputSource(source)
}
private func post(name: Notification.Name, text: String) {
DistributedNotificationCenter.default().post(
name: name,
object: nil,
userInfo: [Message.textKey: text]
)
}
@objc private func handleReady(_ notification: Notification) {
inputMethodActive = notification.userInfo?[Message.activeKey] as? Bool ?? false
}
private func registerInstalledInputMethod() {
guard isInstalled else { return }
_ = TISRegisterInputSource(Self.installedBundle as CFURL)
}
private func launchInputMethod() {
guard isInstalled else { return }
let configuration = NSWorkspace.OpenConfiguration()
configuration.activates = false
NSWorkspace.shared.openApplication(
at: Self.installedBundle,
configuration: configuration
)
}
private func scheduleInputSourceRestore() {
restoreWorkItem?.cancel()
let work = DispatchWorkItem { [weak self] in
self?.restoreInputSource()
}
restoreWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)
}
private func inputSource(withID identifier: String) -> TISInputSource? {
guard let sources = TISCreateInputSourceList(nil, true)?.takeRetainedValue()
as? [TISInputSource] else {
return nil
}
return sources.first { inputSourceID(for: $0) == identifier }
}
private func currentInputSourceID() -> String? {
guard let source = TISCopyCurrentKeyboardInputSource()?.takeRetainedValue() else { return nil }
return inputSourceID(for: source)
}
private func inputSourceID(for source: TISInputSource) -> String? {
guard let rawValue = TISGetInputSourceProperty(
source,
kTISPropertyInputSourceID
) else { return nil }
return Unmanaged<CFString>
.fromOpaque(rawValue)
.takeUnretainedValue() as String
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ final class SettingsWindowController: NSWindowController {
grid.column(at: 0).xPlacement = .trailing grid.column(at: 0).xPlacement = .trailing
modelPopup.addItems(withTitles: ["gpt-4o-transcribe", "gpt-4o-mini-transcribe", "whisper-1"]) modelPopup.addItems(withTitles: ["gpt-4o-transcribe", "gpt-4o-mini-transcribe", "whisper-1"])
let note = NSTextField(wrappingLabelWithString: "F5 — локально. Option+F5 — OpenAI. Вся реплика распознаётся целиком после 5 секунд тишины. Повторное нажатие останавливает её вручную.") let note = NSTextField(wrappingLabelWithString: "F5 — локально. Option+F5 — OpenAI. С нативным источником ввода во время речи показывается подчёркнутый черновик, а после 5 секунд тишины он заменяется финальным текстом.")
note.textColor = .secondaryLabelColor note.textColor = .secondaryLabelColor
note.translatesAutoresizingMaskIntoConstraints = false note.translatesAutoresizingMaskIntoConstraints = false
let save = NSButton(title: "Сохранить", target: self, action: #selector(save)) let save = NSButton(title: "Сохранить", target: self, action: #selector(save))
+172
View File
@@ -0,0 +1,172 @@
import AppKit
import Carbon
import InputMethodKit
import OSLog
private let inputLog = Logger(
subsystem: "com.romanv.inputmethod.WhisperKey",
category: "composition"
)
private enum CompositionMessage {
static let ping = Notification.Name("com.romanv.WhisperKeyInput.ping")
static let ready = Notification.Name("com.romanv.WhisperKeyInput.ready")
static let draft = Notification.Name("com.romanv.WhisperKeyInput.draft")
static let commit = Notification.Name("com.romanv.WhisperKeyInput.commit")
static let cancel = Notification.Name("com.romanv.WhisperKeyInput.cancel")
static let textKey = "text"
static let activeKey = "active"
}
private final class CompositionCoordinator: @unchecked Sendable {
static let shared = CompositionCoordinator()
weak var activeController: WhisperKeyInputController? {
didSet { announceReady() }
}
private init() {
let center = DistributedNotificationCenter.default()
center.addObserver(
self,
selector: #selector(handlePing),
name: CompositionMessage.ping,
object: nil
)
center.addObserver(
self,
selector: #selector(handleDraft),
name: CompositionMessage.draft,
object: nil
)
center.addObserver(
self,
selector: #selector(handleCommit),
name: CompositionMessage.commit,
object: nil
)
center.addObserver(
self,
selector: #selector(handleCancel),
name: CompositionMessage.cancel,
object: nil
)
}
@objc private func handlePing() {
NSLog("WhisperKeyInput: ping")
announceReady()
}
@objc private func handleDraft(_ notification: Notification) {
guard let text = notification.userInfo?[CompositionMessage.textKey] as? String else { return }
NSLog("WhisperKeyInput: draft %ld", (text as NSString).length)
activeController?.setDraft(text)
}
@objc private func handleCommit(_ notification: Notification) {
guard let text = notification.userInfo?[CompositionMessage.textKey] as? String else { return }
NSLog("WhisperKeyInput: commit %ld", (text as NSString).length)
activeController?.commit(text)
}
@objc private func handleCancel() {
NSLog("WhisperKeyInput: cancel")
activeController?.cancelDraft()
}
private func announceReady() {
DistributedNotificationCenter.default().post(
name: CompositionMessage.ready,
object: nil,
userInfo: [CompositionMessage.activeKey: activeController != nil]
)
}
}
@objc(WhisperKeyInputController)
final class WhisperKeyInputController: IMKInputController {
private var draft = ""
override init!(server: IMKServer!, delegate: Any!, client inputClient: Any!) {
super.init(server: server, delegate: delegate, client: inputClient)
_ = CompositionCoordinator.shared
NSLog("WhisperKeyInput: controller initialized")
}
override func activateServer(_ sender: Any!) {
super.activateServer(sender)
inputLog.notice(
"controller activated for \(self.client().bundleIdentifier(), privacy: .public)"
)
CompositionCoordinator.shared.activeController = self
client().overrideKeyboard(withKeyboardNamed: "com.apple.keylayout.Russian")
}
override func deactivateServer(_ sender: Any!) {
cancelDraft()
if CompositionCoordinator.shared.activeController === self {
CompositionCoordinator.shared.activeController = nil
}
super.deactivateServer(sender)
}
override func inputControllerWillClose() {
if CompositionCoordinator.shared.activeController === self {
CompositionCoordinator.shared.activeController = nil
}
super.inputControllerWillClose()
}
func setDraft(_ text: String) {
inputLog.notice(
"setting marked text for \(self.client().bundleIdentifier(), privacy: .public)"
)
draft = text
let length = (text as NSString).length
client().setMarkedText(
text,
selectionRange: NSRange(location: length, length: 0),
replacementRange: activeReplacementRange()
)
}
func commit(_ text: String) {
inputLog.notice(
"inserting final text for \(self.client().bundleIdentifier(), privacy: .public)"
)
client().insertText(text, replacementRange: activeReplacementRange())
draft = ""
}
func cancelDraft() {
guard !draft.isEmpty else { return }
client().insertText("", replacementRange: activeReplacementRange())
draft = ""
}
private func activeReplacementRange() -> NSRange {
let marked = client().markedRange()
return marked.location == NSNotFound
? NSRange(location: NSNotFound, length: 0)
: marked
}
}
let app = NSApplication.shared
app.setActivationPolicy(.prohibited)
let connectionName =
Bundle.main.object(forInfoDictionaryKey: "InputMethodConnectionName") as? String
?? "com.romanv.inputmethod.WhisperKey_Connection"
let bundleIdentifier = Bundle.main.bundleIdentifier ?? "com.romanv.inputmethod.WhisperKey"
guard let inputServer = IMKServer(
name: connectionName,
bundleIdentifier: bundleIdentifier
) else {
fatalError("Не удалось запустить WhisperKey Input Method.")
}
_ = CompositionCoordinator.shared
NSLog("WhisperKeyInput: server started")
app.run()
withExtendedLifetime(inputServer) {}
+10
View File
@@ -3,6 +3,7 @@ set -euo pipefail
PROJECT_DIR=${0:A:h:h} PROJECT_DIR=${0:A:h:h}
APP_DIR="$PROJECT_DIR/dist/WhisperKey.app" APP_DIR="$PROJECT_DIR/dist/WhisperKey.app"
INPUT_APP_DIR="$PROJECT_DIR/dist/WhisperKeyInput.app"
cd "$PROJECT_DIR" cd "$PROJECT_DIR"
swift build -c release swift build -c release
@@ -13,4 +14,13 @@ cp "$PROJECT_DIR/.build/release/WhisperKey" "$APP_DIR/Contents/MacOS/WhisperKey"
cp "$PROJECT_DIR/Resources/Info.plist" "$APP_DIR/Contents/Info.plist" cp "$PROJECT_DIR/Resources/Info.plist" "$APP_DIR/Contents/Info.plist"
codesign --force --deep --sign - "$APP_DIR" codesign --force --deep --sign - "$APP_DIR"
rm -rf "$INPUT_APP_DIR"
mkdir -p "$INPUT_APP_DIR/Contents/MacOS" "$INPUT_APP_DIR/Contents/Resources"
cp "$PROJECT_DIR/.build/release/WhisperKeyInput" "$INPUT_APP_DIR/Contents/MacOS/WhisperKeyInput"
cp "$PROJECT_DIR/Resources/InputMethod-Info.plist" "$INPUT_APP_DIR/Contents/Info.plist"
sips -s format tiff "$PROJECT_DIR/Resources/WhisperKeyInput.pbm" \
--out "$INPUT_APP_DIR/Contents/Resources/WhisperKeyInput.tiff" >/dev/null
codesign --force --deep --sign - "$INPUT_APP_DIR"
echo "$APP_DIR" echo "$APP_DIR"
echo "$INPUT_APP_DIR"
+7
View File
@@ -6,6 +6,12 @@ PROJECT_DIR=${0:A:h:h}
ditto "$PROJECT_DIR/dist/WhisperKey.app" "/Applications/WhisperKey.app" ditto "$PROJECT_DIR/dist/WhisperKey.app" "/Applications/WhisperKey.app"
INPUT_METHOD_DIR="$HOME/Library/Input Methods"
mkdir -p "$INPUT_METHOD_DIR"
pkill -x WhisperKeyInput 2>/dev/null || true
ditto "$PROJECT_DIR/dist/WhisperKeyInput.app" "$INPUT_METHOD_DIR/WhisperKeyInput.app"
open -gja "$INPUT_METHOD_DIR/WhisperKeyInput.app"
LOGIN_AGENT_DIR="$HOME/Library/LaunchAgents" LOGIN_AGENT_DIR="$HOME/Library/LaunchAgents"
LOGIN_AGENT="$LOGIN_AGENT_DIR/com.romanv.WhisperKey.plist" LOGIN_AGENT="$LOGIN_AGENT_DIR/com.romanv.WhisperKey.plist"
mkdir -p "$LOGIN_AGENT_DIR" mkdir -p "$LOGIN_AGENT_DIR"
@@ -16,3 +22,4 @@ launchctl bootstrap "gui/$(id -u)" "$LOGIN_AGENT"
open "/Applications/WhisperKey.app" open "/Applications/WhisperKey.app"
echo "Installed: /Applications/WhisperKey.app" echo "Installed: /Applications/WhisperKey.app"
echo "Installed: $INPUT_METHOD_DIR/WhisperKeyInput.app"