Compare commits
No commits in common. "main" and "v1.0.0" have entirely different histories.
4
.github/PULL_REQUEST_TEMPLATE.md
vendored
@ -13,7 +13,7 @@ Did you use AI-assisted coding tools? If yes, briefly mention where.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I ran the Linux app or its test suite (`cd linux && PYTHONPATH=. pytest tests`) or explained why not.
|
||||
- [ ] I ran `./build.sh --debug` or explained why not.
|
||||
- [ ] I did not commit API keys, tokens, private recordings, or confidential transcripts.
|
||||
- [ ] I considered whether this changes privacy, security, or data flow.
|
||||
- [ ] I kept the change focused on the Linux preview scope.
|
||||
- [ ] I kept the change focused on the macOS preview scope.
|
||||
|
||||
4
.github/dependabot.yml
vendored
@ -5,7 +5,3 @@ updates:
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
- package-ecosystem: pip
|
||||
directory: /linux
|
||||
schedule:
|
||||
interval: weekly
|
||||
|
||||
38
.github/workflows/ci.yml
vendored
@ -10,14 +10,14 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test-linux:
|
||||
name: Test Linux app
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 15
|
||||
build-macos:
|
||||
name: Build macOS app
|
||||
runs-on: macos-14
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Secret hygiene scan
|
||||
run: |
|
||||
@ -39,26 +39,14 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: "pip"
|
||||
- name: Select Xcode
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.2.app || sudo xcode-select -s /Applications/Xcode.app
|
||||
|
||||
- name: Install dependencies
|
||||
- name: Install XcodeGen
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y python3-gi gir1.2-gtk-3.0 gir1.2-appindicator3-0.1 libgirepository1.0-dev libcairo2-dev python3-dev
|
||||
cd linux
|
||||
pip install -r requirements.txt
|
||||
pip install pytest ruff PyGObject
|
||||
if ! command -v xcodegen >/dev/null 2>&1; then
|
||||
brew install xcodegen
|
||||
fi
|
||||
|
||||
- name: Syntax check
|
||||
run: |
|
||||
cd linux
|
||||
python -m py_compile blitztext/*.py
|
||||
|
||||
- name: Run Pytest
|
||||
run: |
|
||||
cd linux
|
||||
PYTHONPATH=. pytest tests
|
||||
- name: Build
|
||||
run: ./build.sh --debug
|
||||
|
||||
8
.gitignore
vendored
@ -30,11 +30,3 @@ Secrets.swift
|
||||
|
||||
# Tooling
|
||||
node_modules/
|
||||
|
||||
# Agent workspace
|
||||
jules/
|
||||
|
||||
# Raw screenshot source folders (published screenshots stay in Screenshots/)
|
||||
Screenshots/Settings - new/
|
||||
Screenshots/Settings-old/
|
||||
Screenshots/Welcome Setup/
|
||||
|
||||
618
BlitztextMac/App/AppState.swift
Normal file
@ -0,0 +1,618 @@
|
||||
import SwiftUI
|
||||
import Observation
|
||||
import AppKit
|
||||
|
||||
enum PopoverPage: Equatable {
|
||||
case main
|
||||
case onboarding
|
||||
case settings
|
||||
case workflow
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class AppState {
|
||||
private static let pasteRetryInitialAttempts = 22
|
||||
private static let concealedPasteboardType = NSPasteboard.PasteboardType("org.nspasteboard.ConcealedType")
|
||||
|
||||
var activeWorkflow: (any Workflow)?
|
||||
var page: PopoverPage = .main
|
||||
var isPopoverShown = false
|
||||
var menuBarStatus: MenuBarStatus = .idle {
|
||||
didSet {
|
||||
guard oldValue != menuBarStatus else { return }
|
||||
onMenuBarStatusChange?(menuBarStatus)
|
||||
}
|
||||
}
|
||||
var accessibilityPermissionGranted = false
|
||||
var localModelDownloadProgress: Double?
|
||||
var localModelDownloadStatusText: String?
|
||||
var localModelDownloadErrorText: String?
|
||||
var onMenuBarStatusChange: ((MenuBarStatus) -> Void)?
|
||||
private var activeLaunchSource: WorkflowLaunchSource = .manual
|
||||
private var activePasteTarget: PasteTarget?
|
||||
private var lastPopoverPasteTarget: PasteTarget?
|
||||
private var menuBarStatusResetTask: Task<Void, Never>?
|
||||
private var workflowCleanupTask: Task<Void, Never>?
|
||||
|
||||
// Persisted settings
|
||||
var appSettings: AppSettings {
|
||||
didSet {
|
||||
saveSettings()
|
||||
prewarmLocalTranscriptionIfNeeded()
|
||||
}
|
||||
}
|
||||
var transcriptionSettings: TranscriptionSettings {
|
||||
didSet { saveSettings() }
|
||||
}
|
||||
var textImprovementSettings: TextImprovementSettings {
|
||||
didSet { saveSettings() }
|
||||
}
|
||||
var dampfAblassenSettings: DampfAblassenSettings {
|
||||
didSet { saveSettings() }
|
||||
}
|
||||
var emojiTextSettings: EmojiTextSettings {
|
||||
didSet { saveSettings() }
|
||||
}
|
||||
|
||||
// Hotkeys
|
||||
let hotkeyService = HotkeyService()
|
||||
|
||||
// Computed
|
||||
var isConfigured: Bool {
|
||||
KeychainService.isConfigured || !LocalTranscriptionService.installedModels().isEmpty
|
||||
}
|
||||
var shouldShowOnboarding: Bool {
|
||||
!isConfigured && !appSettings.hasSeenOnboarding
|
||||
}
|
||||
|
||||
var currentPhase: WorkflowPhase {
|
||||
activeWorkflow?.phase ?? .idle
|
||||
}
|
||||
|
||||
init() {
|
||||
self.appSettings = Self.loadAppSettings()
|
||||
self.transcriptionSettings = Self.loadTranscriptionSettings()
|
||||
self.textImprovementSettings = Self.loadTextImprovementSettings()
|
||||
self.dampfAblassenSettings = Self.loadDampfAblassenSettings()
|
||||
self.emojiTextSettings = Self.loadEmojiTextSettings()
|
||||
refreshAccessibilityPermission()
|
||||
autoSelectFastLocalModelIfNeeded()
|
||||
prewarmLocalTranscriptionIfNeeded()
|
||||
}
|
||||
|
||||
// MARK: - Custom Display Names
|
||||
|
||||
func displayName(for type: WorkflowType) -> String {
|
||||
switch type {
|
||||
case .textImprover:
|
||||
let name = textImprovementSettings.customName.trimmingCharacters(in: .whitespaces)
|
||||
return name.isEmpty ? type.displayName : name
|
||||
case .dampfAblassen:
|
||||
let name = dampfAblassenSettings.customName.trimmingCharacters(in: .whitespaces)
|
||||
return name.isEmpty ? type.displayName : name
|
||||
case .emojiText:
|
||||
let name = emojiTextSettings.customName.trimmingCharacters(in: .whitespaces)
|
||||
return name.isEmpty ? type.displayName : name
|
||||
default:
|
||||
return type.displayName
|
||||
}
|
||||
}
|
||||
|
||||
func workflowSubtitle(for type: WorkflowType) -> String {
|
||||
switch type {
|
||||
case .transcription:
|
||||
if appSettings.secureLocalModeEnabled {
|
||||
let modelName = selectedLocalModelName
|
||||
return LocalTranscriptionService.isModelInstalled(modelName)
|
||||
? "Lokal: \(LocalTranscriptionModel.displayName(for: modelName))."
|
||||
: "Lokales WhisperKit-Modell fehlt."
|
||||
}
|
||||
return "Online: Whisper über OpenAI."
|
||||
case .localTranscription:
|
||||
return "Nur lokal. Kein Server."
|
||||
case .textImprover, .dampfAblassen, .emojiText:
|
||||
if appSettings.secureLocalModeEnabled {
|
||||
return "Im lokalen Modus pausiert."
|
||||
}
|
||||
return type.subtitle
|
||||
}
|
||||
}
|
||||
|
||||
var resolvedLocalModelName: String {
|
||||
LocalTranscriptionService.resolvedModelName(appSettings.selectedLocalTranscriptionModelName)
|
||||
}
|
||||
|
||||
var selectedLocalModelDisplayName: String {
|
||||
LocalTranscriptionModel.displayName(for: selectedLocalModelName)
|
||||
}
|
||||
|
||||
var selectedLocalModelName: String {
|
||||
LocalTranscriptionService.normalizedModelName(appSettings.selectedLocalTranscriptionModelName)
|
||||
}
|
||||
|
||||
var selectedLocalModelIsInstalled: Bool {
|
||||
LocalTranscriptionService.isModelInstalled(selectedLocalModelName)
|
||||
}
|
||||
|
||||
var isDownloadingLocalModel: Bool {
|
||||
localModelDownloadProgress != nil
|
||||
}
|
||||
|
||||
var localModelDownloadButtonTitle: String {
|
||||
selectedLocalModelIsInstalled
|
||||
? "\(LocalTranscriptionModel.displayName(for: selectedLocalModelName)) ist installiert"
|
||||
: "\(LocalTranscriptionModel.displayName(for: selectedLocalModelName)) installieren"
|
||||
}
|
||||
|
||||
// MARK: - Workflow Management
|
||||
|
||||
func startWorkflow(_ type: WorkflowType, source: WorkflowLaunchSource = .manual) {
|
||||
guard isWorkflowAvailable(type) else {
|
||||
if source == .manual {
|
||||
page = .settings
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
activeWorkflow?.stop()
|
||||
menuBarStatusResetTask?.cancel()
|
||||
workflowCleanupTask?.cancel()
|
||||
activeLaunchSource = source
|
||||
activePasteTarget = capturePasteTarget(for: source)
|
||||
|
||||
switch type {
|
||||
case .transcription:
|
||||
let workflow = TranscriptionWorkflow(
|
||||
customTerms: textImprovementSettings.customTerms,
|
||||
language: transcriptionSettings.language,
|
||||
backend: appSettings.secureLocalModeEnabled ? .local : .remote,
|
||||
localModelName: selectedLocalModelName
|
||||
)
|
||||
configureWorkflowHandlers(workflow)
|
||||
activeWorkflow = workflow
|
||||
workflow.start()
|
||||
|
||||
case .localTranscription:
|
||||
let workflow = TranscriptionWorkflow(
|
||||
type: .localTranscription,
|
||||
customTerms: textImprovementSettings.customTerms,
|
||||
language: transcriptionSettings.language,
|
||||
backend: .local,
|
||||
localModelName: selectedLocalModelName
|
||||
)
|
||||
configureWorkflowHandlers(workflow)
|
||||
activeWorkflow = workflow
|
||||
workflow.start()
|
||||
|
||||
case .textImprover:
|
||||
let workflow = TextImprovementWorkflow(
|
||||
settings: textImprovementSettings,
|
||||
language: transcriptionSettings.language
|
||||
)
|
||||
configureWorkflowHandlers(workflow)
|
||||
activeWorkflow = workflow
|
||||
workflow.start()
|
||||
|
||||
case .dampfAblassen:
|
||||
let workflow = DampfAblassenWorkflow(
|
||||
settings: dampfAblassenSettings,
|
||||
customTerms: textImprovementSettings.customTerms,
|
||||
language: transcriptionSettings.language
|
||||
)
|
||||
configureWorkflowHandlers(workflow)
|
||||
activeWorkflow = workflow
|
||||
workflow.start()
|
||||
|
||||
case .emojiText:
|
||||
let workflow = EmojiTextWorkflow(
|
||||
settings: emojiTextSettings,
|
||||
customTerms: textImprovementSettings.customTerms,
|
||||
language: transcriptionSettings.language
|
||||
)
|
||||
configureWorkflowHandlers(workflow)
|
||||
activeWorkflow = workflow
|
||||
workflow.start()
|
||||
}
|
||||
|
||||
page = source.presentsWorkflowPage ? .workflow : .main
|
||||
}
|
||||
|
||||
func isWorkflowAvailable(_ type: WorkflowType) -> Bool {
|
||||
switch type {
|
||||
case .localTranscription:
|
||||
return selectedLocalModelIsInstalled
|
||||
case .transcription:
|
||||
return appSettings.secureLocalModeEnabled
|
||||
? selectedLocalModelIsInstalled
|
||||
: KeychainService.isConfigured
|
||||
case .textImprover, .dampfAblassen, .emojiText:
|
||||
return !appSettings.secureLocalModeEnabled && KeychainService.isConfigured
|
||||
}
|
||||
}
|
||||
|
||||
func stopCurrentWorkflow() {
|
||||
activeWorkflow?.stop()
|
||||
}
|
||||
|
||||
func resetCurrentWorkflow() {
|
||||
activeWorkflow?.reset()
|
||||
activeWorkflow = nil
|
||||
activePasteTarget = nil
|
||||
activeLaunchSource = .manual
|
||||
menuBarStatusResetTask?.cancel()
|
||||
workflowCleanupTask?.cancel()
|
||||
menuBarStatus = .idle
|
||||
page = .main
|
||||
}
|
||||
|
||||
func enableSecureLocalMode() {
|
||||
appSettings.secureLocalModeEnabled = true
|
||||
if !selectedLocalModelIsInstalled {
|
||||
installSelectedLocalModel()
|
||||
}
|
||||
}
|
||||
|
||||
func installSelectedLocalModel() {
|
||||
guard !isDownloadingLocalModel else { return }
|
||||
|
||||
let modelName = selectedLocalModelName
|
||||
localModelDownloadProgress = 0
|
||||
localModelDownloadStatusText = "Download startet..."
|
||||
localModelDownloadErrorText = nil
|
||||
|
||||
Task {
|
||||
do {
|
||||
let installedURL = try await LocalTranscriptionService.shared.downloadAndInstall(
|
||||
modelName: modelName
|
||||
) { [weak self] progress in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
let clampedProgress = min(max(progress, 0), 1)
|
||||
self.localModelDownloadProgress = clampedProgress
|
||||
self.localModelDownloadStatusText = "Download \(Int(clampedProgress * 100)) %"
|
||||
}
|
||||
}
|
||||
|
||||
appSettings.selectedLocalTranscriptionModelName = installedURL.lastPathComponent
|
||||
appSettings.secureLocalModeEnabled = true
|
||||
localModelDownloadProgress = nil
|
||||
localModelDownloadStatusText = "\(LocalTranscriptionModel.displayName(for: modelName)) ist installiert."
|
||||
localModelDownloadErrorText = nil
|
||||
|
||||
try? await LocalTranscriptionService.shared.prepare(modelName: modelName)
|
||||
} catch {
|
||||
localModelDownloadProgress = nil
|
||||
localModelDownloadStatusText = nil
|
||||
localModelDownloadErrorText = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func copyToClipboard(_ text: String) {
|
||||
writeSensitiveTextToPasteboard(text)
|
||||
}
|
||||
|
||||
// MARK: - Auto-Paste
|
||||
|
||||
/// Copies the text, restores focus when needed, then simulates Cmd+V.
|
||||
/// The text intentionally remains on the clipboard as a fallback if paste is blocked.
|
||||
private func pasteAtCursor(_ text: String, target: PasteTarget? = nil) {
|
||||
writeSensitiveTextToPasteboard(text)
|
||||
|
||||
if isPopoverShown {
|
||||
NotificationCenter.default.post(name: .dismissPopover, object: nil)
|
||||
}
|
||||
|
||||
let trusted = AccessibilityPermissionService.isTrusted(promptIfNeeded: true)
|
||||
accessibilityPermissionGranted = trusted
|
||||
guard trusted else {
|
||||
menuBarStatus = .error(activeWorkflow?.type)
|
||||
return
|
||||
}
|
||||
|
||||
attemptPasteTrusted(
|
||||
target: target,
|
||||
attemptsRemaining: Self.pasteRetryInitialAttempts
|
||||
)
|
||||
}
|
||||
|
||||
private func writeSensitiveTextToPasteboard(_ text: String) {
|
||||
let pasteboard = NSPasteboard.general
|
||||
|
||||
pasteboard.clearContents()
|
||||
pasteboard.declareTypes([.string, Self.concealedPasteboardType], owner: nil)
|
||||
pasteboard.setString(text, forType: .string)
|
||||
pasteboard.setString("", forType: Self.concealedPasteboardType)
|
||||
}
|
||||
|
||||
func prepareForPopoverPresentation() {
|
||||
lastPopoverPasteTarget = captureCurrentFrontmostApp()
|
||||
if let activeWorkflow, activeWorkflow.phase.isActive {
|
||||
page = .workflow
|
||||
} else if shouldShowOnboarding {
|
||||
page = .onboarding
|
||||
markOnboardingSeen()
|
||||
} else if page == .workflow {
|
||||
page = .main
|
||||
} else if page == .onboarding {
|
||||
page = .main
|
||||
}
|
||||
}
|
||||
|
||||
func markOnboardingSeen() {
|
||||
guard !appSettings.hasSeenOnboarding else { return }
|
||||
appSettings.hasSeenOnboarding = true
|
||||
}
|
||||
|
||||
// MARK: - API Key Status
|
||||
|
||||
func apiKeyDisplayValue(for key: KeychainKey) -> String {
|
||||
guard let value = KeychainService.load(key: key), !value.isEmpty else {
|
||||
return ""
|
||||
}
|
||||
if value.count > 8 {
|
||||
return String(value.prefix(4)) + " \u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}"
|
||||
}
|
||||
return "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}"
|
||||
}
|
||||
|
||||
func hasValue(for key: KeychainKey) -> Bool {
|
||||
guard let value = KeychainService.load(key: key) else { return false }
|
||||
return !value.isEmpty
|
||||
}
|
||||
|
||||
// MARK: - Settings Persistence
|
||||
|
||||
private static let settingsURL: URL = {
|
||||
try? AppSupportPaths.ensureAppSupportDirectoryExists()
|
||||
return AppSupportPaths.settingsURL
|
||||
}()
|
||||
|
||||
private func saveSettings() {
|
||||
let container = SettingsContainer(
|
||||
app: appSettings,
|
||||
transcription: transcriptionSettings,
|
||||
textImprovement: textImprovementSettings,
|
||||
dampfAblassen: dampfAblassenSettings,
|
||||
emojiText: emojiTextSettings
|
||||
)
|
||||
if let data = try? JSONEncoder().encode(container) {
|
||||
try? data.write(to: Self.settingsURL)
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadAppSettings() -> AppSettings {
|
||||
loadContainer()?.app ?? AppSettings()
|
||||
}
|
||||
|
||||
private static func loadTranscriptionSettings() -> TranscriptionSettings {
|
||||
loadContainer()?.transcription ?? TranscriptionSettings()
|
||||
}
|
||||
|
||||
private static func loadTextImprovementSettings() -> TextImprovementSettings {
|
||||
loadContainer()?.textImprovement ?? TextImprovementSettings()
|
||||
}
|
||||
|
||||
private static func loadDampfAblassenSettings() -> DampfAblassenSettings {
|
||||
loadContainer()?.dampfAblassen ?? DampfAblassenSettings()
|
||||
}
|
||||
|
||||
private static func loadEmojiTextSettings() -> EmojiTextSettings {
|
||||
loadContainer()?.emojiText ?? EmojiTextSettings()
|
||||
}
|
||||
|
||||
private static func loadContainer() -> SettingsContainer? {
|
||||
guard let data = try? Data(contentsOf: settingsURL) else { return nil }
|
||||
return try? JSONDecoder().decode(SettingsContainer.self, from: data)
|
||||
}
|
||||
|
||||
func refreshAccessibilityPermission() {
|
||||
accessibilityPermissionGranted = AccessibilityPermissionService.currentStatus()
|
||||
}
|
||||
|
||||
func requestAccessibilityPermission() {
|
||||
accessibilityPermissionGranted = AccessibilityPermissionService.requestPermissionPrompt()
|
||||
AccessibilityPermissionService.openSystemSettings()
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
||||
self?.refreshAccessibilityPermission()
|
||||
}
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3.0) { [weak self] in
|
||||
self?.refreshAccessibilityPermission()
|
||||
}
|
||||
}
|
||||
|
||||
private func autoSelectFastLocalModelIfNeeded() {
|
||||
guard !appSettings.hasAutoSelectedFastLocalModel,
|
||||
LocalTranscriptionService.shouldAutoSelectRecommendedFastModel(
|
||||
currentModelName: appSettings.selectedLocalTranscriptionModelName
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
appSettings.selectedLocalTranscriptionModelName = LocalTranscriptionService.recommendedFastModelName
|
||||
appSettings.hasAutoSelectedFastLocalModel = true
|
||||
}
|
||||
|
||||
private func prewarmLocalTranscriptionIfNeeded() {
|
||||
guard appSettings.secureLocalModeEnabled,
|
||||
LocalTranscriptionService.isModelInstalled(resolvedLocalModelName) else {
|
||||
return
|
||||
}
|
||||
|
||||
let modelName = resolvedLocalModelName
|
||||
Task.detached(priority: .utility) {
|
||||
try? await LocalTranscriptionService.shared.prepare(modelName: modelName)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleWorkflowOutput(_ text: String) {
|
||||
pasteAtCursor(text, target: activePasteTarget)
|
||||
if activeLaunchSource == .hotkeyBackground {
|
||||
page = .main
|
||||
}
|
||||
scheduleWorkflowCleanup(after: 1.05)
|
||||
}
|
||||
|
||||
private func configureWorkflowHandlers<T: Workflow>(_ workflow: T) {
|
||||
workflow.onOutput = { [weak self] text in
|
||||
self?.handleWorkflowOutput(text)
|
||||
}
|
||||
workflow.onPhaseChange = { [weak self, weak workflow] phase in
|
||||
guard let self, let workflow else { return }
|
||||
self.handleWorkflowPhaseChange(phase, workflow: workflow)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleWorkflowPhaseChange(_ phase: WorkflowPhase, workflow: any Workflow) {
|
||||
menuBarStatusResetTask?.cancel()
|
||||
|
||||
switch phase {
|
||||
case .idle:
|
||||
if activeWorkflow == nil {
|
||||
menuBarStatus = .idle
|
||||
}
|
||||
|
||||
case .running:
|
||||
menuBarStatus = workflow.isRecording
|
||||
? .recording(workflow.type)
|
||||
: .processing(workflow.type)
|
||||
|
||||
case .done:
|
||||
menuBarStatus = .success(workflow.type)
|
||||
|
||||
case .error:
|
||||
menuBarStatus = .error(workflow.type)
|
||||
if activeLaunchSource == .hotkeyBackground {
|
||||
activeWorkflow = nil
|
||||
activePasteTarget = nil
|
||||
page = .main
|
||||
}
|
||||
scheduleMenuBarStatusReset(after: 1.6)
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleWorkflowCleanup(after delay: TimeInterval) {
|
||||
guard let workflow = activeWorkflow else { return }
|
||||
|
||||
workflowCleanupTask?.cancel()
|
||||
let workflowID = ObjectIdentifier(workflow)
|
||||
|
||||
workflowCleanupTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .seconds(delay))
|
||||
guard let self, let activeWorkflow = self.activeWorkflow else { return }
|
||||
guard ObjectIdentifier(activeWorkflow) == workflowID else { return }
|
||||
|
||||
activeWorkflow.reset()
|
||||
self.activeWorkflow = nil
|
||||
self.activePasteTarget = nil
|
||||
self.activeLaunchSource = .manual
|
||||
if !self.isPopoverShown {
|
||||
self.page = .main
|
||||
}
|
||||
self.menuBarStatus = .idle
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleMenuBarStatusReset(after delay: TimeInterval) {
|
||||
menuBarStatusResetTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(for: .seconds(delay))
|
||||
guard let self else { return }
|
||||
if self.activeWorkflow == nil || !(self.activeWorkflow?.phase.isActive ?? false) {
|
||||
self.menuBarStatus = .idle
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func capturePasteTarget(for source: WorkflowLaunchSource) -> PasteTarget? {
|
||||
switch source {
|
||||
case .manual:
|
||||
return lastPopoverPasteTarget
|
||||
case .hotkeyBackground:
|
||||
return captureCurrentFrontmostApp()
|
||||
}
|
||||
}
|
||||
|
||||
private func attemptPasteTrusted(
|
||||
target: PasteTarget?,
|
||||
attemptsRemaining: Int
|
||||
) {
|
||||
let frontmostPid = NSWorkspace.shared.frontmostApplication?.processIdentifier
|
||||
|
||||
if let target {
|
||||
if frontmostPid == target.processIdentifier {
|
||||
performPaste()
|
||||
return
|
||||
}
|
||||
|
||||
target.application.activate(options: [])
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
guard attemptsRemaining > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
let delay: TimeInterval
|
||||
switch attemptsRemaining {
|
||||
case 16...:
|
||||
delay = 0.015
|
||||
case 8...15:
|
||||
delay = 0.025
|
||||
default:
|
||||
delay = 0.04
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.attemptPasteTrusted(
|
||||
target: target,
|
||||
attemptsRemaining: attemptsRemaining - 1
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func performPaste() {
|
||||
let source = CGEventSource(stateID: .hidSystemState)
|
||||
let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: true)
|
||||
keyDown?.flags = .maskCommand
|
||||
let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0x09, keyDown: false)
|
||||
keyUp?.flags = .maskCommand
|
||||
keyDown?.post(tap: .cghidEventTap)
|
||||
keyUp?.post(tap: .cghidEventTap)
|
||||
}
|
||||
|
||||
private func captureCurrentFrontmostApp() -> PasteTarget? {
|
||||
guard let app = NSWorkspace.shared.frontmostApplication else { return nil }
|
||||
|
||||
let ownPid = NSRunningApplication.current.processIdentifier
|
||||
guard app.processIdentifier != ownPid else { return nil }
|
||||
|
||||
return PasteTarget(
|
||||
bundleIdentifier: app.bundleIdentifier,
|
||||
processIdentifier: app.processIdentifier,
|
||||
application: app
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct SettingsContainer: Codable {
|
||||
var app: AppSettings?
|
||||
var transcription: TranscriptionSettings
|
||||
var textImprovement: TextImprovementSettings
|
||||
var dampfAblassen: DampfAblassenSettings?
|
||||
var emojiText: EmojiTextSettings?
|
||||
}
|
||||
|
||||
// MARK: - Notification for Popover Dismissal
|
||||
|
||||
extension Notification.Name {
|
||||
static let dismissPopover = Notification.Name("dismissPopover")
|
||||
}
|
||||
|
||||
private struct PasteTarget {
|
||||
let bundleIdentifier: String?
|
||||
let processIdentifier: pid_t
|
||||
let application: NSRunningApplication
|
||||
}
|
||||
153
BlitztextMac/App/BlitztextMacApp.swift
Normal file
@ -0,0 +1,153 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct BlitztextMacApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
Settings {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||
private var statusItem: NSStatusItem!
|
||||
private var popover: NSPopover!
|
||||
private let menuBarStatusController = MenuBarStatusController()
|
||||
let appState = AppState()
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
|
||||
|
||||
if let button = statusItem.button {
|
||||
menuBarStatusController.attach(to: button)
|
||||
button.action = #selector(togglePopover)
|
||||
button.target = self
|
||||
}
|
||||
|
||||
popover = NSPopover()
|
||||
popover.contentSize = NSSize(width: 340, height: 480)
|
||||
popover.behavior = .transient
|
||||
popover.delegate = self
|
||||
popover.contentViewController = NSHostingController(rootView: MenuBarView(appState: appState))
|
||||
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
|
||||
// Hotkey events
|
||||
appState.hotkeyService.onHotkeyEvent = { [weak self] event in
|
||||
self?.handleHotkeyEvent(event)
|
||||
}
|
||||
appState.onMenuBarStatusChange = { [weak self] status in
|
||||
self?.menuBarStatusController.update(to: status)
|
||||
}
|
||||
appState.hotkeyService.start()
|
||||
|
||||
// Listen for popover dismiss requests (from auto-paste)
|
||||
NotificationCenter.default.addObserver(
|
||||
self,
|
||||
selector: #selector(handleDismissPopover),
|
||||
name: .dismissPopover,
|
||||
object: nil
|
||||
)
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.showOnboardingIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@objc private func handleDismissPopover() {
|
||||
appState.isPopoverShown = false
|
||||
popover.performClose(nil)
|
||||
}
|
||||
|
||||
private func handleHotkeyEvent(_ event: HotkeyEvent) {
|
||||
switch event {
|
||||
case .down(let type):
|
||||
handleHotkeyDown(type)
|
||||
case .up(let type):
|
||||
handleHotkeyUp(type)
|
||||
case .cancel:
|
||||
handleHotkeyCancel()
|
||||
}
|
||||
}
|
||||
|
||||
private func handleHotkeyDown(_ type: WorkflowType) {
|
||||
guard appState.isConfigured else { return }
|
||||
|
||||
let mode = appState.appSettings.hotkeyMode
|
||||
|
||||
switch mode {
|
||||
case .hold:
|
||||
// Hold mode: start recording on key down
|
||||
appState.startWorkflow(type, source: .hotkeyBackground)
|
||||
|
||||
case .toggle:
|
||||
// Toggle mode: if already recording same workflow, stop it
|
||||
if let active = appState.activeWorkflow,
|
||||
active.type == type,
|
||||
active.phase.isActive {
|
||||
active.stop()
|
||||
} else {
|
||||
appState.prepareForPopoverPresentation()
|
||||
appState.startWorkflow(type, source: .manual)
|
||||
showPopover()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleHotkeyUp(_ type: WorkflowType) {
|
||||
let mode = appState.appSettings.hotkeyMode
|
||||
|
||||
guard mode == .hold else { return }
|
||||
|
||||
// Hold mode: stop recording on key release
|
||||
if let active = appState.activeWorkflow,
|
||||
active.type == type {
|
||||
// Only stop if currently recording (running phase)
|
||||
if case .running = active.phase {
|
||||
active.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleHotkeyCancel() {
|
||||
appState.activeWorkflow?.stop()
|
||||
}
|
||||
|
||||
@objc private func togglePopover() {
|
||||
if popover.isShown {
|
||||
popover.performClose(nil)
|
||||
appState.isPopoverShown = false
|
||||
} else {
|
||||
appState.prepareForPopoverPresentation()
|
||||
showPopover()
|
||||
}
|
||||
}
|
||||
|
||||
private func showOnboardingIfNeeded() {
|
||||
guard appState.shouldShowOnboarding else { return }
|
||||
appState.prepareForPopoverPresentation()
|
||||
showPopover()
|
||||
}
|
||||
|
||||
private func showPopover() {
|
||||
guard let button = statusItem.button else { return }
|
||||
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||
appState.isPopoverShown = true
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
}
|
||||
|
||||
nonisolated func popoverDidClose(_ notification: Notification) {
|
||||
Task { @MainActor in
|
||||
appState.isPopoverShown = false
|
||||
switch appState.currentPhase {
|
||||
case .done, .error:
|
||||
appState.resetCurrentWorkflow()
|
||||
default:
|
||||
appState.page = .main
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
380
BlitztextMac/App/MenuBarStatusController.swift
Normal file
@ -0,0 +1,380 @@
|
||||
import AppKit
|
||||
|
||||
enum MenuBarStatus: Equatable {
|
||||
case idle
|
||||
case recording(WorkflowType)
|
||||
case processing(WorkflowType)
|
||||
case success(WorkflowType?)
|
||||
case error(WorkflowType?)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class MenuBarStatusController {
|
||||
private weak var button: NSStatusBarButton?
|
||||
private var animationTimer: Timer?
|
||||
private var animationFrame = 0
|
||||
private var currentStatus: MenuBarStatus = .idle
|
||||
|
||||
func attach(to button: NSStatusBarButton) {
|
||||
self.button = button
|
||||
button.imagePosition = .imageOnly
|
||||
button.imageScaling = .scaleProportionallyDown
|
||||
renderCurrentStatus()
|
||||
}
|
||||
|
||||
func update(to status: MenuBarStatus) {
|
||||
currentStatus = status
|
||||
animationFrame = 0
|
||||
configureAnimationIfNeeded()
|
||||
renderCurrentStatus()
|
||||
}
|
||||
|
||||
private func configureAnimationIfNeeded() {
|
||||
stopAnimation()
|
||||
|
||||
switch currentStatus {
|
||||
case .recording:
|
||||
startAnimation(interval: 0.12)
|
||||
case .processing:
|
||||
startAnimation(interval: 0.18)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func startAnimation(interval: TimeInterval) {
|
||||
animationTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.tick()
|
||||
}
|
||||
}
|
||||
RunLoop.main.add(animationTimer!, forMode: .common)
|
||||
}
|
||||
|
||||
private func stopAnimation() {
|
||||
animationTimer?.invalidate()
|
||||
animationTimer = nil
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
animationFrame = (animationFrame + 1) % 4
|
||||
renderCurrentStatus()
|
||||
}
|
||||
|
||||
private func renderCurrentStatus() {
|
||||
guard let button else { return }
|
||||
button.image = MenuBarStatusIconRenderer.makeImage(for: currentStatus, frame: animationFrame)
|
||||
button.image?.isTemplate = true
|
||||
button.toolTip = tooltip(for: currentStatus)
|
||||
}
|
||||
|
||||
private func tooltip(for status: MenuBarStatus) -> String {
|
||||
switch status {
|
||||
case .idle:
|
||||
return "Blitztext ist bereit"
|
||||
case .recording(let type):
|
||||
return "\(type.displayName): Aufnahme läuft"
|
||||
case .processing(let type):
|
||||
return "\(type.displayName): Verarbeitung läuft"
|
||||
case .success(let type):
|
||||
if let type {
|
||||
return "\(type.displayName): Fertig"
|
||||
}
|
||||
return "Blitztext: Fertig"
|
||||
case .error(let type):
|
||||
if let type {
|
||||
return "\(type.displayName): Fehler"
|
||||
}
|
||||
return "Blitztext: Fehler"
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
animationTimer?.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private enum MenuBarStatusIconRenderer {
|
||||
static func makeImage(for status: MenuBarStatus, frame: Int) -> NSImage {
|
||||
if case .idle = status, let baseImage = baseTemplateImage() {
|
||||
return baseImage
|
||||
}
|
||||
|
||||
let size = NSSize(width: 18, height: 18)
|
||||
let image = NSImage(size: size, flipped: false) { bounds in
|
||||
drawBaseIcon(in: bounds, status: status, frame: frame)
|
||||
|
||||
switch status {
|
||||
case .recording(let type):
|
||||
drawActivityBadge(
|
||||
type: type,
|
||||
systemName: badgeSymbol(for: type),
|
||||
in: bounds,
|
||||
frame: frame,
|
||||
phase: .recording
|
||||
)
|
||||
case .processing(let type):
|
||||
drawActivityBadge(
|
||||
type: type,
|
||||
systemName: badgeSymbol(for: type),
|
||||
in: bounds,
|
||||
frame: frame,
|
||||
phase: .processing
|
||||
)
|
||||
case .success:
|
||||
drawBadge(systemName: "checkmark", in: bounds, fillOpacity: 1.0)
|
||||
case .error:
|
||||
drawBadge(systemName: "exclamationmark", in: bounds, fillOpacity: 1.0)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
image.isTemplate = true
|
||||
image.size = size
|
||||
return image
|
||||
}
|
||||
|
||||
private enum ActivityPhase {
|
||||
case recording
|
||||
case processing
|
||||
}
|
||||
|
||||
private static func drawBaseIcon(in bounds: CGRect, status: MenuBarStatus, frame: Int) {
|
||||
let stripeWidths: [CGFloat] = [12, 10, 8, 6]
|
||||
let stripeHeight: CGFloat = 2
|
||||
let stripeSpacing: CGFloat = 1.6
|
||||
let totalHeight = (CGFloat(stripeWidths.count) * stripeHeight) + (CGFloat(stripeWidths.count - 1) * stripeSpacing)
|
||||
let originY = bounds.midY - (totalHeight / 2)
|
||||
let baseAlpha = baseAlphaValues(for: status, frame: frame)
|
||||
|
||||
for (index, width) in stripeWidths.enumerated() {
|
||||
let x = bounds.midX - (width / 2)
|
||||
let y = originY + CGFloat(index) * (stripeHeight + stripeSpacing)
|
||||
let rect = CGRect(x: x, y: y, width: width, height: stripeHeight)
|
||||
let path = NSBezierPath(roundedRect: rect, xRadius: 1, yRadius: 1)
|
||||
NSColor.black.withAlphaComponent(baseAlpha[index]).setFill()
|
||||
path.fill()
|
||||
}
|
||||
}
|
||||
|
||||
private static func drawActivityBadge(
|
||||
type: WorkflowType,
|
||||
systemName: String,
|
||||
in bounds: CGRect,
|
||||
frame: Int,
|
||||
phase: ActivityPhase
|
||||
) {
|
||||
let badgeSize: CGFloat = 7.5
|
||||
let badgeRect = CGRect(
|
||||
x: bounds.maxX - badgeSize - 0.8,
|
||||
y: bounds.minY + 0.8,
|
||||
width: badgeSize,
|
||||
height: badgeSize
|
||||
)
|
||||
|
||||
let badgeOpacity: CGFloat
|
||||
let haloOpacity: CGFloat
|
||||
|
||||
switch phase {
|
||||
case .recording:
|
||||
let values: [CGFloat]
|
||||
switch type {
|
||||
case .transcription, .localTranscription:
|
||||
values = [0.74, 1.0, 0.82, 0.92]
|
||||
case .textImprover:
|
||||
values = [0.66, 0.84, 1.0, 0.8]
|
||||
case .dampfAblassen:
|
||||
values = [1.0, 0.76, 0.94, 0.68]
|
||||
case .emojiText:
|
||||
values = [0.8, 0.92, 0.7, 1.0]
|
||||
}
|
||||
badgeOpacity = values[frame % values.count]
|
||||
haloOpacity = 0.14 + (CGFloat(frame % 4) * 0.04)
|
||||
case .processing:
|
||||
let values: [CGFloat]
|
||||
switch type {
|
||||
case .transcription, .localTranscription:
|
||||
values = [0.58, 0.72, 0.9, 0.72]
|
||||
case .textImprover:
|
||||
values = [0.48, 0.68, 0.92, 0.84]
|
||||
case .dampfAblassen:
|
||||
values = [0.84, 0.62, 0.9, 0.56]
|
||||
case .emojiText:
|
||||
values = [0.54, 0.76, 0.88, 0.68]
|
||||
}
|
||||
badgeOpacity = values[frame % values.count]
|
||||
haloOpacity = 0.12 + (CGFloat((frame + 2) % 4) * 0.03)
|
||||
}
|
||||
|
||||
let haloInset = phase == .recording ? -0.8 : -0.45
|
||||
let haloRect = badgeRect.insetBy(dx: haloInset, dy: haloInset)
|
||||
let haloPath = NSBezierPath(ovalIn: haloRect)
|
||||
NSColor.black.withAlphaComponent(haloOpacity).setStroke()
|
||||
haloPath.lineWidth = phase == .recording ? 0.9 : 0.75
|
||||
haloPath.stroke()
|
||||
|
||||
drawBadge(systemName: systemName, in: bounds, fillOpacity: badgeOpacity)
|
||||
|
||||
if phase == .processing {
|
||||
drawProcessingDot(around: badgeRect, frame: frame)
|
||||
}
|
||||
}
|
||||
|
||||
private static func drawBadge(systemName: String, in bounds: CGRect, fillOpacity: CGFloat) {
|
||||
let badgeSize: CGFloat = 7.5
|
||||
let badgeRect = CGRect(
|
||||
x: bounds.maxX - badgeSize - 0.8,
|
||||
y: bounds.minY + 0.8,
|
||||
width: badgeSize,
|
||||
height: badgeSize
|
||||
)
|
||||
|
||||
let badgePath = NSBezierPath(ovalIn: badgeRect)
|
||||
NSColor.black.withAlphaComponent(fillOpacity).setFill()
|
||||
badgePath.fill()
|
||||
|
||||
guard let symbol = NSImage(
|
||||
systemSymbolName: systemName,
|
||||
accessibilityDescription: nil
|
||||
) else {
|
||||
return
|
||||
}
|
||||
|
||||
let config = NSImage.SymbolConfiguration(pointSize: 5.5, weight: .bold)
|
||||
let configuredSymbol = symbol.withSymbolConfiguration(config) ?? symbol
|
||||
let symbolRect = badgeRect.insetBy(dx: 1.2, dy: 1.2)
|
||||
configuredSymbol.draw(
|
||||
in: symbolRect,
|
||||
from: .zero,
|
||||
operation: .destinationOut,
|
||||
fraction: 1.0
|
||||
)
|
||||
}
|
||||
|
||||
private static func drawProcessingDot(around badgeRect: CGRect, frame: Int) {
|
||||
let orbitPoints: [CGPoint] = [
|
||||
CGPoint(x: badgeRect.midX, y: badgeRect.maxY + 0.35),
|
||||
CGPoint(x: badgeRect.maxX + 0.35, y: badgeRect.midY),
|
||||
CGPoint(x: badgeRect.midX, y: badgeRect.minY - 0.35),
|
||||
CGPoint(x: badgeRect.minX - 0.35, y: badgeRect.midY),
|
||||
]
|
||||
let point = orbitPoints[frame % orbitPoints.count]
|
||||
let dotRect = CGRect(x: point.x - 0.85, y: point.y - 0.85, width: 1.7, height: 1.7)
|
||||
let dotPath = NSBezierPath(ovalIn: dotRect)
|
||||
NSColor.black.withAlphaComponent(0.92).setFill()
|
||||
dotPath.fill()
|
||||
}
|
||||
|
||||
private static func baseAlphaValues(for status: MenuBarStatus, frame: Int) -> [CGFloat] {
|
||||
switch status {
|
||||
case .idle:
|
||||
return [1.0, 0.82, 0.64, 0.46]
|
||||
case .recording(let type):
|
||||
return recordingAlphaValues(for: type, frame: frame)
|
||||
case .processing(let type):
|
||||
return processingAlphaValues(for: type, frame: frame)
|
||||
case .success:
|
||||
return [1.0, 0.9, 0.78, 0.62]
|
||||
case .error:
|
||||
return [1.0, 0.7, 0.52, 0.36]
|
||||
}
|
||||
}
|
||||
|
||||
private static func recordingAlphaValues(for type: WorkflowType, frame: Int) -> [CGFloat] {
|
||||
switch type {
|
||||
case .transcription, .localTranscription:
|
||||
let patterns: [[CGFloat]] = [
|
||||
[1.0, 0.42, 0.28, 0.18],
|
||||
[0.82, 1.0, 0.4, 0.24],
|
||||
[0.58, 0.86, 1.0, 0.36],
|
||||
[0.4, 0.62, 0.88, 1.0],
|
||||
]
|
||||
return patterns[frame % patterns.count]
|
||||
case .textImprover:
|
||||
let patterns: [[CGFloat]] = [
|
||||
[1.0, 0.88, 0.52, 0.3],
|
||||
[0.86, 1.0, 0.84, 0.44],
|
||||
[0.64, 0.9, 1.0, 0.68],
|
||||
[0.48, 0.68, 0.9, 1.0],
|
||||
]
|
||||
return patterns[frame % patterns.count]
|
||||
case .dampfAblassen:
|
||||
let patterns: [[CGFloat]] = [
|
||||
[1.0, 0.44, 0.78, 1.0],
|
||||
[0.86, 0.34, 0.96, 0.9],
|
||||
[0.72, 0.3, 1.0, 0.78],
|
||||
[0.94, 0.4, 0.74, 1.0],
|
||||
]
|
||||
return patterns[frame % patterns.count]
|
||||
case .emojiText:
|
||||
let patterns: [[CGFloat]] = [
|
||||
[1.0, 0.7, 0.46, 0.28],
|
||||
[0.78, 1.0, 0.72, 0.42],
|
||||
[0.52, 0.82, 1.0, 0.66],
|
||||
[0.36, 0.58, 0.84, 1.0],
|
||||
]
|
||||
return patterns[frame % patterns.count]
|
||||
}
|
||||
}
|
||||
|
||||
private static func processingAlphaValues(for type: WorkflowType, frame: Int) -> [CGFloat] {
|
||||
switch type {
|
||||
case .transcription, .localTranscription:
|
||||
let patterns: [[CGFloat]] = [
|
||||
[1.0, 0.84, 0.68, 0.52],
|
||||
[0.92, 0.8, 0.64, 0.5],
|
||||
[0.84, 0.74, 0.6, 0.48],
|
||||
[0.92, 0.8, 0.64, 0.5],
|
||||
]
|
||||
return patterns[frame % patterns.count]
|
||||
case .textImprover:
|
||||
let patterns: [[CGFloat]] = [
|
||||
[1.0, 0.76, 0.52, 0.34],
|
||||
[0.86, 1.0, 0.74, 0.48],
|
||||
[0.7, 0.88, 1.0, 0.72],
|
||||
[0.56, 0.74, 0.9, 1.0],
|
||||
]
|
||||
return patterns[frame % patterns.count]
|
||||
case .dampfAblassen:
|
||||
let patterns: [[CGFloat]] = [
|
||||
[0.9, 0.5, 0.72, 1.0],
|
||||
[0.78, 0.44, 0.9, 1.0],
|
||||
[0.66, 0.38, 1.0, 0.88],
|
||||
[0.84, 0.48, 0.78, 1.0],
|
||||
]
|
||||
return patterns[frame % patterns.count]
|
||||
case .emojiText:
|
||||
let patterns: [[CGFloat]] = [
|
||||
[1.0, 0.8, 0.58, 0.4],
|
||||
[0.88, 1.0, 0.78, 0.54],
|
||||
[0.74, 0.9, 1.0, 0.7],
|
||||
[0.6, 0.76, 0.92, 1.0],
|
||||
]
|
||||
return patterns[frame % patterns.count]
|
||||
}
|
||||
}
|
||||
|
||||
private static func badgeSymbol(for type: WorkflowType) -> String {
|
||||
switch type {
|
||||
case .transcription:
|
||||
return "mic.fill"
|
||||
case .localTranscription:
|
||||
return "lock.shield.fill"
|
||||
case .textImprover:
|
||||
return "text.alignleft"
|
||||
case .dampfAblassen:
|
||||
return "flame.fill"
|
||||
case .emojiText:
|
||||
return "face.smiling"
|
||||
}
|
||||
}
|
||||
|
||||
private static func baseTemplateImage() -> NSImage? {
|
||||
guard let image = NSImage(named: "menubar_icon") else { return nil }
|
||||
image.isTemplate = true
|
||||
image.size = NSSize(width: 18, height: 18)
|
||||
return image
|
||||
}
|
||||
}
|
||||
1024
BlitztextMac/Features/MenuBar/MenuBarView.swift
Normal file
137
BlitztextMac/Features/MenuBar/WorkflowRowView.swift
Normal file
@ -0,0 +1,137 @@
|
||||
import SwiftUI
|
||||
|
||||
struct WorkflowRowView: View {
|
||||
let type: WorkflowType
|
||||
let enabled: Bool
|
||||
var customName: String? = nil
|
||||
var subtitle: String? = nil
|
||||
let action: () -> Void
|
||||
|
||||
@State private var isHovered = false
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
HStack(spacing: 12) {
|
||||
// Icon with monochrome background
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(Color.primary.opacity(isHovered ? 0.1 : 0.06))
|
||||
.frame(width: 36, height: 36)
|
||||
|
||||
Image(systemName: type.icon)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// Name + subtitle
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(customName ?? type.displayName)
|
||||
.font(.system(size: 13, weight: .medium))
|
||||
.foregroundStyle(enabled ? .primary : .tertiary)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(subtitle ?? type.subtitle)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(enabled ? .secondary : .quaternary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
// Hotkey badge
|
||||
HotkeyBadge(label: type.hotkeyLabel, enabled: enabled)
|
||||
.opacity(enabled ? 1 : 0.4)
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.fill(isHovered && enabled ? Color.primary.opacity(0.05) : Color.clear)
|
||||
)
|
||||
.padding(.horizontal, 6)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.disabled(!enabled)
|
||||
.opacity(enabled ? 1 : 0.5)
|
||||
.onHover { hovering in
|
||||
withAnimation(.easeOut(duration: 0.12)) {
|
||||
isHovered = hovering
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Hotkey Badge
|
||||
|
||||
struct HotkeyBadge: View {
|
||||
let label: String
|
||||
let enabled: Bool
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 3) {
|
||||
ForEach(label.components(separatedBy: " + "), id: \.self) { key in
|
||||
Text(key)
|
||||
.font(.system(size: 10.5, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(keyTextColor)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.fill(keyBackgroundColor)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.strokeBorder(keyStrokeColor, lineWidth: 0.8)
|
||||
)
|
||||
.shadow(color: keyShadowColor, radius: 1.2, y: 0.6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var keyTextColor: Color {
|
||||
guard enabled else {
|
||||
return colorScheme == .dark
|
||||
? Color.white.opacity(0.34)
|
||||
: Color.black.opacity(0.26)
|
||||
}
|
||||
|
||||
return colorScheme == .dark
|
||||
? Color.white.opacity(0.84)
|
||||
: Color.black.opacity(0.72)
|
||||
}
|
||||
|
||||
private var keyBackgroundColor: Color {
|
||||
guard enabled else {
|
||||
return colorScheme == .dark
|
||||
? Color.white.opacity(0.05)
|
||||
: Color.black.opacity(0.035)
|
||||
}
|
||||
|
||||
return colorScheme == .dark
|
||||
? Color.white.opacity(0.12)
|
||||
: Color.black.opacity(0.09)
|
||||
}
|
||||
|
||||
private var keyStrokeColor: Color {
|
||||
guard enabled else {
|
||||
return colorScheme == .dark
|
||||
? Color.white.opacity(0.08)
|
||||
: Color.black.opacity(0.06)
|
||||
}
|
||||
|
||||
return colorScheme == .dark
|
||||
? Color.white.opacity(0.20)
|
||||
: Color.black.opacity(0.16)
|
||||
}
|
||||
|
||||
private var keyShadowColor: Color {
|
||||
guard enabled else { return .clear }
|
||||
|
||||
return colorScheme == .dark
|
||||
? Color.black.opacity(0.10)
|
||||
: Color.black.opacity(0.06)
|
||||
}
|
||||
}
|
||||
837
BlitztextMac/Features/Settings/SettingsContentView.swift
Normal file
@ -0,0 +1,837 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
struct SettingsContentView: View {
|
||||
@Bindable var appState: AppState
|
||||
@State private var selectedTab = 0
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Two-tab segmented picker
|
||||
Picker("", selection: $selectedTab) {
|
||||
Text("Anpassen").tag(0)
|
||||
Text("Zugang").tag(1)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 10)
|
||||
|
||||
ScrollView {
|
||||
if selectedTab == 0 {
|
||||
CustomizeSettingsView(appState: appState)
|
||||
} else {
|
||||
AccessSettingsView(appState: appState)
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
appState.refreshAccessibilityPermission()
|
||||
selectedTab = defaultTabSelection
|
||||
}
|
||||
}
|
||||
|
||||
private var defaultTabSelection: Int {
|
||||
if !appState.accessibilityPermissionGranted {
|
||||
return 1
|
||||
}
|
||||
if appState.isConfigured && !BlitztextInstallLocationService.shouldOfferMoveToApplications {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Section Label (quiet style)
|
||||
|
||||
private struct SectionLabel: View {
|
||||
let text: String
|
||||
|
||||
var body: some View {
|
||||
Text(text.uppercased())
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Access Settings (Tab 1: Zugang)
|
||||
|
||||
struct AccessSettingsView: View {
|
||||
private static let openAIAPIKeyPattern = #"^sk-[A-Za-z0-9_-]{20,}$"#
|
||||
|
||||
@Bindable var appState: AppState
|
||||
|
||||
private enum FieldFocus {
|
||||
case openAIAPIKey
|
||||
}
|
||||
|
||||
@State private var launchAtLoginService = LaunchAtLoginService()
|
||||
@State private var currentInstallLocation = BlitztextInstallLocationService.currentInstallLocation
|
||||
@State private var openAIAPIKey = ""
|
||||
@State private var editingAPIKey = false
|
||||
@State private var saved = false
|
||||
@State private var saveErrorText: String?
|
||||
@State private var installActionErrorText: String?
|
||||
@State private var showCleanupOptions = false
|
||||
@State private var deleteLocalDataOnCleanup = true
|
||||
@State private var cleanupStatusText: String?
|
||||
@State private var cleanupErrorText: String?
|
||||
@FocusState private var focusedField: FieldFocus?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SectionLabel(text: "Berechtigungen")
|
||||
|
||||
HStack(alignment: .top, spacing: 8) {
|
||||
Image(systemName: appState.accessibilityPermissionGranted ? "checkmark.circle.fill" : "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(appState.accessibilityPermissionGranted ? .green : .orange)
|
||||
.frame(width: 18, height: 18)
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(appState.accessibilityPermissionGranted ? "Direktes Einfügen ist freigegeben." : "Direktes Einfügen ist noch nicht freigegeben.")
|
||||
.font(.system(size: 11.5, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text("Öffne Bedienungshilfen und aktiviere Blitztext. Falls Blitztext schon aktiv ist, einmal aus- und wieder einschalten.")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("Bedienungshilfen öffnen") {
|
||||
appState.requestAccessibilityPermission()
|
||||
}
|
||||
.buttonStyle(SubtleButtonStyle())
|
||||
|
||||
Button("Erneut prüfen") {
|
||||
appState.refreshAccessibilityPermission()
|
||||
}
|
||||
.buttonStyle(SubtleButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
SectionLabel(text: "OpenAI API Key")
|
||||
Spacer()
|
||||
if appState.hasValue(for: .openAIAPIKey) && !editingAPIKey {
|
||||
Button("Aendern") { editingAPIKey = true }
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
|
||||
if appState.hasValue(for: .openAIAPIKey) && !editingAPIKey {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.system(size: 9))
|
||||
.foregroundStyle(.green.opacity(0.8))
|
||||
Text(appState.apiKeyDisplayValue(for: .openAIAPIKey))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(8)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 6)
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
)
|
||||
} else {
|
||||
HStack(spacing: 8) {
|
||||
SecureField("sk-...", text: $openAIAPIKey)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(size: 11.5))
|
||||
.focused($focusedField, equals: .openAIAPIKey)
|
||||
|
||||
Button("Einfuegen") {
|
||||
pasteAPIKeyFromClipboard()
|
||||
}
|
||||
.buttonStyle(SubtleButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
Text("Dein Key bleibt lokal in dieser App. Audio und Text werden direkt an die OpenAI API gesendet.")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SectionLabel(text: "Installation")
|
||||
|
||||
Text(installationHeadline)
|
||||
.font(.system(size: 11.5, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(installationDetail)
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Text(BlitztextInstallLocationService.bundleURL.path)
|
||||
.font(.system(size: 10.5, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
|
||||
if !BlitztextInstallLocationService.otherInstalledBundleURLs.isEmpty {
|
||||
Text("Weitere Blitztext-Kopien auf diesem Mac können doppelte Login-Items auslösen.")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.orange)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
if BlitztextInstallLocationService.shouldOfferMoveToApplications {
|
||||
Button("Nach /Applications bewegen") {
|
||||
moveToApplications()
|
||||
}
|
||||
.buttonStyle(SubtleButtonStyle())
|
||||
}
|
||||
|
||||
Button("Im Finder zeigen") {
|
||||
revealInFinder(urls: [BlitztextInstallLocationService.bundleURL])
|
||||
}
|
||||
.buttonStyle(SubtleButtonStyle())
|
||||
|
||||
if !BlitztextInstallLocationService.otherInstalledBundleURLs.isEmpty {
|
||||
Button("Weitere Kopien zeigen") {
|
||||
revealInFinder(urls: BlitztextInstallLocationService.otherInstalledBundleURLs)
|
||||
}
|
||||
.buttonStyle(SubtleButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
if let installActionErrorText {
|
||||
Text(installActionErrorText)
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.red)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SectionLabel(text: "Updates")
|
||||
|
||||
Text("Diese Preview hat keinen oeffentlichen Update-Feed. Baue neue Versionen selbst aus dem Repo.")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if !currentInstallLocation.isCanonicalInstall {
|
||||
Text("Hotkeys und Login-Start laufen am stabilsten, wenn Blitztext aus /Applications gestartet wird.")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.orange)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
} else {
|
||||
Text("Updates sind in dieser Preview manuell: pull, build, starten.")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
// Launch at Login
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SectionLabel(text: "Beim Anmelden")
|
||||
|
||||
Toggle("Blitztext automatisch starten", isOn: Binding(
|
||||
get: { launchAtLoginService.isEnabled },
|
||||
set: { launchAtLoginService.setEnabled($0) }
|
||||
))
|
||||
.toggleStyle(.switch)
|
||||
|
||||
Text(launchAtLoginService.errorText ?? launchAtLoginService.helperText)
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(
|
||||
launchAtLoginService.errorText == nil
|
||||
? AnyShapeStyle(.secondary)
|
||||
: AnyShapeStyle(.red)
|
||||
)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if let saveErrorText {
|
||||
Text(saveErrorText)
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.red)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
SectionLabel(text: "Hinweis")
|
||||
|
||||
Text("Fuer direktes Einfuegen: Blitztext einmal nach /Applications legen und danach Mikrofon sowie Bedienungshilfen erlauben.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(10)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color.primary.opacity(0.03))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(Color.primary.opacity(0.05), lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
SectionLabel(text: "Sauber Entfernen")
|
||||
|
||||
Text("Vor dem Löschen Blitztext erst auf diesem Mac bereinigen. So verschwinden Anmeldestart und lokale Daten sauber aus dem Weg.")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
if showCleanupOptions {
|
||||
Toggle("Zugangsdaten und Einstellungen dieses Macs löschen", isOn: $deleteLocalDataOnCleanup)
|
||||
.toggleStyle(.switch)
|
||||
|
||||
Text("Danach Blitztext beenden und die App aus /Applications löschen. Bereits verwaiste alte Login-Items können in den Systemeinstellungen einmalig manuell entfernt werden.")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button("Abbrechen") {
|
||||
showCleanupOptions = false
|
||||
}
|
||||
.buttonStyle(SubtleButtonStyle())
|
||||
|
||||
Button("Jetzt bereinigen") {
|
||||
runCleanup()
|
||||
}
|
||||
.buttonStyle(SubtleButtonStyle())
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
} else {
|
||||
Button("Entfernung vorbereiten") {
|
||||
showCleanupOptions = true
|
||||
}
|
||||
.buttonStyle(SubtleButtonStyle())
|
||||
}
|
||||
|
||||
if let cleanupStatusText {
|
||||
Text(cleanupStatusText)
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.green)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
if let cleanupErrorText {
|
||||
Text(cleanupErrorText)
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.red)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
// Save button (right-aligned, text only)
|
||||
HStack {
|
||||
Spacer()
|
||||
Button {
|
||||
save()
|
||||
} label: {
|
||||
if saved {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.system(size: 10, weight: .bold))
|
||||
Text("Gespeichert")
|
||||
}
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(.green)
|
||||
} else {
|
||||
Text("Speichern")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
}
|
||||
.buttonStyle(SubtleButtonStyle())
|
||||
.animation(.easeInOut(duration: 0.2), value: saved)
|
||||
}
|
||||
}
|
||||
.padding(16)
|
||||
.onAppear {
|
||||
launchAtLoginService.refresh()
|
||||
refreshInstallState()
|
||||
load()
|
||||
if !appState.hasValue(for: .openAIAPIKey) {
|
||||
editingAPIKey = true
|
||||
focusedField = .openAIAPIKey
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func load() {
|
||||
openAIAPIKey = ""
|
||||
}
|
||||
|
||||
private func save() {
|
||||
saveErrorText = nil
|
||||
cleanupStatusText = nil
|
||||
cleanupErrorText = nil
|
||||
KeychainService.invalidateCache()
|
||||
let trimmedAPIKey = openAIAPIKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if editingAPIKey || !appState.hasValue(for: .openAIAPIKey) {
|
||||
guard !trimmedAPIKey.isEmpty else {
|
||||
saveErrorText = "Bitte trage deinen OpenAI API Key ein."
|
||||
return
|
||||
}
|
||||
do {
|
||||
try KeychainService.save(key: .openAIAPIKey, value: trimmedAPIKey)
|
||||
openAIAPIKey = ""
|
||||
editingAPIKey = false
|
||||
} catch {
|
||||
saveErrorText = "OpenAI API Key konnte nicht gespeichert werden."
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
KeychainService.invalidateCache()
|
||||
if !appState.hasValue(for: .openAIAPIKey) {
|
||||
saveErrorText = "OpenAI API Key wurde nicht persistent gespeichert. Bitte App neu starten und erneut versuchen."
|
||||
return
|
||||
}
|
||||
|
||||
withAnimation(.easeInOut(duration: 0.2)) { saved = true }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
withAnimation(.easeInOut(duration: 0.2)) { saved = false }
|
||||
}
|
||||
}
|
||||
|
||||
private func pasteAPIKeyFromClipboard() {
|
||||
guard let rawText = NSPasteboard.general.string(forType: .string) else {
|
||||
saveErrorText = "Zwischenablage enthält keinen Text."
|
||||
return
|
||||
}
|
||||
|
||||
let firstLine = rawText.components(separatedBy: .newlines).first ?? rawText
|
||||
let trimmedKey = firstLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmedKey.range(of: Self.openAIAPIKeyPattern, options: .regularExpression) != nil else {
|
||||
saveErrorText = "Zwischenablage enthält keinen plausiblen OpenAI API Key."
|
||||
return
|
||||
}
|
||||
|
||||
openAIAPIKey = trimmedKey
|
||||
NSPasteboard.general.clearContents()
|
||||
saveErrorText = nil
|
||||
}
|
||||
|
||||
private var installationHeadline: String {
|
||||
switch currentInstallLocation {
|
||||
case .applications:
|
||||
return "Blitztext liegt am richtigen Ort."
|
||||
case .userApplications:
|
||||
return "Blitztext liegt noch in ~/Applications."
|
||||
case .outsideApplications:
|
||||
return "Blitztext liegt noch nicht in /Applications."
|
||||
case .unknown:
|
||||
return "Der Installationsort konnte nicht sicher erkannt werden."
|
||||
}
|
||||
}
|
||||
|
||||
private var installationDetail: String {
|
||||
switch currentInstallLocation {
|
||||
case .applications:
|
||||
if BlitztextInstallLocationService.otherInstalledBundleURLs.isEmpty {
|
||||
return "Für stabile Login-Items und Updates nur diese Kopie weiterverwenden."
|
||||
}
|
||||
return "Diese Kopie ist korrekt. Zusätzliche Kopien solltest du später entfernen."
|
||||
case .userApplications:
|
||||
return "Fuer stabile Hotkeys und Login-Items sollte Blitztext nur aus /Applications laufen."
|
||||
case .outsideApplications:
|
||||
return "Verschiebe Blitztext einmal nach /Applications, damit Anmeldestart und Hotkeys sauber bleiben."
|
||||
case .unknown:
|
||||
return "Öffne Blitztext möglichst direkt aus /Applications."
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshInstallState() {
|
||||
currentInstallLocation = BlitztextInstallLocationService.currentInstallLocation
|
||||
installActionErrorText = nil
|
||||
}
|
||||
|
||||
private func moveToApplications() {
|
||||
installActionErrorText = nil
|
||||
|
||||
do {
|
||||
try BlitztextInstallLocationService.moveToApplicationsAndRelaunch()
|
||||
} catch {
|
||||
installActionErrorText = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func runCleanup() {
|
||||
cleanupStatusText = nil
|
||||
cleanupErrorText = nil
|
||||
|
||||
let report = deleteLocalDataOnCleanup
|
||||
? BlitztextCleanupService.cleanupUserData()
|
||||
: BlitztextCleanupService.removeLaunchAtLoginRegistration()
|
||||
|
||||
KeychainService.invalidateCache()
|
||||
launchAtLoginService.refresh()
|
||||
refreshInstallState()
|
||||
|
||||
if deleteLocalDataOnCleanup {
|
||||
openAIAPIKey = ""
|
||||
editingAPIKey = true
|
||||
}
|
||||
|
||||
if report.failedItems.isEmpty {
|
||||
cleanupStatusText = deleteLocalDataOnCleanup
|
||||
? "Anmeldestart und lokale Daten wurden bereinigt. Jetzt Blitztext beenden und aus /Applications löschen."
|
||||
: "Anmeldestart wurde deaktiviert. Jetzt Blitztext beenden und aus /Applications löschen."
|
||||
showCleanupOptions = false
|
||||
|
||||
let urlsToReveal = report.knownInstallBundleURLs.isEmpty
|
||||
? [BlitztextInstallLocationService.bundleURL]
|
||||
: report.knownInstallBundleURLs
|
||||
revealInFinder(urls: urlsToReveal)
|
||||
return
|
||||
}
|
||||
|
||||
let failureSummary = report.failedItems
|
||||
.map { "\($0.url.lastPathComponent): \($0.errorDescription)" }
|
||||
.joined(separator: "\n")
|
||||
cleanupErrorText = "Nicht alles konnte bereinigt werden:\n\(failureSummary)"
|
||||
}
|
||||
|
||||
private func revealInFinder(urls: [URL]) {
|
||||
guard !urls.isEmpty else { return }
|
||||
NSWorkspace.shared.activateFileViewerSelecting(urls)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Customize Settings (Tab 2: Anpassen)
|
||||
|
||||
struct CustomizeSettingsView: View {
|
||||
@Bindable var appState: AppState
|
||||
@State private var newTerm = ""
|
||||
|
||||
private var installedLocalModels: [LocalTranscriptionModel] {
|
||||
LocalTranscriptionService.installedModels()
|
||||
}
|
||||
|
||||
private var localModelOptions: [LocalTranscriptionModel] {
|
||||
LocalTranscriptionService.modelOptions()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
|
||||
// MARK: Lokaler Modus
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
SectionLabel(text: "Sicherer Lokaler Modus")
|
||||
|
||||
Toggle("Sicherer Lokaler Modus", isOn: $appState.appSettings.secureLocalModeEnabled)
|
||||
.toggleStyle(.switch)
|
||||
.onChange(of: appState.appSettings.secureLocalModeEnabled) { _, newValue in
|
||||
if newValue && !appState.selectedLocalModelIsInstalled {
|
||||
appState.installSelectedLocalModel()
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: appState.selectedLocalModelIsInstalled ? "checkmark.circle.fill" : "arrow.down.circle.fill")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
.foregroundStyle(appState.selectedLocalModelIsInstalled ? .green : .blue)
|
||||
Text(appState.selectedLocalModelIsInstalled ? "\(installedLocalModels.count) lokales WhisperKit-Modell installiert." : "Das ausgewählte Modell wird beim Installieren lokal gespeichert.")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Text("Lokales Modell")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Picker("", selection: Binding(
|
||||
get: { appState.selectedLocalModelName },
|
||||
set: { appState.appSettings.selectedLocalTranscriptionModelName = $0 }
|
||||
)) {
|
||||
ForEach(localModelOptions) { model in
|
||||
Text("\(model.displayName) · \(model.installStateLabel)").tag(model.id)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
.controlSize(.small)
|
||||
.disabled(appState.isDownloadingLocalModel)
|
||||
}
|
||||
|
||||
if let progress = appState.localModelDownloadProgress {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
ProgressView(value: progress)
|
||||
Text(appState.localModelDownloadStatusText ?? "Modell wird geladen...")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
HStack(spacing: 10) {
|
||||
Button(appState.localModelDownloadButtonTitle) {
|
||||
appState.installSelectedLocalModel()
|
||||
}
|
||||
.controlSize(.small)
|
||||
.disabled(appState.selectedLocalModelIsInstalled)
|
||||
|
||||
Link("Modellseite", destination: LocalTranscriptionService.modelPageURL(for: appState.selectedLocalModelName))
|
||||
.font(.system(size: 10.5, weight: .medium))
|
||||
}
|
||||
}
|
||||
|
||||
if let errorText = appState.localModelDownloadErrorText {
|
||||
Text(errorText)
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.red)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Tastenkuerzel
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
SectionLabel(text: "Tastenk\u{00FC}rzel")
|
||||
|
||||
VStack(spacing: 6) {
|
||||
ForEach(WorkflowType.mainMenuCases) { type in
|
||||
HStack {
|
||||
Text(type.hotkeyLabel)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 124, alignment: .leading)
|
||||
Text(appState.displayName(for: type))
|
||||
.font(.system(size: 11.5, weight: .medium))
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mode picker
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Modus")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Picker("", selection: $appState.appSettings.hotkeyMode) {
|
||||
ForEach(HotkeyMode.allCases) { mode in
|
||||
Text(mode.displayName).tag(mode)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Blitztext+
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
SectionLabel(text: "Blitztext+")
|
||||
|
||||
// Tone
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Schreibstil")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Picker("", selection: $appState.textImprovementSettings.tone) {
|
||||
ForEach(TextImprovementSettings.TextTone.allCases) { tone in
|
||||
Text(tone.displayName).tag(tone)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
|
||||
// System Prompt
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Eigene Anweisung")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextEditor(text: $appState.textImprovementSettings.systemPrompt)
|
||||
.font(.system(size: 11))
|
||||
.frame(height: 64)
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(8)
|
||||
.background(Color.primary.opacity(0.03), in: RoundedRectangle(cornerRadius: 6))
|
||||
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Color.primary.opacity(0.06), lineWidth: 0.5))
|
||||
.overlay(alignment: .topLeading) {
|
||||
if appState.textImprovementSettings.systemPrompt.isEmpty {
|
||||
Text("z.B. \"Schreibe pr\u{00E4}gnant und ohne F\u{00FC}llw\u{00F6}rter.\"")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.quaternary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 12)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Context
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Kontext")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextField("z.B. \"E-Mails im Bereich Unternehmensberatung\"", text: $appState.textImprovementSettings.context)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(size: 11))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Blitztext $%&!
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
SectionLabel(text: "Blitztext $%&!")
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Eigene Anweisung")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
TextEditor(text: $appState.dampfAblassenSettings.systemPrompt)
|
||||
.font(.system(size: 11))
|
||||
.frame(height: 80)
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(8)
|
||||
.background(Color.primary.opacity(0.03), in: RoundedRectangle(cornerRadius: 6))
|
||||
.overlay(RoundedRectangle(cornerRadius: 6).strokeBorder(Color.primary.opacity(0.06), lineWidth: 0.5))
|
||||
.overlay(alignment: .topLeading) {
|
||||
if appState.dampfAblassenSettings.systemPrompt.isEmpty {
|
||||
Text("z.B. \"Formuliere den Text sachlich und freundlich um.\"")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.quaternary)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 12)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Blitztext :)
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
SectionLabel(text: "Blitztext :)")
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Emoji-Dichte")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Picker("", selection: $appState.emojiTextSettings.emojiDensity) {
|
||||
ForEach(EmojiTextSettings.EmojiDensity.allCases) { density in
|
||||
Text(density.displayName).tag(density)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Eigennamen
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
SectionLabel(text: "Eigennamen")
|
||||
|
||||
// Term chips
|
||||
if !appState.textImprovementSettings.customTerms.isEmpty {
|
||||
FlowLayout(spacing: 5) {
|
||||
ForEach(appState.textImprovementSettings.customTerms, id: \.self) { term in
|
||||
HStack(spacing: 3) {
|
||||
Text(term)
|
||||
.font(.system(size: 10.5))
|
||||
Button {
|
||||
withAnimation(.easeOut(duration: 0.15)) {
|
||||
appState.textImprovementSettings.customTerms.removeAll { $0 == term }
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.system(size: 7, weight: .bold))
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.buttonStyle(SubtleButtonStyle())
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
Capsule()
|
||||
.fill(Color(nsColor: .controlBackgroundColor))
|
||||
)
|
||||
.overlay(
|
||||
Capsule()
|
||||
.strokeBorder(Color.primary.opacity(0.04), lineWidth: 0.5)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 6) {
|
||||
TextField("Neuer Begriff", text: $newTerm)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(size: 11))
|
||||
.onSubmit { addTerm() }
|
||||
|
||||
Button { addTerm() } label: {
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.font(.system(size: 16))
|
||||
.foregroundStyle(.blue.opacity(0.7))
|
||||
}
|
||||
.buttonStyle(SubtleButtonStyle())
|
||||
.disabled(newTerm.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
.padding(16)
|
||||
}
|
||||
|
||||
private func addTerm() {
|
||||
let trimmed = newTerm.trimmingCharacters(in: .whitespaces)
|
||||
guard !trimmed.isEmpty, !appState.textImprovementSettings.customTerms.contains(trimmed) else { return }
|
||||
withAnimation(.easeOut(duration: 0.15)) {
|
||||
appState.textImprovementSettings.customTerms.append(trimmed)
|
||||
}
|
||||
newTerm = ""
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Flow Layout (for term tags)
|
||||
|
||||
struct FlowLayout: Layout {
|
||||
var spacing: CGFloat = 6
|
||||
|
||||
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
|
||||
let result = arrangeSubviews(proposal: proposal, subviews: subviews)
|
||||
return result.size
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
let result = arrangeSubviews(proposal: proposal, subviews: subviews)
|
||||
for (index, position) in result.positions.enumerated() {
|
||||
subviews[index].place(at: CGPoint(x: bounds.minX + position.x, y: bounds.minY + position.y), proposal: .unspecified)
|
||||
}
|
||||
}
|
||||
|
||||
private func arrangeSubviews(proposal: ProposedViewSize, subviews: Subviews) -> (positions: [CGPoint], size: CGSize) {
|
||||
let maxWidth = proposal.width ?? .infinity
|
||||
var positions: [CGPoint] = []
|
||||
var x: CGFloat = 0
|
||||
var y: CGFloat = 0
|
||||
var rowHeight: CGFloat = 0
|
||||
var maxX: CGFloat = 0
|
||||
|
||||
for subview in subviews {
|
||||
let size = subview.sizeThatFits(.unspecified)
|
||||
if x + size.width > maxWidth && x > 0 {
|
||||
x = 0
|
||||
y += rowHeight + spacing
|
||||
rowHeight = 0
|
||||
}
|
||||
positions.append(CGPoint(x: x, y: y))
|
||||
rowHeight = max(rowHeight, size.height)
|
||||
x += size.width + spacing
|
||||
maxX = max(maxX, x)
|
||||
}
|
||||
|
||||
return (positions, CGSize(width: maxX, height: y + rowHeight))
|
||||
}
|
||||
}
|
||||
118
BlitztextMac/Features/Workflows/DampfAblassenWorkflow.swift
Normal file
@ -0,0 +1,118 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class DampfAblassenWorkflow: Workflow {
|
||||
let type = WorkflowType.dampfAblassen
|
||||
var phase: WorkflowPhase = .idle {
|
||||
didSet { onPhaseChange?(phase) }
|
||||
}
|
||||
var onOutput: WorkflowOutputHandler?
|
||||
var onPhaseChange: WorkflowPhaseChangeHandler?
|
||||
|
||||
private let recorder = AudioRecorder()
|
||||
private let settings: DampfAblassenSettings
|
||||
private let customTerms: [String]
|
||||
private let language: String
|
||||
private var processingTask: Task<Void, Never>?
|
||||
|
||||
init(settings: DampfAblassenSettings, customTerms: [String] = [], language: String = "de") {
|
||||
self.settings = settings
|
||||
self.customTerms = customTerms
|
||||
self.language = language
|
||||
}
|
||||
|
||||
// MARK: - Recording State
|
||||
|
||||
var isRecording: Bool { recorder.isRecording }
|
||||
var audioLevel: Float { recorder.audioLevel }
|
||||
|
||||
// MARK: - Workflow Protocol
|
||||
|
||||
func start() {
|
||||
phase = .running("Aufnahme läuft ...")
|
||||
recorder.startRecording()
|
||||
|
||||
if let error = recorder.errorMessage {
|
||||
phase = .error(error)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
if recorder.isRecording {
|
||||
recorder.stopRecording()
|
||||
guard !TranscriptionQualityService.shouldRejectRecording(duration: recorder.lastRecordingDuration) else {
|
||||
recorder.discardRecording()
|
||||
phase = .error("Keine Aufnahme erkannt.")
|
||||
return
|
||||
}
|
||||
processRecording()
|
||||
} else {
|
||||
processingTask?.cancel()
|
||||
phase = .idle
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
processingTask?.cancel()
|
||||
if recorder.isRecording {
|
||||
recorder.stopRecording()
|
||||
}
|
||||
recorder.discardRecording()
|
||||
phase = .idle
|
||||
}
|
||||
|
||||
// MARK: - Two-Phase Processing: Whisper -> GPT Rage Mode
|
||||
|
||||
private func processRecording() {
|
||||
guard let url = recorder.recordingURL else {
|
||||
phase = .error("Keine Aufnahme vorhanden.")
|
||||
return
|
||||
}
|
||||
|
||||
phase = .running("Wird transkribiert ...")
|
||||
let recordingDuration = recorder.lastRecordingDuration
|
||||
let vocabularyHints = recordingDuration >= 0.9 ? customTerms : []
|
||||
|
||||
processingTask = Task {
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
do {
|
||||
// Phase 1: Whisper transcription
|
||||
let rawText = try await TranscriptionService.transcribe(
|
||||
audioURL: url,
|
||||
customTerms: vocabularyHints,
|
||||
language: language
|
||||
)
|
||||
let cleanedRawText = TranscriptionQualityService.cleanedTranscript(rawText)
|
||||
guard !TranscriptionQualityService.isLikelyArtifact(cleanedRawText, recordingDuration: recordingDuration) else {
|
||||
phase = .error("Keine Aufnahme erkannt.")
|
||||
return
|
||||
}
|
||||
|
||||
if Task.isCancelled { return }
|
||||
|
||||
// Phase 2: GPT dampf ablassen
|
||||
phase = .running("Wird umformuliert ...")
|
||||
|
||||
let answer = try await LLMService.dampfAblassen(
|
||||
text: cleanedRawText,
|
||||
systemPrompt: settings.systemPrompt
|
||||
)
|
||||
let cleanedAnswer = TranscriptionQualityService.cleanedTranscript(answer)
|
||||
guard cleanedAnswer != "KEINE_AUFNAHME_ERKANNT" else {
|
||||
phase = .error("Keine Aufnahme erkannt.")
|
||||
return
|
||||
}
|
||||
phase = .done(cleanedAnswer)
|
||||
onOutput?(cleanedAnswer)
|
||||
} catch {
|
||||
phase = .error(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
118
BlitztextMac/Features/Workflows/EmojiTextWorkflow.swift
Normal file
@ -0,0 +1,118 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class EmojiTextWorkflow: Workflow {
|
||||
let type = WorkflowType.emojiText
|
||||
var phase: WorkflowPhase = .idle {
|
||||
didSet { onPhaseChange?(phase) }
|
||||
}
|
||||
var onOutput: WorkflowOutputHandler?
|
||||
var onPhaseChange: WorkflowPhaseChangeHandler?
|
||||
|
||||
private let recorder = AudioRecorder()
|
||||
private let settings: EmojiTextSettings
|
||||
private let customTerms: [String]
|
||||
private let language: String
|
||||
private var processingTask: Task<Void, Never>?
|
||||
|
||||
init(settings: EmojiTextSettings, customTerms: [String] = [], language: String = "de") {
|
||||
self.settings = settings
|
||||
self.customTerms = customTerms
|
||||
self.language = language
|
||||
}
|
||||
|
||||
// MARK: - Recording State
|
||||
|
||||
var isRecording: Bool { recorder.isRecording }
|
||||
var audioLevel: Float { recorder.audioLevel }
|
||||
|
||||
// MARK: - Workflow Protocol
|
||||
|
||||
func start() {
|
||||
phase = .running("Aufnahme l\u{00E4}uft ...")
|
||||
recorder.startRecording()
|
||||
|
||||
if let error = recorder.errorMessage {
|
||||
phase = .error(error)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
if recorder.isRecording {
|
||||
recorder.stopRecording()
|
||||
guard !TranscriptionQualityService.shouldRejectRecording(duration: recorder.lastRecordingDuration) else {
|
||||
recorder.discardRecording()
|
||||
phase = .error("Keine Aufnahme erkannt.")
|
||||
return
|
||||
}
|
||||
processRecording()
|
||||
} else {
|
||||
processingTask?.cancel()
|
||||
phase = .idle
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
processingTask?.cancel()
|
||||
if recorder.isRecording {
|
||||
recorder.stopRecording()
|
||||
}
|
||||
recorder.discardRecording()
|
||||
phase = .idle
|
||||
}
|
||||
|
||||
// MARK: - Two-Phase Processing: Whisper -> Emoji
|
||||
|
||||
private func processRecording() {
|
||||
guard let url = recorder.recordingURL else {
|
||||
phase = .error("Keine Aufnahme vorhanden.")
|
||||
return
|
||||
}
|
||||
|
||||
phase = .running("Wird transkribiert ...")
|
||||
let recordingDuration = recorder.lastRecordingDuration
|
||||
let vocabularyHints = recordingDuration >= 0.9 ? customTerms : []
|
||||
|
||||
processingTask = Task {
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
do {
|
||||
// Phase 1: Whisper transcription
|
||||
let rawText = try await TranscriptionService.transcribe(
|
||||
audioURL: url,
|
||||
customTerms: vocabularyHints,
|
||||
language: language
|
||||
)
|
||||
let cleanedRawText = TranscriptionQualityService.cleanedTranscript(rawText)
|
||||
guard !TranscriptionQualityService.isLikelyArtifact(cleanedRawText, recordingDuration: recordingDuration) else {
|
||||
phase = .error("Keine Aufnahme erkannt.")
|
||||
return
|
||||
}
|
||||
|
||||
if Task.isCancelled { return }
|
||||
|
||||
// Phase 2: Add emojis
|
||||
phase = .running("Emojis werden eingef\u{00FC}gt ...")
|
||||
|
||||
let result = try await LLMService.addEmojis(
|
||||
text: cleanedRawText,
|
||||
settings: settings
|
||||
)
|
||||
let cleanedResult = TranscriptionQualityService.cleanedTranscript(result)
|
||||
guard cleanedResult != "KEINE_AUFNAHME_ERKANNT" else {
|
||||
phase = .error("Keine Aufnahme erkannt.")
|
||||
return
|
||||
}
|
||||
phase = .done(cleanedResult)
|
||||
onOutput?(cleanedResult)
|
||||
} catch {
|
||||
phase = .error(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
113
BlitztextMac/Features/Workflows/TextImprovementWorkflow.swift
Normal file
@ -0,0 +1,113 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class TextImprovementWorkflow: Workflow {
|
||||
let type = WorkflowType.textImprover
|
||||
var phase: WorkflowPhase = .idle {
|
||||
didSet { onPhaseChange?(phase) }
|
||||
}
|
||||
var onOutput: WorkflowOutputHandler?
|
||||
var onPhaseChange: WorkflowPhaseChangeHandler?
|
||||
|
||||
private let recorder = AudioRecorder()
|
||||
private let settings: TextImprovementSettings
|
||||
private let language: String
|
||||
private var processingTask: Task<Void, Never>?
|
||||
|
||||
init(settings: TextImprovementSettings, language: String = "de") {
|
||||
self.settings = settings
|
||||
self.language = language
|
||||
}
|
||||
|
||||
// MARK: - Recording State
|
||||
|
||||
var isRecording: Bool { recorder.isRecording }
|
||||
var audioLevel: Float { recorder.audioLevel }
|
||||
|
||||
// MARK: - Workflow Protocol
|
||||
|
||||
func start() {
|
||||
phase = .running("Aufnahme läuft ...")
|
||||
recorder.startRecording()
|
||||
|
||||
if let error = recorder.errorMessage {
|
||||
phase = .error(error)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
if recorder.isRecording {
|
||||
recorder.stopRecording()
|
||||
guard !TranscriptionQualityService.shouldRejectRecording(duration: recorder.lastRecordingDuration) else {
|
||||
recorder.discardRecording()
|
||||
phase = .error("Keine Aufnahme erkannt.")
|
||||
return
|
||||
}
|
||||
processRecording()
|
||||
} else {
|
||||
processingTask?.cancel()
|
||||
phase = .idle
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
processingTask?.cancel()
|
||||
if recorder.isRecording {
|
||||
recorder.stopRecording()
|
||||
}
|
||||
recorder.discardRecording()
|
||||
phase = .idle
|
||||
}
|
||||
|
||||
// MARK: - Two-Phase Processing: Whisper -> GPT
|
||||
|
||||
private func processRecording() {
|
||||
guard let url = recorder.recordingURL else {
|
||||
phase = .error("Keine Aufnahme vorhanden.")
|
||||
return
|
||||
}
|
||||
|
||||
phase = .running("Wird transkribiert ...")
|
||||
let recordingDuration = recorder.lastRecordingDuration
|
||||
let vocabularyHints = recordingDuration >= 0.9 ? settings.customTerms : []
|
||||
|
||||
processingTask = Task {
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
do {
|
||||
// Phase 1: Whisper transcription
|
||||
let rawText = try await TranscriptionService.transcribe(
|
||||
audioURL: url,
|
||||
customTerms: vocabularyHints,
|
||||
language: language
|
||||
)
|
||||
let cleanedRawText = TranscriptionQualityService.cleanedTranscript(rawText)
|
||||
guard !TranscriptionQualityService.isLikelyArtifact(cleanedRawText, recordingDuration: recordingDuration) else {
|
||||
phase = .error("Keine Aufnahme erkannt.")
|
||||
return
|
||||
}
|
||||
|
||||
if Task.isCancelled { return }
|
||||
|
||||
// Phase 2: GPT improvement
|
||||
phase = .running("Text wird verbessert ...")
|
||||
|
||||
let improved = try await LLMService.improve(
|
||||
text: cleanedRawText,
|
||||
settings: settings
|
||||
)
|
||||
|
||||
let cleanedImproved = TranscriptionQualityService.cleanedTranscript(improved)
|
||||
phase = .done(cleanedImproved)
|
||||
onOutput?(cleanedImproved)
|
||||
} catch {
|
||||
phase = .error(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
138
BlitztextMac/Features/Workflows/TranscriptionWorkflow.swift
Normal file
@ -0,0 +1,138 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
private let transcriptionLogger = Logger(subsystem: "app.blitztext.mac", category: "Transcription")
|
||||
|
||||
private func elapsedMilliseconds(since start: Date, until end: Date = Date()) -> Int {
|
||||
Int((end.timeIntervalSince(start) * 1000).rounded())
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class TranscriptionWorkflow: Workflow {
|
||||
let type: WorkflowType
|
||||
var phase: WorkflowPhase = .idle {
|
||||
didSet { onPhaseChange?(phase) }
|
||||
}
|
||||
var onOutput: WorkflowOutputHandler?
|
||||
var onPhaseChange: WorkflowPhaseChangeHandler?
|
||||
|
||||
private let recorder = AudioRecorder()
|
||||
private let customTerms: [String]
|
||||
private let language: String
|
||||
private let backend: TranscriptionBackend
|
||||
private let localModelName: String
|
||||
private var transcriptionTask: Task<Void, Never>?
|
||||
|
||||
init(
|
||||
type: WorkflowType = .transcription,
|
||||
customTerms: [String] = [],
|
||||
language: String = "de",
|
||||
backend: TranscriptionBackend = .remote,
|
||||
localModelName: String = LocalTranscriptionService.recommendedFastModelName
|
||||
) {
|
||||
self.type = type
|
||||
self.customTerms = customTerms
|
||||
self.language = language
|
||||
self.backend = backend
|
||||
self.localModelName = localModelName
|
||||
}
|
||||
|
||||
func start() {
|
||||
phase = .running("Aufnahme läuft ...")
|
||||
recorder.startRecording()
|
||||
|
||||
if let error = recorder.errorMessage {
|
||||
phase = .error(error)
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
if recorder.isRecording {
|
||||
recorder.stopRecording()
|
||||
guard !TranscriptionQualityService.shouldRejectRecording(duration: recorder.lastRecordingDuration) else {
|
||||
recorder.discardRecording()
|
||||
phase = .error("Keine Aufnahme erkannt.")
|
||||
return
|
||||
}
|
||||
transcribe()
|
||||
} else {
|
||||
transcriptionTask?.cancel()
|
||||
phase = .idle
|
||||
}
|
||||
}
|
||||
|
||||
func reset() {
|
||||
transcriptionTask?.cancel()
|
||||
if recorder.isRecording {
|
||||
recorder.stopRecording()
|
||||
}
|
||||
recorder.discardRecording()
|
||||
phase = .idle
|
||||
}
|
||||
|
||||
var isRecording: Bool { recorder.isRecording }
|
||||
var audioLevel: Float { recorder.audioLevel }
|
||||
|
||||
private func transcribe() {
|
||||
guard let url = recorder.recordingURL else {
|
||||
phase = .error("Keine Aufnahme vorhanden.")
|
||||
return
|
||||
}
|
||||
|
||||
phase = .running(backend == .local ? "Wird lokal transkribiert ..." : "Wird transkribiert ...")
|
||||
let recordingDuration = recorder.lastRecordingDuration
|
||||
let vocabularyHints = recordingDuration >= 0.9 ? customTerms : []
|
||||
let requestLanguage = language
|
||||
let stopTime = Date()
|
||||
|
||||
transcriptionTask = Task(priority: .userInitiated) {
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
let requestStart = Date()
|
||||
do {
|
||||
let text: String
|
||||
switch backend {
|
||||
case .remote:
|
||||
text = try await TranscriptionService.transcribe(
|
||||
audioURL: url,
|
||||
customTerms: vocabularyHints,
|
||||
language: requestLanguage
|
||||
)
|
||||
case .local:
|
||||
text = try await LocalTranscriptionService.shared.transcribe(
|
||||
audioURL: url,
|
||||
language: requestLanguage,
|
||||
modelName: localModelName
|
||||
)
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
|
||||
let responseReceivedAt = Date()
|
||||
let cleaned = TranscriptionQualityService.cleanedTranscript(text)
|
||||
guard !TranscriptionQualityService.isLikelyArtifact(cleaned, recordingDuration: recordingDuration) else {
|
||||
transcriptionLogger.info(
|
||||
"Transcription rejected short artifact after \(elapsedMilliseconds(since: stopTime)) ms"
|
||||
)
|
||||
phase = .error("Keine Aufnahme erkannt.")
|
||||
return
|
||||
}
|
||||
|
||||
transcriptionLogger.info(
|
||||
"Transcription ready in \(elapsedMilliseconds(since: stopTime, until: responseReceivedAt)) ms (request \(elapsedMilliseconds(since: requestStart, until: responseReceivedAt)) ms)"
|
||||
)
|
||||
phase = .done(cleaned)
|
||||
onOutput?(cleaned)
|
||||
} catch {
|
||||
transcriptionLogger.error(
|
||||
"Transcription failed after \(elapsedMilliseconds(since: stopTime)) ms: \(error.localizedDescription, privacy: .private)"
|
||||
)
|
||||
phase = .error(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
223
BlitztextMac/Features/Workflows/WorkflowProtocol.swift
Normal file
@ -0,0 +1,223 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - Workflow Types
|
||||
|
||||
enum WorkflowType: String, CaseIterable, Identifiable, Codable {
|
||||
case transcription
|
||||
case localTranscription
|
||||
case textImprover
|
||||
case dampfAblassen
|
||||
case emojiText
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
static var mainMenuCases: [WorkflowType] {
|
||||
allCases.filter { $0 != .localTranscription }
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .transcription: return "Blitztext"
|
||||
case .localTranscription: return "Blitztext Lokal"
|
||||
case .textImprover: return "Blitztext+"
|
||||
case .dampfAblassen: return "Blitztext $%&!"
|
||||
case .emojiText: return "Blitztext :)"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .transcription: return "mic.fill"
|
||||
case .localTranscription: return "lock.shield.fill"
|
||||
case .textImprover: return "text.badge.checkmark"
|
||||
case .dampfAblassen: return "flame.fill"
|
||||
case .emojiText: return "face.smiling"
|
||||
}
|
||||
}
|
||||
|
||||
var subtitle: String {
|
||||
switch self {
|
||||
case .transcription: return "Sprache rein. Text raus."
|
||||
case .localTranscription: return "Nur lokal. Kein Server."
|
||||
case .textImprover: return "Geschrieben sprechen."
|
||||
case .dampfAblassen: return "Frust rein. Entspannt raus."
|
||||
case .emojiText: return "Text rein. Emojis dazu."
|
||||
}
|
||||
}
|
||||
|
||||
var hotkeyLabel: String {
|
||||
switch self {
|
||||
case .transcription: return "fn + Shift"
|
||||
case .localTranscription: return "fn + Shift + Ctrl"
|
||||
case .textImprover: return "fn + Control"
|
||||
case .dampfAblassen: return "fn + Option"
|
||||
case .emojiText: return "fn + Cmd"
|
||||
}
|
||||
}
|
||||
|
||||
var accentColor: String {
|
||||
switch self {
|
||||
case .transcription: return "blue"
|
||||
case .localTranscription: return "green"
|
||||
case .textImprover: return "purple"
|
||||
case .dampfAblassen: return "orange"
|
||||
case .emojiText: return "cyan"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Workflow State
|
||||
|
||||
enum WorkflowPhase: Equatable {
|
||||
case idle
|
||||
case running(String)
|
||||
case done(String)
|
||||
case error(String)
|
||||
|
||||
var isActive: Bool {
|
||||
switch self {
|
||||
case .idle: return false
|
||||
default: return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum WorkflowLaunchSource: Equatable {
|
||||
case manual
|
||||
case hotkeyBackground
|
||||
|
||||
var presentsWorkflowPage: Bool {
|
||||
switch self {
|
||||
case .manual:
|
||||
return true
|
||||
case .hotkeyBackground:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
typealias WorkflowOutputHandler = @MainActor (String) -> Void
|
||||
typealias WorkflowPhaseChangeHandler = @MainActor (WorkflowPhase) -> Void
|
||||
|
||||
// MARK: - Workflow Protocol
|
||||
|
||||
@MainActor
|
||||
protocol Workflow: AnyObject, Observable {
|
||||
var type: WorkflowType { get }
|
||||
var phase: WorkflowPhase { get set }
|
||||
var isRecording: Bool { get }
|
||||
var onOutput: WorkflowOutputHandler? { get set }
|
||||
var onPhaseChange: WorkflowPhaseChangeHandler? { get set }
|
||||
|
||||
func start()
|
||||
func stop()
|
||||
func reset()
|
||||
}
|
||||
|
||||
// MARK: - App Settings
|
||||
|
||||
struct AppSettings: Codable {
|
||||
var hotkeyMode: HotkeyMode = .hold
|
||||
var hasSeenOnboarding: Bool = false
|
||||
var secureLocalModeEnabled: Bool = false
|
||||
var selectedLocalTranscriptionModelName: String = LocalTranscriptionService.recommendedFastModelName
|
||||
var hasAutoSelectedFastLocalModel: Bool = false
|
||||
|
||||
init(
|
||||
hotkeyMode: HotkeyMode = .hold,
|
||||
hasSeenOnboarding: Bool = false,
|
||||
secureLocalModeEnabled: Bool = false,
|
||||
selectedLocalTranscriptionModelName: String = LocalTranscriptionService.recommendedFastModelName,
|
||||
hasAutoSelectedFastLocalModel: Bool = false
|
||||
) {
|
||||
self.hotkeyMode = hotkeyMode
|
||||
self.hasSeenOnboarding = hasSeenOnboarding
|
||||
self.secureLocalModeEnabled = secureLocalModeEnabled
|
||||
self.selectedLocalTranscriptionModelName = selectedLocalTranscriptionModelName
|
||||
self.hasAutoSelectedFastLocalModel = hasAutoSelectedFastLocalModel
|
||||
}
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case hotkeyMode
|
||||
case hasSeenOnboarding
|
||||
case secureLocalModeEnabled
|
||||
case selectedLocalTranscriptionModelName
|
||||
case hasAutoSelectedFastLocalModel
|
||||
}
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
hotkeyMode = try container.decodeIfPresent(HotkeyMode.self, forKey: .hotkeyMode) ?? .hold
|
||||
hasSeenOnboarding = try container.decodeIfPresent(Bool.self, forKey: .hasSeenOnboarding) ?? false
|
||||
secureLocalModeEnabled = try container.decodeIfPresent(Bool.self, forKey: .secureLocalModeEnabled) ?? false
|
||||
selectedLocalTranscriptionModelName = try container.decodeIfPresent(
|
||||
String.self,
|
||||
forKey: .selectedLocalTranscriptionModelName
|
||||
) ?? LocalTranscriptionService.recommendedFastModelName
|
||||
hasAutoSelectedFastLocalModel = try container.decodeIfPresent(
|
||||
Bool.self,
|
||||
forKey: .hasAutoSelectedFastLocalModel
|
||||
) ?? false
|
||||
}
|
||||
}
|
||||
|
||||
enum TranscriptionBackend: String, Codable {
|
||||
case remote
|
||||
case local
|
||||
}
|
||||
|
||||
// MARK: - Workflow Settings
|
||||
|
||||
struct TranscriptionSettings: Codable {
|
||||
var language: String = "de"
|
||||
}
|
||||
|
||||
struct DampfAblassenSettings: Codable {
|
||||
var systemPrompt: String = "Du erhältst ein emotional gesprochenes Transkript. Erkenne zuerst das eigentliche Ziel, Anliegen und den wahren Frust der Person. Formuliere daraus eine klare, respektvolle und wirksame Nachricht, mit der die Person ihr Ziel eher erreicht. Bewahre relevante Fakten, konkrete Probleme, Grenzen, Erwartungen und die nötige Dringlichkeit. Entferne Beleidigungen, Drohungen, Sarkasmus, Unterstellungen und unnötige Eskalation. Wenn mehrere Vorwürfe genannt werden, verdichte sie auf die entscheidenden Kernpunkte. Der Ton soll ruhig, menschlich, bestimmt und lösungsorientiert sein. Gib NUR die fertige Nachricht zurück."
|
||||
var customName: String = ""
|
||||
}
|
||||
|
||||
struct EmojiTextSettings: Codable {
|
||||
var emojiDensity: EmojiDensity = .mittel
|
||||
var customName: String = ""
|
||||
|
||||
enum EmojiDensity: String, Codable, CaseIterable, Identifiable {
|
||||
case wenig
|
||||
case mittel
|
||||
case viel
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .wenig: return "Wenig"
|
||||
case .mittel: return "Mittel"
|
||||
case .viel: return "Viel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TextImprovementSettings: Codable {
|
||||
var systemPrompt: String = ""
|
||||
var customTerms: [String] = []
|
||||
var context: String = ""
|
||||
var tone: TextTone = .neutral
|
||||
var customName: String = ""
|
||||
|
||||
enum TextTone: String, Codable, CaseIterable, Identifiable {
|
||||
case formal
|
||||
case neutral
|
||||
case casual
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .formal: return "Formell"
|
||||
case .neutral: return "Neutral"
|
||||
case .casual: return "Locker"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
BlitztextMac/Resources/AppIcon.icns
Normal file
@ -0,0 +1,80 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "icon_16x16.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_16x16@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_32x32.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_32x32@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_64x64.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "64x64"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_64x64@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "64x64"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_128x128.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_128x128@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_256x256.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_256x256@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_512x512.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "icon_512x512@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 388 B |
|
After Width: | Height: | Size: 601 B |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 601 B |
|
After Width: | Height: | Size: 931 B |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 931 B |
|
After Width: | Height: | Size: 1.4 KiB |
6
BlitztextMac/Resources/Assets.xcassets/Contents.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
12
BlitztextMac/Resources/BlitztextMac.entitlements
Normal file
@ -0,0 +1,12 @@
|
||||
<?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>com.apple.security.app-sandbox</key>
|
||||
<false/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
28
BlitztextMac/Resources/Info.plist
Normal file
@ -0,0 +1,28 @@
|
||||
<?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>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(MARKETING_VERSION)</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Blitztext benötigt Mikrofon-Zugriff für die Sprach-Transkription.</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
</dict>
|
||||
</plist>
|
||||
BIN
BlitztextMac/Resources/menubar_icon.png
Normal file
|
After Width: | Height: | Size: 110 B |
BIN
BlitztextMac/Resources/menubar_icon@2x.png
Normal file
|
After Width: | Height: | Size: 142 B |
34
BlitztextMac/Services/AccessibilityPermissionService.swift
Normal file
@ -0,0 +1,34 @@
|
||||
import AppKit
|
||||
import ApplicationServices
|
||||
|
||||
@MainActor
|
||||
enum AccessibilityPermissionService {
|
||||
private static var hasPromptedThisSession = false
|
||||
|
||||
static func currentStatus() -> Bool {
|
||||
AXIsProcessTrusted()
|
||||
}
|
||||
|
||||
static func isTrusted(promptIfNeeded: Bool) -> Bool {
|
||||
let shouldPrompt = promptIfNeeded && !hasPromptedThisSession
|
||||
if shouldPrompt {
|
||||
hasPromptedThisSession = true
|
||||
}
|
||||
|
||||
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: shouldPrompt] as CFDictionary
|
||||
return AXIsProcessTrustedWithOptions(options)
|
||||
}
|
||||
|
||||
static func requestPermissionPrompt() -> Bool {
|
||||
hasPromptedThisSession = true
|
||||
let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary
|
||||
return AXIsProcessTrustedWithOptions(options)
|
||||
}
|
||||
|
||||
static func openSystemSettings() {
|
||||
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") else {
|
||||
return
|
||||
}
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
57
BlitztextMac/Services/AppSupportPaths.swift
Normal file
@ -0,0 +1,57 @@
|
||||
import Foundation
|
||||
|
||||
enum AppSupportPaths {
|
||||
private static let bundleIdentifier = Bundle.main.bundleIdentifier ?? "app.blitztext.mac"
|
||||
|
||||
static var appSupportDirectoryURL: URL {
|
||||
FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)
|
||||
.first!
|
||||
.appendingPathComponent("Blitztext", isDirectory: true)
|
||||
}
|
||||
|
||||
static var settingsURL: URL {
|
||||
appSupportDirectoryURL.appendingPathComponent("settings.json")
|
||||
}
|
||||
|
||||
static var localModelsDirectoryURL: URL {
|
||||
appSupportDirectoryURL.appendingPathComponent("models", isDirectory: true)
|
||||
}
|
||||
|
||||
static var whisperKitModelsDirectoryURL: URL {
|
||||
localModelsDirectoryURL.appendingPathComponent("whisperkit", isDirectory: true)
|
||||
}
|
||||
|
||||
static var defaultWhisperKitModelURL: URL {
|
||||
whisperKitModelsDirectoryURL.appendingPathComponent(
|
||||
"openai_whisper-large-v3-v20240930_626MB",
|
||||
isDirectory: true
|
||||
)
|
||||
}
|
||||
|
||||
static var cachesDirectoryURL: URL {
|
||||
FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
|
||||
.first!
|
||||
.appendingPathComponent(bundleIdentifier, isDirectory: true)
|
||||
}
|
||||
|
||||
static var preferencesURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library", isDirectory: true)
|
||||
.appendingPathComponent("Preferences", isDirectory: true)
|
||||
.appendingPathComponent("\(bundleIdentifier).plist")
|
||||
}
|
||||
|
||||
static var savedApplicationStateDirectoryURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Library", isDirectory: true)
|
||||
.appendingPathComponent("Saved Application State", isDirectory: true)
|
||||
.appendingPathComponent("\(bundleIdentifier).savedState", isDirectory: true)
|
||||
}
|
||||
|
||||
static func ensureAppSupportDirectoryExists() throws {
|
||||
try FileManager.default.createDirectory(
|
||||
at: appSupportDirectoryURL,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
}
|
||||
}
|
||||
98
BlitztextMac/Services/AudioRecorder.swift
Normal file
@ -0,0 +1,98 @@
|
||||
import AVFoundation
|
||||
import Observation
|
||||
|
||||
@Observable
|
||||
final class AudioRecorder: NSObject, AVAudioRecorderDelegate {
|
||||
var isRecording = false
|
||||
var recordingURL: URL?
|
||||
var errorMessage: String?
|
||||
var audioLevel: Float = 0
|
||||
var lastRecordingDuration: TimeInterval = 0
|
||||
|
||||
private var audioRecorder: AVAudioRecorder?
|
||||
private var levelTimer: Timer?
|
||||
private var currentFileURL: URL?
|
||||
|
||||
private func makeRecordingURL() -> URL {
|
||||
FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("blitztext-\(UUID().uuidString).m4a")
|
||||
}
|
||||
|
||||
func startRecording() {
|
||||
errorMessage = nil
|
||||
lastRecordingDuration = 0
|
||||
recordingURL = nil
|
||||
if let currentFileURL {
|
||||
try? FileManager.default.removeItem(at: currentFileURL)
|
||||
}
|
||||
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
|
||||
AVSampleRateKey: 16000,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue,
|
||||
]
|
||||
|
||||
do {
|
||||
let fileURL = makeRecordingURL()
|
||||
currentFileURL = fileURL
|
||||
audioRecorder = try AVAudioRecorder(url: fileURL, settings: settings)
|
||||
audioRecorder?.delegate = self
|
||||
audioRecorder?.isMeteringEnabled = true
|
||||
audioRecorder?.record()
|
||||
isRecording = true
|
||||
startMetering()
|
||||
} catch {
|
||||
currentFileURL = nil
|
||||
errorMessage = "Aufnahme konnte nicht gestartet werden: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
func stopRecording() {
|
||||
stopMetering()
|
||||
lastRecordingDuration = audioRecorder?.currentTime ?? 0
|
||||
audioRecorder?.stop()
|
||||
isRecording = false
|
||||
recordingURL = currentFileURL
|
||||
currentFileURL = nil
|
||||
audioRecorder = nil
|
||||
audioLevel = 0
|
||||
}
|
||||
|
||||
func discardRecording() {
|
||||
if let recordingURL {
|
||||
try? FileManager.default.removeItem(at: recordingURL)
|
||||
self.recordingURL = nil
|
||||
}
|
||||
|
||||
if let currentFileURL {
|
||||
try? FileManager.default.removeItem(at: currentFileURL)
|
||||
self.currentFileURL = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func startMetering() {
|
||||
levelTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { [weak self] _ in
|
||||
guard let self else { return }
|
||||
self.audioRecorder?.updateMeters()
|
||||
let power = self.audioRecorder?.averagePower(forChannel: 0) ?? -160
|
||||
let normalized = max(0, min(1, (power + 50) / 50))
|
||||
self.audioLevel = normalized
|
||||
}
|
||||
}
|
||||
|
||||
private func stopMetering() {
|
||||
levelTimer?.invalidate()
|
||||
levelTimer = nil
|
||||
}
|
||||
|
||||
// MARK: - AVAudioRecorderDelegate
|
||||
|
||||
nonisolated func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) {
|
||||
if !flag {
|
||||
Task { @MainActor in
|
||||
self.errorMessage = "Aufnahme fehlgeschlagen"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
108
BlitztextMac/Services/BlitztextCleanupService.swift
Normal file
@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
import ServiceManagement
|
||||
|
||||
enum BlitztextCleanupService {
|
||||
struct CleanupItemFailure: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
let url: URL
|
||||
let errorDescription: String
|
||||
}
|
||||
|
||||
struct CleanupReport: Equatable {
|
||||
let removedURLs: [URL]
|
||||
let failedItems: [CleanupItemFailure]
|
||||
let knownInstallBundleURLs: [URL]
|
||||
|
||||
var didSucceedFully: Bool {
|
||||
failedItems.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
static func cleanupUserData() -> CleanupReport {
|
||||
KeychainService.delete(key: .openAIAPIKey)
|
||||
|
||||
return cleanup(paths: [
|
||||
AppSupportPaths.settingsURL,
|
||||
AppSupportPaths.appSupportDirectoryURL,
|
||||
AppSupportPaths.cachesDirectoryURL,
|
||||
AppSupportPaths.preferencesURL,
|
||||
AppSupportPaths.savedApplicationStateDirectoryURL
|
||||
], unregisterLaunchAtLogin: true)
|
||||
}
|
||||
|
||||
static func removeLaunchAtLoginRegistration() -> CleanupReport {
|
||||
cleanup(paths: [], unregisterLaunchAtLogin: true)
|
||||
}
|
||||
|
||||
static func removeApplicationSupportFiles() -> CleanupReport {
|
||||
KeychainService.delete(key: .openAIAPIKey)
|
||||
|
||||
return cleanup(
|
||||
paths: [
|
||||
AppSupportPaths.settingsURL,
|
||||
AppSupportPaths.appSupportDirectoryURL
|
||||
],
|
||||
unregisterLaunchAtLogin: false
|
||||
)
|
||||
}
|
||||
|
||||
static func removeCacheAndStateFiles() -> CleanupReport {
|
||||
cleanup(
|
||||
paths: [
|
||||
AppSupportPaths.cachesDirectoryURL,
|
||||
AppSupportPaths.preferencesURL,
|
||||
AppSupportPaths.savedApplicationStateDirectoryURL
|
||||
],
|
||||
unregisterLaunchAtLogin: false
|
||||
)
|
||||
}
|
||||
|
||||
static func knownInstallBundleURLs() -> [URL] {
|
||||
BlitztextInstallLocationService.knownInstallBundleURLs
|
||||
}
|
||||
|
||||
static func cleanup(paths: [URL], unregisterLaunchAtLogin: Bool) -> CleanupReport {
|
||||
var removedURLs: [URL] = []
|
||||
var failedItems: [CleanupItemFailure] = []
|
||||
|
||||
if unregisterLaunchAtLogin {
|
||||
do {
|
||||
try SMAppService.mainApp.unregister()
|
||||
} catch {
|
||||
failedItems.append(
|
||||
CleanupItemFailure(
|
||||
url: BlitztextInstallLocationService.bundleURL,
|
||||
errorDescription: error.localizedDescription
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for url in paths {
|
||||
do {
|
||||
try removeItemIfNeeded(at: url)
|
||||
removedURLs.append(url)
|
||||
} catch {
|
||||
failedItems.append(
|
||||
CleanupItemFailure(
|
||||
url: url,
|
||||
errorDescription: error.localizedDescription
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return CleanupReport(
|
||||
removedURLs: removedURLs,
|
||||
failedItems: failedItems,
|
||||
knownInstallBundleURLs: BlitztextInstallLocationService.otherInstalledBundleURLs
|
||||
)
|
||||
}
|
||||
|
||||
private static func removeItemIfNeeded(at url: URL) throws {
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
return
|
||||
}
|
||||
try FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
176
BlitztextMac/Services/BlitztextInstallLocationService.swift
Normal file
@ -0,0 +1,176 @@
|
||||
import AppKit
|
||||
import Foundation
|
||||
|
||||
enum BlitztextInstallLocationService {
|
||||
enum InstallLocation: Equatable {
|
||||
case applications
|
||||
case userApplications
|
||||
case outsideApplications(URL)
|
||||
case unknown
|
||||
|
||||
var isInApplicationsFolder: Bool {
|
||||
switch self {
|
||||
case .applications, .userApplications:
|
||||
return true
|
||||
case .outsideApplications, .unknown:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
var isCanonicalInstall: Bool {
|
||||
if case .applications = self {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
enum MoveError: LocalizedError {
|
||||
case sourceBundleMissing(URL)
|
||||
case destinationUnavailable
|
||||
case destinationExists(URL)
|
||||
case destinationNotWritable(URL)
|
||||
case copyFailed(source: URL, destination: URL, underlying: Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .sourceBundleMissing:
|
||||
return "Die aktuelle App-Installation wurde nicht gefunden."
|
||||
case .destinationUnavailable:
|
||||
return "Der Zielordner /Applications ist nicht verfügbar."
|
||||
case .destinationExists:
|
||||
return "Am Zielort liegt bereits eine Blitztext-Installation."
|
||||
case .destinationNotWritable:
|
||||
return "Der Zielordner /Applications ist auf diesem Mac nicht beschreibbar."
|
||||
case .copyFailed:
|
||||
return "Blitztext konnte nicht nach /Applications kopiert werden."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static var bundleURL: URL {
|
||||
Bundle.main.bundleURL.standardizedFileURL.resolvingSymlinksInPath()
|
||||
}
|
||||
|
||||
static var bundleName: String {
|
||||
bundleURL.deletingPathExtension().lastPathComponent
|
||||
}
|
||||
|
||||
static var currentInstallLocation: InstallLocation {
|
||||
let currentDirectory = bundleURL.deletingLastPathComponent()
|
||||
let standardizedCurrentDirectory = currentDirectory.standardizedFileURL.resolvingSymlinksInPath()
|
||||
|
||||
if standardizedCurrentDirectory == systemApplicationsDirectoryURL.standardizedFileURL.resolvingSymlinksInPath() {
|
||||
return .applications
|
||||
}
|
||||
|
||||
if standardizedCurrentDirectory == userApplicationsDirectoryURL.standardizedFileURL.resolvingSymlinksInPath() {
|
||||
return .userApplications
|
||||
}
|
||||
|
||||
if FileManager.default.fileExists(atPath: bundleURL.path) {
|
||||
return .outsideApplications(bundleURL)
|
||||
}
|
||||
|
||||
return .unknown
|
||||
}
|
||||
|
||||
static var shouldOfferMoveToApplications: Bool {
|
||||
!currentInstallLocation.isCanonicalInstall
|
||||
}
|
||||
|
||||
static var systemApplicationsDirectoryURL: URL {
|
||||
URL(fileURLWithPath: "/Applications", isDirectory: true)
|
||||
}
|
||||
|
||||
static var userApplicationsDirectoryURL: URL {
|
||||
FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Applications", isDirectory: true)
|
||||
}
|
||||
|
||||
static var preferredInstallDirectoryURL: URL? {
|
||||
systemApplicationsDirectoryURL
|
||||
}
|
||||
|
||||
static var preferredInstallBundleURL: URL? {
|
||||
preferredInstallDirectoryURL?.appendingPathComponent(bundleURL.lastPathComponent)
|
||||
}
|
||||
|
||||
static var knownInstallBundleURLs: [URL] {
|
||||
let candidates = [
|
||||
bundleURL,
|
||||
systemApplicationsDirectoryURL.appendingPathComponent(bundleURL.lastPathComponent),
|
||||
userApplicationsDirectoryURL.appendingPathComponent(bundleURL.lastPathComponent)
|
||||
]
|
||||
|
||||
var seen = Set<String>()
|
||||
return candidates.filter { candidate in
|
||||
let key = candidate.standardizedFileURL.resolvingSymlinksInPath().path
|
||||
guard seen.insert(key).inserted else { return false }
|
||||
return FileManager.default.fileExists(atPath: candidate.path)
|
||||
}
|
||||
}
|
||||
|
||||
static var otherInstalledBundleURLs: [URL] {
|
||||
knownInstallBundleURLs.filter { $0 != bundleURL }
|
||||
}
|
||||
|
||||
static func moveCurrentAppToApplications(replacingExisting: Bool = true) throws -> URL {
|
||||
let sourceURL = bundleURL
|
||||
guard FileManager.default.fileExists(atPath: sourceURL.path) else {
|
||||
throw MoveError.sourceBundleMissing(sourceURL)
|
||||
}
|
||||
|
||||
guard let destinationDirectoryURL = preferredInstallDirectoryURL else {
|
||||
throw MoveError.destinationUnavailable
|
||||
}
|
||||
|
||||
let destinationURL = destinationDirectoryURL.appendingPathComponent(sourceURL.lastPathComponent)
|
||||
|
||||
if sourceURL.standardizedFileURL.resolvingSymlinksInPath() == destinationURL.standardizedFileURL.resolvingSymlinksInPath() {
|
||||
return destinationURL
|
||||
}
|
||||
|
||||
try ensureDirectoryExists(at: destinationDirectoryURL)
|
||||
|
||||
if FileManager.default.fileExists(atPath: destinationURL.path) {
|
||||
guard replacingExisting else {
|
||||
throw MoveError.destinationExists(destinationURL)
|
||||
}
|
||||
try FileManager.default.removeItem(at: destinationURL)
|
||||
}
|
||||
|
||||
guard FileManager.default.isWritableFile(atPath: destinationDirectoryURL.path) else {
|
||||
throw MoveError.destinationNotWritable(destinationDirectoryURL)
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.copyItem(at: sourceURL, to: destinationURL)
|
||||
} catch {
|
||||
throw MoveError.copyFailed(source: sourceURL, destination: destinationURL, underlying: error)
|
||||
}
|
||||
|
||||
return destinationURL
|
||||
}
|
||||
|
||||
static func moveToApplicationsAndRelaunch(replacingExisting: Bool = true) throws {
|
||||
let destinationURL = try moveCurrentAppToApplications(replacingExisting: replacingExisting)
|
||||
|
||||
let openProcess = Process()
|
||||
openProcess.executableURL = URL(fileURLWithPath: "/usr/bin/open")
|
||||
openProcess.arguments = [destinationURL.path]
|
||||
try openProcess.run()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
NSApplication.shared.terminate(nil)
|
||||
}
|
||||
}
|
||||
|
||||
private static func ensureDirectoryExists(at url: URL) throws {
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
return
|
||||
}
|
||||
|
||||
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||
}
|
||||
}
|
||||
131
BlitztextMac/Services/HotkeyService.swift
Normal file
@ -0,0 +1,131 @@
|
||||
import Cocoa
|
||||
import Observation
|
||||
|
||||
enum HotkeyMode: String, Codable, CaseIterable, Identifiable {
|
||||
case hold // Tasten halten = aufnehmen, loslassen = stoppen
|
||||
case toggle // Einmal drücken = starten, nochmal/Escape = stoppen
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .hold: return "Halten"
|
||||
case .toggle: return "Drücken"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .hold: return "Tasten halten zum Aufnehmen, loslassen zum Stoppen"
|
||||
case .toggle: return "Einmal drücken zum Starten, nochmal oder Escape zum Stoppen"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum HotkeyEvent {
|
||||
case down(WorkflowType) // Keys pressed
|
||||
case up(WorkflowType) // Keys released (for hold mode)
|
||||
case cancel // Escape pressed
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class HotkeyService {
|
||||
private var globalMonitor: Any?
|
||||
private var localMonitor: Any?
|
||||
private var keyMonitor: Any?
|
||||
private var activeCombo: WorkflowType? // Which combo is currently held
|
||||
|
||||
var onHotkeyEvent: ((HotkeyEvent) -> Void)?
|
||||
|
||||
func start() {
|
||||
globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||||
Task { @MainActor in
|
||||
self?.handleFlags(event)
|
||||
}
|
||||
}
|
||||
localMonitor = NSEvent.addLocalMonitorForEvents(matching: .flagsChanged) { [weak self] event in
|
||||
Task { @MainActor in
|
||||
self?.handleFlags(event)
|
||||
}
|
||||
return event
|
||||
}
|
||||
// Escape key monitor for toggle mode
|
||||
keyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in
|
||||
Task { @MainActor in
|
||||
if event.keyCode == 53 { // Escape
|
||||
self?.handleEscape()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stop() {
|
||||
if let globalMonitor { NSEvent.removeMonitor(globalMonitor) }
|
||||
if let localMonitor { NSEvent.removeMonitor(localMonitor) }
|
||||
if let keyMonitor { NSEvent.removeMonitor(keyMonitor) }
|
||||
globalMonitor = nil
|
||||
localMonitor = nil
|
||||
keyMonitor = nil
|
||||
}
|
||||
|
||||
private func handleFlags(_ event: NSEvent) {
|
||||
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
||||
|
||||
// fn + Shift + Control -> local transcription
|
||||
if flags == [.function, .shift, .control] {
|
||||
if activeCombo == nil {
|
||||
activeCombo = .localTranscription
|
||||
onHotkeyEvent?(.down(.localTranscription))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// fn + Shift -> transcription
|
||||
if flags == [.function, .shift] {
|
||||
if activeCombo == nil {
|
||||
activeCombo = .transcription
|
||||
onHotkeyEvent?(.down(.transcription))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// fn + Control -> Textverbesserer
|
||||
if flags == [.function, .control] {
|
||||
if activeCombo == nil {
|
||||
activeCombo = .textImprover
|
||||
onHotkeyEvent?(.down(.textImprover))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// fn + Option -> Rage Mode
|
||||
if flags == [.function, .option] {
|
||||
if activeCombo == nil {
|
||||
activeCombo = .dampfAblassen
|
||||
onHotkeyEvent?(.down(.dampfAblassen))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// fn + Command -> Emoji Mode
|
||||
if flags == [.function, .command] {
|
||||
if activeCombo == nil {
|
||||
activeCombo = .emojiText
|
||||
onHotkeyEvent?(.down(.emojiText))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Keys released -- fire up event
|
||||
if let combo = activeCombo {
|
||||
activeCombo = nil
|
||||
onHotkeyEvent?(.up(combo))
|
||||
}
|
||||
}
|
||||
|
||||
private func handleEscape() {
|
||||
activeCombo = nil
|
||||
onHotkeyEvent?(.cancel)
|
||||
}
|
||||
}
|
||||
91
BlitztextMac/Services/KeychainService.swift
Normal file
@ -0,0 +1,91 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
enum KeychainKey: String, CaseIterable, Codable {
|
||||
case openAIAPIKey = "openAIAPIKey"
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .openAIAPIKey: return "OpenAI API Key"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stores preview credentials in the user's macOS Keychain.
|
||||
enum KeychainService {
|
||||
private static let service = "app.blitztext.preview.credentials"
|
||||
|
||||
static func save(key: KeychainKey, value: String) throws {
|
||||
let data = Data(value.utf8)
|
||||
var query = baseQuery(for: key)
|
||||
query[kSecValueData as String] = data
|
||||
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
|
||||
|
||||
let status = SecItemAdd(query as CFDictionary, nil)
|
||||
|
||||
if status == errSecDuplicateItem {
|
||||
let updateStatus = SecItemUpdate(
|
||||
baseQuery(for: key) as CFDictionary,
|
||||
[kSecValueData as String: data] as CFDictionary
|
||||
)
|
||||
guard updateStatus == errSecSuccess else {
|
||||
throw KeychainError.saveFailed(updateStatus)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard status == errSecSuccess else {
|
||||
throw KeychainError.saveFailed(status)
|
||||
}
|
||||
}
|
||||
|
||||
static func load(key: KeychainKey) -> String? {
|
||||
var query = baseQuery(for: key)
|
||||
query[kSecMatchLimit as String] = kSecMatchLimitOne
|
||||
query[kSecReturnData as String] = true
|
||||
|
||||
var item: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &item)
|
||||
|
||||
guard status == errSecSuccess,
|
||||
let data = item as? Data,
|
||||
let value = String(data: data, encoding: .utf8),
|
||||
!value.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
static func delete(key: KeychainKey) {
|
||||
SecItemDelete(baseQuery(for: key) as CFDictionary)
|
||||
}
|
||||
|
||||
/// Force the next `load` to re-read credentials.
|
||||
static func invalidateCache() {
|
||||
// Kept for call-site compatibility. Keychain reads do not use an in-memory cache.
|
||||
}
|
||||
|
||||
static var isConfigured: Bool {
|
||||
load(key: .openAIAPIKey) != nil
|
||||
}
|
||||
|
||||
private static func baseQuery(for key: KeychainKey) -> [String: Any] {
|
||||
[
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: service,
|
||||
kSecAttrAccount as String: key.rawValue
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
enum KeychainError: LocalizedError {
|
||||
case saveFailed(OSStatus)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .saveFailed(let status):
|
||||
return "Zugangsdaten konnten nicht im macOS Keychain gespeichert werden. Status: \(status)"
|
||||
}
|
||||
}
|
||||
}
|
||||
209
BlitztextMac/Services/LLMService.swift
Normal file
@ -0,0 +1,209 @@
|
||||
import Foundation
|
||||
|
||||
enum LLMError: LocalizedError {
|
||||
case notConfigured
|
||||
case networkError(String)
|
||||
case apiError(String)
|
||||
case noContent
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notConfigured:
|
||||
return "OpenAI API Key fehlt. Bitte in den Einstellungen hinterlegen."
|
||||
case .networkError(let msg):
|
||||
return "Verbindungsproblem: \(msg)"
|
||||
case .apiError(let msg):
|
||||
return "Fehler von OpenAI: \(msg)"
|
||||
case .noContent:
|
||||
return "Keine Antwort erhalten. Bitte nochmal versuchen."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum RewriteModel: String {
|
||||
case fastEdit = "gpt-4o-mini"
|
||||
case rageMode = "gpt-4o"
|
||||
}
|
||||
|
||||
private struct OpenAIChatRequest: Encodable {
|
||||
struct Message: Encodable {
|
||||
let role: String
|
||||
let content: String
|
||||
}
|
||||
|
||||
let model: String
|
||||
let messages: [Message]
|
||||
let temperature: Double
|
||||
}
|
||||
|
||||
private struct OpenAIChatResponse: Decodable {
|
||||
struct Choice: Decodable {
|
||||
struct Message: Decodable {
|
||||
let content: String?
|
||||
}
|
||||
|
||||
let message: Message?
|
||||
}
|
||||
|
||||
let choices: [Choice]?
|
||||
}
|
||||
|
||||
private struct OpenAIErrorResponse: Decodable {
|
||||
struct APIError: Decodable {
|
||||
let message: String?
|
||||
}
|
||||
|
||||
let error: APIError?
|
||||
}
|
||||
|
||||
enum LLMService {
|
||||
private static let chatCompletionsURL = URL(string: "https://api.openai.com/v1/chat/completions")!
|
||||
|
||||
private static let session: URLSession = {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.waitsForConnectivity = false
|
||||
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
configuration.timeoutIntervalForRequest = 45
|
||||
configuration.timeoutIntervalForResource = 45
|
||||
return URLSession(configuration: configuration)
|
||||
}()
|
||||
|
||||
static func improve(
|
||||
text: String,
|
||||
settings: TextImprovementSettings,
|
||||
model: RewriteModel = .fastEdit
|
||||
) async throws -> String {
|
||||
try await complete(
|
||||
text: text,
|
||||
systemPrompt: buildSystemPrompt(settings: settings),
|
||||
model: model,
|
||||
temperature: 0.3
|
||||
)
|
||||
}
|
||||
|
||||
static func dampfAblassen(
|
||||
text: String,
|
||||
systemPrompt: String,
|
||||
model: RewriteModel = .rageMode
|
||||
) async throws -> String {
|
||||
try await complete(
|
||||
text: text,
|
||||
systemPrompt: systemPrompt,
|
||||
model: model,
|
||||
temperature: 0.4
|
||||
)
|
||||
}
|
||||
|
||||
static func addEmojis(
|
||||
text: String,
|
||||
settings: EmojiTextSettings,
|
||||
model: RewriteModel = .fastEdit
|
||||
) async throws -> String {
|
||||
try await complete(
|
||||
text: text,
|
||||
systemPrompt: buildEmojiSystemPrompt(density: settings.emojiDensity),
|
||||
model: model,
|
||||
temperature: 0.3
|
||||
)
|
||||
}
|
||||
|
||||
private static func complete(
|
||||
text: String,
|
||||
systemPrompt: String,
|
||||
model: RewriteModel,
|
||||
temperature: Double
|
||||
) async throws -> String {
|
||||
guard let apiKey = KeychainService.load(key: .openAIAPIKey) else {
|
||||
throw LLMError.notConfigured
|
||||
}
|
||||
|
||||
let payload = OpenAIChatRequest(
|
||||
model: model.rawValue,
|
||||
messages: [
|
||||
.init(role: "system", content: systemPrompt),
|
||||
.init(role: "user", content: text),
|
||||
],
|
||||
temperature: temperature
|
||||
)
|
||||
|
||||
var request = URLRequest(url: chatCompletionsURL)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.timeoutInterval = 45
|
||||
request.httpBody = try JSONEncoder().encode(payload)
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw LLMError.networkError("Keine gültige Antwort")
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
throw LLMError.apiError(openAIErrorMessage(from: data) ?? "Status \(httpResponse.statusCode)")
|
||||
}
|
||||
|
||||
let result = try JSONDecoder().decode(OpenAIChatResponse.self, from: data)
|
||||
guard let content = result.choices?.first?.message?.content,
|
||||
!content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
|
||||
throw LLMError.noContent
|
||||
}
|
||||
|
||||
return content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func openAIErrorMessage(from data: Data) -> String? {
|
||||
(try? JSONDecoder().decode(OpenAIErrorResponse.self, from: data))?.error?.message
|
||||
}
|
||||
|
||||
private static func buildEmojiSystemPrompt(density: EmojiTextSettings.EmojiDensity) -> String {
|
||||
let densityInstruction: String
|
||||
switch density {
|
||||
case .wenig:
|
||||
densityInstruction = "Setze nur vereinzelt Emojis ein, maximal 1-2 pro Absatz."
|
||||
case .mittel:
|
||||
densityInstruction = "Setze regelmaessig passende Emojis ein, etwa alle 1-2 Saetze."
|
||||
case .viel:
|
||||
densityInstruction = "Setze grosszuegig Emojis ein, gerne mehrere pro Satz."
|
||||
}
|
||||
|
||||
return "Du erhaeltst ein gesprochenes Transkript. Gib den Text moeglichst originalgetreu zurueck, aber fuege passende Emojis ein. \(densityInstruction) Korrigiere offensichtliche Sprach- und Grammatikfehler. Behalte den Stil und die Bedeutung bei. Gib NUR den Text mit Emojis zurueck, keine Erklaerungen."
|
||||
}
|
||||
|
||||
private static func buildSystemPrompt(settings: TextImprovementSettings) -> String {
|
||||
if !settings.systemPrompt.isEmpty {
|
||||
var prompt = settings.systemPrompt
|
||||
if !settings.customTerms.isEmpty {
|
||||
prompt += "\n\nWichtig: Diese Eigennamen und Fachbegriffe muessen exakt so geschrieben werden: \(settings.customTerms.joined(separator: ", "))"
|
||||
}
|
||||
return prompt
|
||||
}
|
||||
|
||||
var prompt = """
|
||||
Du bist ein Lektor und Schreibassistent. Verbessere den folgenden Text:
|
||||
- Korrigiere Rechtschreibung und Grammatik
|
||||
- Verbessere die Formulierung und den Lesefluss
|
||||
- Behalte die urspruengliche Bedeutung bei
|
||||
- Gib NUR den verbesserten Text zurueck, keine Erklaerungen
|
||||
"""
|
||||
|
||||
switch settings.tone {
|
||||
case .formal:
|
||||
prompt += "\n- Verwende einen formellen, professionellen Ton"
|
||||
case .neutral:
|
||||
prompt += "\n- Verwende einen neutralen, klaren Ton"
|
||||
case .casual:
|
||||
prompt += "\n- Verwende einen lockeren, natuerlichen Ton"
|
||||
}
|
||||
|
||||
if !settings.customTerms.isEmpty {
|
||||
prompt += "\n\nWichtig: Diese Eigennamen und Fachbegriffe muessen exakt so geschrieben werden: \(settings.customTerms.joined(separator: ", "))"
|
||||
}
|
||||
|
||||
if !settings.context.isEmpty {
|
||||
prompt += "\n\nKontext: \(settings.context)"
|
||||
}
|
||||
|
||||
return prompt
|
||||
}
|
||||
}
|
||||
55
BlitztextMac/Services/LaunchAtLoginService.swift
Normal file
@ -0,0 +1,55 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import ServiceManagement
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class LaunchAtLoginService {
|
||||
var isEnabled = false
|
||||
var helperText = "Blitztext startet nicht automatisch."
|
||||
var errorText: String?
|
||||
|
||||
init() {
|
||||
refresh()
|
||||
}
|
||||
|
||||
func refresh() {
|
||||
let status = SMAppService.mainApp.status
|
||||
|
||||
switch status {
|
||||
case .enabled:
|
||||
isEnabled = true
|
||||
helperText = "Blitztext startet beim Anmelden automatisch."
|
||||
case .notFound:
|
||||
isEnabled = false
|
||||
helperText = "Blitztext muss in /Applications liegen, damit der Anmeldestart verf\u{00FC}gbar ist."
|
||||
case .requiresApproval:
|
||||
isEnabled = true
|
||||
helperText = "Noch in den Systemeinstellungen freigeben."
|
||||
case .notRegistered:
|
||||
isEnabled = false
|
||||
helperText = "Blitztext startet nicht automatisch."
|
||||
@unknown default:
|
||||
isEnabled = false
|
||||
helperText = "Auf diesem Mac nicht verfügbar."
|
||||
}
|
||||
}
|
||||
|
||||
func setEnabled(_ enabled: Bool) {
|
||||
errorText = nil
|
||||
|
||||
do {
|
||||
if enabled {
|
||||
try SMAppService.mainApp.register()
|
||||
} else {
|
||||
try SMAppService.mainApp.unregister()
|
||||
}
|
||||
refresh()
|
||||
} catch {
|
||||
refresh()
|
||||
errorText = enabled
|
||||
? "Anmeldestart konnte nicht aktiviert werden. Lege Blitztext in /Applications und versuche es erneut."
|
||||
: "Anmeldestart konnte nicht deaktiviert werden. Bitte versuche es erneut."
|
||||
}
|
||||
}
|
||||
}
|
||||
310
BlitztextMac/Services/LocalTranscriptionService.swift
Normal file
@ -0,0 +1,310 @@
|
||||
import Foundation
|
||||
import WhisperKit
|
||||
|
||||
struct LocalTranscriptionModel: Identifiable, Hashable {
|
||||
let id: String
|
||||
let url: URL
|
||||
let isInstalled: Bool
|
||||
|
||||
init(id: String, url: URL, isInstalled: Bool = true) {
|
||||
self.id = id
|
||||
self.url = url
|
||||
self.isInstalled = isInstalled
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
Self.displayName(for: id)
|
||||
}
|
||||
|
||||
var installStateLabel: String {
|
||||
isInstalled ? "Installiert" : "Nicht installiert"
|
||||
}
|
||||
|
||||
var shortDisplayName: String {
|
||||
if id.contains("small") {
|
||||
return "Whisper Small"
|
||||
}
|
||||
if id.contains("base") {
|
||||
return "Whisper Base"
|
||||
}
|
||||
if id.contains("tiny") {
|
||||
return "Whisper Tiny"
|
||||
}
|
||||
if id.contains("turbo") {
|
||||
return "Whisper Turbo"
|
||||
}
|
||||
if id.contains("large-v3") {
|
||||
return "Whisper Large"
|
||||
}
|
||||
return displayName
|
||||
}
|
||||
|
||||
static func displayName(for modelName: String) -> String {
|
||||
if modelName.contains("small") {
|
||||
return "Whisper Small"
|
||||
}
|
||||
if modelName.contains("base") {
|
||||
return "Whisper Base"
|
||||
}
|
||||
if modelName.contains("tiny") {
|
||||
return "Whisper Tiny"
|
||||
}
|
||||
if modelName.contains("turbo") {
|
||||
return "Whisper Large v3 Turbo"
|
||||
}
|
||||
if modelName.contains("large-v3") {
|
||||
return "Whisper Large v3"
|
||||
}
|
||||
return modelName
|
||||
.replacingOccurrences(of: "openai_", with: "")
|
||||
.replacingOccurrences(of: "_", with: " ")
|
||||
.replacingOccurrences(of: "-", with: " ")
|
||||
}
|
||||
}
|
||||
|
||||
enum LocalTranscriptionError: LocalizedError {
|
||||
case modelMissing(URL)
|
||||
case downloadedModelInvalid(String)
|
||||
case noText
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .modelMissing(let url):
|
||||
return "Lokales Modell fehlt: \(url.path)"
|
||||
case .downloadedModelInvalid(let modelName):
|
||||
return "Das geladene Modell ist unvollständig: \(modelName)"
|
||||
case .noText:
|
||||
return "Das lokale Modell hat keinen Text erkannt."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actor LocalTranscriptionService {
|
||||
static let shared = LocalTranscriptionService()
|
||||
|
||||
static let defaultModelName = "openai_whisper-large-v3-v20240930_626MB"
|
||||
static let fastModelName = "openai_whisper-large-v3-v20240930_turbo_632MB"
|
||||
static let recommendedFastModelName = "openai_whisper-small_216MB"
|
||||
static let modelRepo = "argmaxinc/whisperkit-coreml"
|
||||
static let supportedModelNames = [
|
||||
recommendedFastModelName,
|
||||
fastModelName,
|
||||
defaultModelName
|
||||
]
|
||||
static let modelPageURL = URL(
|
||||
string: "https://huggingface.co/argmaxinc/whisperkit-coreml/tree/main/openai_whisper-large-v3-v20240930_626MB"
|
||||
)!
|
||||
static let fastModelPageURL = URL(
|
||||
string: "https://huggingface.co/argmaxinc/whisperkit-coreml/tree/main/openai_whisper-large-v3-v20240930_turbo_632MB"
|
||||
)!
|
||||
static let recommendedFastModelPageURL = URL(
|
||||
string: "https://huggingface.co/argmaxinc/whisperkit-coreml/tree/main/openai_whisper-small_216MB"
|
||||
)!
|
||||
|
||||
static func modelPageURL(for modelName: String) -> URL {
|
||||
switch normalizedModelName(modelName) {
|
||||
case recommendedFastModelName:
|
||||
return recommendedFastModelPageURL
|
||||
case fastModelName:
|
||||
return fastModelPageURL
|
||||
case defaultModelName:
|
||||
return modelPageURL
|
||||
default:
|
||||
return URL(string: "https://huggingface.co/\(modelRepo)/tree/main/\(normalizedModelName(modelName))")!
|
||||
}
|
||||
}
|
||||
|
||||
private var whisperKit: WhisperKit?
|
||||
private var loadedModelName: String?
|
||||
|
||||
static var isModelInstalled: Bool {
|
||||
isModelInstalled(defaultModelName)
|
||||
}
|
||||
|
||||
static func modelURL(named modelName: String) -> URL {
|
||||
AppSupportPaths.whisperKitModelsDirectoryURL.appendingPathComponent(normalizedModelName(modelName), isDirectory: true)
|
||||
}
|
||||
|
||||
static func isModelInstalled(_ modelName: String) -> Bool {
|
||||
isUsableModel(at: modelURL(named: modelName))
|
||||
}
|
||||
|
||||
static func normalizedModelName(_ modelName: String) -> String {
|
||||
let trimmed = modelName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? recommendedFastModelName : trimmed
|
||||
}
|
||||
|
||||
static func installedModels() -> [LocalTranscriptionModel] {
|
||||
let directory = AppSupportPaths.whisperKitModelsDirectoryURL
|
||||
let urls = (try? FileManager.default.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: [.isDirectoryKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
)) ?? []
|
||||
|
||||
return urls
|
||||
.filter { isUsableModel(at: $0) }
|
||||
.map { LocalTranscriptionModel(id: $0.lastPathComponent, url: $0) }
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.id == recommendedFastModelName { return true }
|
||||
if rhs.id == recommendedFastModelName { return false }
|
||||
if lhs.id == fastModelName { return true }
|
||||
if rhs.id == fastModelName { return false }
|
||||
if lhs.id == defaultModelName { return true }
|
||||
if rhs.id == defaultModelName { return false }
|
||||
return lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
static func modelOptions() -> [LocalTranscriptionModel] {
|
||||
var seen = Set<String>()
|
||||
let installed = installedModels()
|
||||
let installedByID = Dictionary(uniqueKeysWithValues: installed.map { ($0.id, $0) })
|
||||
let orderedIDs = supportedModelNames + installed.map(\.id)
|
||||
|
||||
return orderedIDs.compactMap { modelName in
|
||||
let normalizedName = normalizedModelName(modelName)
|
||||
guard seen.insert(normalizedName).inserted else { return nil }
|
||||
|
||||
if let installedModel = installedByID[normalizedName] {
|
||||
return installedModel
|
||||
}
|
||||
|
||||
return LocalTranscriptionModel(
|
||||
id: normalizedName,
|
||||
url: modelURL(named: normalizedName),
|
||||
isInstalled: false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
static func resolvedModelName(_ preferredModelName: String) -> String {
|
||||
let normalizedName = normalizedModelName(preferredModelName)
|
||||
if isModelInstalled(normalizedName) {
|
||||
return normalizedName
|
||||
}
|
||||
|
||||
return installedModels().first?.id ?? normalizedName
|
||||
}
|
||||
|
||||
static func shouldAutoSelectRecommendedFastModel(currentModelName: String) -> Bool {
|
||||
guard isModelInstalled(recommendedFastModelName) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return currentModelName == defaultModelName || currentModelName == fastModelName
|
||||
}
|
||||
|
||||
func prepare(modelName: String) async throws {
|
||||
_ = try await pipeline(modelName: modelName)
|
||||
}
|
||||
|
||||
func downloadAndInstall(
|
||||
modelName: String,
|
||||
progressHandler: @escaping @Sendable (Double) -> Void
|
||||
) async throws -> URL {
|
||||
let normalizedName = Self.normalizedModelName(modelName)
|
||||
let destinationURL = Self.modelURL(named: normalizedName)
|
||||
|
||||
if Self.isUsableModel(at: destinationURL) {
|
||||
progressHandler(1)
|
||||
return destinationURL
|
||||
}
|
||||
|
||||
let fileManager = FileManager.default
|
||||
try fileManager.createDirectory(
|
||||
at: AppSupportPaths.whisperKitModelsDirectoryURL,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
let downloadRoot = AppSupportPaths.localModelsDirectoryURL
|
||||
.appendingPathComponent("downloads", isDirectory: true)
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
try fileManager.createDirectory(at: downloadRoot, withIntermediateDirectories: true)
|
||||
|
||||
do {
|
||||
let downloadedURL = try await WhisperKit.download(
|
||||
variant: normalizedName,
|
||||
downloadBase: downloadRoot,
|
||||
from: Self.modelRepo
|
||||
) { progress in
|
||||
let fraction = progress.fractionCompleted
|
||||
progressHandler(fraction.isFinite ? fraction : 0)
|
||||
}
|
||||
|
||||
guard Self.isUsableModel(at: downloadedURL) else {
|
||||
throw LocalTranscriptionError.downloadedModelInvalid(normalizedName)
|
||||
}
|
||||
|
||||
if fileManager.fileExists(atPath: destinationURL.path) {
|
||||
try fileManager.removeItem(at: destinationURL)
|
||||
}
|
||||
try fileManager.moveItem(at: downloadedURL, to: destinationURL)
|
||||
try? fileManager.removeItem(at: downloadRoot)
|
||||
|
||||
if loadedModelName == normalizedName {
|
||||
whisperKit = nil
|
||||
loadedModelName = nil
|
||||
}
|
||||
|
||||
progressHandler(1)
|
||||
return destinationURL
|
||||
} catch {
|
||||
try? fileManager.removeItem(at: downloadRoot)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func transcribe(audioURL: URL, language: String, modelName: String) async throws -> String {
|
||||
let resolvedLanguage = language.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let decodeOptions = DecodingOptions(
|
||||
task: .transcribe,
|
||||
language: resolvedLanguage.isEmpty ? nil : resolvedLanguage
|
||||
)
|
||||
|
||||
let pipeline = try await pipeline(modelName: modelName)
|
||||
let results = try await pipeline.transcribe(
|
||||
audioPath: audioURL.path,
|
||||
decodeOptions: decodeOptions
|
||||
)
|
||||
let text = results
|
||||
.map(\.text)
|
||||
.joined(separator: " ")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
guard !text.isEmpty else {
|
||||
throw LocalTranscriptionError.noText
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
private func pipeline(modelName: String) async throws -> WhisperKit {
|
||||
let resolvedModelName = Self.resolvedModelName(modelName)
|
||||
if let whisperKit, loadedModelName == resolvedModelName {
|
||||
return whisperKit
|
||||
}
|
||||
|
||||
let url = Self.modelURL(named: resolvedModelName)
|
||||
guard Self.isUsableModel(at: url) else {
|
||||
throw LocalTranscriptionError.modelMissing(url)
|
||||
}
|
||||
|
||||
let loaded = try await WhisperKit(
|
||||
modelFolder: url.path,
|
||||
verbose: false,
|
||||
prewarm: true,
|
||||
load: true,
|
||||
download: false
|
||||
)
|
||||
whisperKit = loaded
|
||||
loadedModelName = resolvedModelName
|
||||
return loaded
|
||||
}
|
||||
|
||||
private static func isUsableModel(at url: URL) -> Bool {
|
||||
FileManager.default.fileExists(atPath: url.appendingPathComponent("AudioEncoder.mlmodelc").path) &&
|
||||
FileManager.default.fileExists(atPath: url.appendingPathComponent("MelSpectrogram.mlmodelc").path) &&
|
||||
FileManager.default.fileExists(atPath: url.appendingPathComponent("TextDecoder.mlmodelc").path)
|
||||
}
|
||||
}
|
||||
35
BlitztextMac/Services/TranscriptionQualityService.swift
Normal file
@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
enum TranscriptionQualityService {
|
||||
static let minimumRecordingDuration: TimeInterval = 0.3
|
||||
|
||||
static func shouldRejectRecording(duration: TimeInterval) -> Bool {
|
||||
duration < minimumRecordingDuration
|
||||
}
|
||||
|
||||
static func cleanedTranscript(_ text: String) -> String {
|
||||
text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
static func isLikelyArtifact(_ text: String, recordingDuration: TimeInterval) -> Bool {
|
||||
let cleaned = cleanedTranscript(text)
|
||||
guard !cleaned.isEmpty else { return true }
|
||||
|
||||
let words = cleaned.split { $0.isWhitespace || $0.isNewline }
|
||||
let letters = cleaned.unicodeScalars.filter { CharacterSet.letters.contains($0) }.count
|
||||
|
||||
if letters == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
if recordingDuration < 0.55 && (words.count >= 5 || cleaned.count >= 32) {
|
||||
return true
|
||||
}
|
||||
|
||||
if recordingDuration < 0.8 && cleaned.count >= 56 {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
135
BlitztextMac/Services/TranscriptionService.swift
Normal file
@ -0,0 +1,135 @@
|
||||
import Foundation
|
||||
|
||||
enum TranscriptionError: LocalizedError {
|
||||
case noFile
|
||||
case notConfigured
|
||||
case networkError(String)
|
||||
case apiError(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noFile:
|
||||
return "Keine Audio-Datei gefunden"
|
||||
case .notConfigured:
|
||||
return "OpenAI API Key fehlt. Bitte in den Einstellungen hinterlegen."
|
||||
case .networkError(let msg):
|
||||
return "Netzwerkfehler: \(msg)"
|
||||
case .apiError(let msg):
|
||||
return "OpenAI-Fehler: \(msg)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TranscriptionOpenAIErrorResponse: Decodable {
|
||||
struct APIError: Decodable {
|
||||
let message: String?
|
||||
}
|
||||
|
||||
let error: APIError?
|
||||
}
|
||||
|
||||
enum TranscriptionService {
|
||||
private static let remoteModel = "whisper-1"
|
||||
private static let transcriptionsURL = URL(string: "https://api.openai.com/v1/audio/transcriptions")!
|
||||
|
||||
private static let session: URLSession = {
|
||||
let configuration = URLSessionConfiguration.ephemeral
|
||||
configuration.waitsForConnectivity = false
|
||||
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
||||
configuration.timeoutIntervalForRequest = 60
|
||||
configuration.timeoutIntervalForResource = 60
|
||||
return URLSession(configuration: configuration)
|
||||
}()
|
||||
|
||||
static func transcribe(
|
||||
audioURL: URL,
|
||||
customTerms: [String] = [],
|
||||
language: String? = nil
|
||||
) async throws -> String {
|
||||
guard let apiKey = KeychainService.load(key: .openAIAPIKey) else {
|
||||
throw TranscriptionError.notConfigured
|
||||
}
|
||||
|
||||
return try await Task.detached(priority: .userInitiated) {
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: audioURL)
|
||||
}
|
||||
|
||||
let boundary = UUID().uuidString
|
||||
var request = URLRequest(url: transcriptionsURL)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("text/plain, application/json", forHTTPHeaderField: "Accept")
|
||||
request.timeoutInterval = 60
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
|
||||
let audioData = try Data(contentsOf: audioURL, options: [.mappedIfSafe])
|
||||
|
||||
var body = Data()
|
||||
body.append("--\(boundary)\r\n")
|
||||
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"audio.m4a\"\r\n")
|
||||
body.append("Content-Type: audio/m4a\r\n\r\n")
|
||||
body.append(audioData)
|
||||
body.append("\r\n")
|
||||
|
||||
body.append("--\(boundary)\r\n")
|
||||
body.append("Content-Disposition: form-data; name=\"model\"\r\n\r\n")
|
||||
body.append(remoteModel)
|
||||
body.append("\r\n")
|
||||
|
||||
body.append("--\(boundary)\r\n")
|
||||
body.append("Content-Disposition: form-data; name=\"response_format\"\r\n\r\n")
|
||||
body.append("text")
|
||||
body.append("\r\n")
|
||||
|
||||
if !customTerms.isEmpty {
|
||||
let prompt = "Eigennamen und Begriffe: \(customTerms.joined(separator: ", "))"
|
||||
body.append("--\(boundary)\r\n")
|
||||
body.append("Content-Disposition: form-data; name=\"prompt\"\r\n\r\n")
|
||||
body.append(prompt)
|
||||
body.append("\r\n")
|
||||
}
|
||||
|
||||
if let language, !language.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
body.append("--\(boundary)\r\n")
|
||||
body.append("Content-Disposition: form-data; name=\"language\"\r\n\r\n")
|
||||
body.append(language.trimmingCharacters(in: .whitespacesAndNewlines))
|
||||
body.append("\r\n")
|
||||
}
|
||||
|
||||
body.append("--\(boundary)--\r\n")
|
||||
request.httpBody = body
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
throw TranscriptionError.networkError("Ungueltige Antwort")
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
throw TranscriptionError.apiError(openAIErrorMessage(from: data) ?? "Status \(httpResponse.statusCode)")
|
||||
}
|
||||
|
||||
guard let text = String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!text.isEmpty else {
|
||||
throw TranscriptionError.apiError("Transkription fehlgeschlagen")
|
||||
}
|
||||
|
||||
return text
|
||||
}.value
|
||||
}
|
||||
|
||||
private static func openAIErrorMessage(from data: Data) -> String? {
|
||||
(try? JSONDecoder().decode(TranscriptionOpenAIErrorResponse.self, from: data))?.error?.message
|
||||
}
|
||||
}
|
||||
|
||||
private extension Data {
|
||||
mutating func append(_ string: String) {
|
||||
if let data = string.data(using: .utf8) {
|
||||
append(data)
|
||||
}
|
||||
}
|
||||
}
|
||||
96
BlitztextMac/Views/WaveformView.swift
Normal file
@ -0,0 +1,96 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
|
||||
/// Manages waveform bar levels and an internal display timer.
|
||||
/// Lives as a reference type so the Timer closure always reads fresh state.
|
||||
@MainActor
|
||||
final class WaveformState: ObservableObject {
|
||||
@Published var levels: [CGFloat] = Array(repeating: 0.03, count: 40)
|
||||
|
||||
/// The current audio level fed from the parent -- updated on every
|
||||
/// SwiftUI body evaluation so the timer always has the latest value.
|
||||
var currentAudioLevel: Float = 0
|
||||
|
||||
private var phase: Double = 0
|
||||
private var timer: Timer?
|
||||
|
||||
func startTimer() {
|
||||
guard timer == nil else { return }
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.tick()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopTimer() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
}
|
||||
|
||||
func reset() {
|
||||
levels = Array(repeating: 0.03, count: 40)
|
||||
phase = 0
|
||||
}
|
||||
|
||||
private func tick() {
|
||||
phase += 0.15
|
||||
let base = CGFloat(currentAudioLevel)
|
||||
levels.removeFirst()
|
||||
let jitter = CGFloat.random(in: -0.06...0.06)
|
||||
let breathe = sin(phase) * 0.03
|
||||
let newLevel = max(0.03, min(1.0, base + jitter + breathe))
|
||||
levels.append(newLevel)
|
||||
}
|
||||
|
||||
deinit {
|
||||
timer?.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
struct WaveformView: View {
|
||||
var audioLevel: Float
|
||||
var isRecording: Bool
|
||||
var accentColor: Color = .primary
|
||||
|
||||
@StateObject private var state = WaveformState()
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 2) {
|
||||
ForEach(Array(state.levels.enumerated()), id: \.offset) { _, level in
|
||||
Capsule()
|
||||
.fill(barColor(for: level))
|
||||
.frame(width: 2.5, height: max(2, level * 40))
|
||||
}
|
||||
}
|
||||
.frame(height: 40)
|
||||
.onChange(of: audioLevel) { _, newLevel in
|
||||
state.currentAudioLevel = newLevel
|
||||
}
|
||||
.onChange(of: isRecording) { _, recording in
|
||||
if recording {
|
||||
state.currentAudioLevel = audioLevel
|
||||
state.startTimer()
|
||||
} else {
|
||||
state.stopTimer()
|
||||
withAnimation(.easeOut(duration: 0.4)) {
|
||||
state.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
state.currentAudioLevel = audioLevel
|
||||
if isRecording {
|
||||
state.startTimer()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
state.stopTimer()
|
||||
}
|
||||
}
|
||||
|
||||
private func barColor(for level: CGFloat) -> Color {
|
||||
let opacity = 0.25 + Double(level) * 0.75
|
||||
return accentColor.opacity(opacity)
|
||||
}
|
||||
}
|
||||
56
BlitztextMac/project.yml
Normal file
@ -0,0 +1,56 @@
|
||||
name: BlitztextMac
|
||||
options:
|
||||
bundleIdPrefix: app.blitztext
|
||||
deploymentTarget:
|
||||
macOS: "14.0"
|
||||
xcodeVersion: "16.0"
|
||||
generateEmptyDirectories: true
|
||||
|
||||
settings:
|
||||
base:
|
||||
SWIFT_VERSION: "5.10"
|
||||
MACOSX_DEPLOYMENT_TARGET: "14.0"
|
||||
ONLY_ACTIVE_ARCH: NO
|
||||
MARKETING_VERSION: "1.5"
|
||||
CURRENT_PROJECT_VERSION: "15"
|
||||
|
||||
packages:
|
||||
ArgmaxOSS:
|
||||
url: https://github.com/argmaxinc/argmax-oss-swift.git
|
||||
exactVersion: 0.18.0
|
||||
|
||||
targets:
|
||||
BlitztextMac:
|
||||
type: application
|
||||
platform: macOS
|
||||
sources:
|
||||
- path: App
|
||||
- path: Features
|
||||
- path: Services
|
||||
- path: Views
|
||||
- path: Resources
|
||||
buildPhase: resources
|
||||
excludes:
|
||||
- AppIcon.icns
|
||||
- Info.plist
|
||||
- BlitztextMac.entitlements
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: app.blitztext.mac
|
||||
PRODUCT_NAME: Blitztext
|
||||
INFOPLIST_FILE: Resources/Info.plist
|
||||
INFOPLIST_KEY_CFBundleShortVersionString: $(MARKETING_VERSION)
|
||||
INFOPLIST_KEY_CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
CODE_SIGN_ENTITLEMENTS: Resources/BlitztextMac.entitlements
|
||||
ENABLE_HARDENED_RUNTIME: true
|
||||
COMBINE_HIDPI_IMAGES: true
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
dependencies:
|
||||
- package: ArgmaxOSS
|
||||
product: WhisperKit
|
||||
entitlements:
|
||||
path: Resources/BlitztextMac.entitlements
|
||||
properties:
|
||||
com.apple.security.app-sandbox: false
|
||||
com.apple.security.device.audio-input: true
|
||||
com.apple.security.network.client: true
|
||||
@ -1,22 +1,17 @@
|
||||
# Contributing
|
||||
|
||||
Thanks for taking a look at Blitztext App Linux.
|
||||
Thanks for taking a look at Blitztext macOS Preview.
|
||||
|
||||
This repository is intentionally a preview. Contributions should make it easier to learn from, build, fork, test, or safely extend.
|
||||
|
||||
## Inspiration
|
||||
|
||||
This Linux app is inspired by [cmagnussen/blitztext-app](https://github.com/cmagnussen/blitztext-app). Please keep that credit intact when changing project-facing docs.
|
||||
This repository is intentionally a preview. Contributions should make it easier to learn from, build, fork, or safely extend.
|
||||
|
||||
## Good First Contributions
|
||||
|
||||
- improve Linux setup instructions
|
||||
- add current Linux screenshots
|
||||
- improve build instructions
|
||||
- fix confusing UI text
|
||||
- improve error messages
|
||||
- add tests around config parsing, routing, quality filters, and streaming URL handling
|
||||
- document known-good STT or LLM engine configs
|
||||
- simplify packaging and first-run setup
|
||||
- add tests around parsing or quality filters
|
||||
- document local model experiments
|
||||
- simplify setup
|
||||
|
||||
## Before Opening A Pull Request
|
||||
|
||||
@ -32,33 +27,24 @@ Keep changes small when possible. Avoid unrelated cleanup in the same PR.
|
||||
## Local Build
|
||||
|
||||
```bash
|
||||
cd linux
|
||||
./install.sh
|
||||
.venv/bin/python -m blitztext gui
|
||||
```
|
||||
|
||||
Package build:
|
||||
|
||||
```bash
|
||||
cd linux
|
||||
bash packaging/build-deb.sh
|
||||
./build.sh --debug
|
||||
```
|
||||
|
||||
## Security And Privacy
|
||||
|
||||
- Never commit API keys, tokens, private audio, confidential transcripts, or private endpoint URLs.
|
||||
- Never commit API keys, tokens, private audio, or confidential transcripts.
|
||||
- Avoid adding telemetry, hosted services, or external dependencies without a clear issue first.
|
||||
- Call out privacy-impacting changes in the pull request description.
|
||||
- Keep the preview honest: do not describe remote STT or rewrite workflows as offline or local.
|
||||
- Keep the preview honest: do not describe remote OpenAI workflows as offline or local.
|
||||
|
||||
## Project Boundaries
|
||||
|
||||
This preview currently does not include:
|
||||
|
||||
- other platforms
|
||||
- a hosted backend
|
||||
- production support
|
||||
- bundled STT model files
|
||||
- guaranteed Wayland support
|
||||
- local text rewriting unless the user configures a local OpenAI-compatible LLM endpoint
|
||||
- packaged releases
|
||||
- bundled local model files
|
||||
- local text rewriting
|
||||
|
||||
Those can be discussed in issues, but please keep PRs focused unless a maintainer agrees on a larger direction first.
|
||||
Those can be discussed in issues, but please keep PRs focused on the current macOS preview unless a maintainer agrees on a larger direction first.
|
||||
|
||||
331
MANUAL.md
@ -1,331 +0,0 @@
|
||||
# Blitztext — User Manual
|
||||
|
||||
A reference for every setting in the Blitztext **Settings** window, page by page.
|
||||
|
||||
Open Settings from the system-tray menu (**Settings…**) or the control panel. The
|
||||
sidebar lists all pages: **Presets · General · Keyboard · Wakeword · STT Engines ·
|
||||
LLM Engines · Benchmark — STT · Benchmark — Wakeword · Log · Manual · About**.
|
||||
Three buttons run along the top: **Save**, **Save & Restart**, and **✕ Close**.
|
||||
|
||||
> **Where settings are stored:** `~/.config/blitztext/config.toml`
|
||||
> (or `$XDG_CONFIG_HOME/blitztext/config.toml`). You can edit that file directly;
|
||||
> the relevant TOML key is noted next to each setting below.
|
||||
|
||||
### Saving your changes
|
||||
|
||||
| Button | What it does |
|
||||
|---|---|
|
||||
| **Close** | Discard and close. Nothing is written. |
|
||||
| **Save** | Write `config.toml`. A note reminds you that **engine/hotkey changes need a restart** to take effect. |
|
||||
| **Save & Restart** | Write `config.toml` and immediately relaunch Blitztext (`blitztext tray`) so every change applies. Use this after changing engines, hotkeys, or the wakeword. |
|
||||
|
||||
---
|
||||
|
||||
## Presets
|
||||
|
||||
<p align="center">
|
||||
<a href="Screenshots/settings-presets.png"><img src="Screenshots/settings-presets.png" alt="Presets page" width="100%"></a>
|
||||
</p>
|
||||
|
||||
Presets are your dictation **actions**. Each one either types what you say, or
|
||||
rewrites it through the language model first (e.g. into a polished email). Trigger
|
||||
a preset by speaking its keyword, or with an optional keyboard shortcut.
|
||||
|
||||
Use the dropdown at the top to pick a preset to edit, **+ Add** to create one, or
|
||||
**Delete** to remove it (you must keep at least one). Each preset maps to a
|
||||
`[[workflow]]` entry in the config.
|
||||
|
||||
| Setting | TOML key | Description |
|
||||
|---|---|---|
|
||||
| **Name** | `name` | Short name for the action, shown in the main panel. |
|
||||
| **Icon (emoji)** | `icon` | Emoji shown next to this preset in the "matched preset" notification — give each a distinct one to tell them apart at a glance. Default `⚡`. |
|
||||
| **Description** | `description` | One line explaining what the preset does (shown in the panel). |
|
||||
| **Keywords (comma)** | `keywords` | Spoken trigger words, comma-separated. Say one at the **start or end** of your speech to select this preset (fuzzy-matched, e.g. `nicer email, bessere email`). The preset's **name is always an implicit trigger**, so it works by voice even with no keywords here; add keywords for alternate/foreign-language phrasings. |
|
||||
| **Hotkey (optional)** | `hotkey` | A direct keyboard shortcut for this preset. Click **Set** and press the combo, or type it (e.g. `<ctrl>+<alt>+e`). Leave blank for keyword-only. |
|
||||
| **Mode** | `mode` | `transcribe` types your words as-is · `rewrite` sends them to the language model first · `stream` shows live text from a realtime STT engine. |
|
||||
| **LLM model (opt.)** | `model` | Override the language model for *this preset only*. Blank = use the active LLM engine's model. |
|
||||
| **Temperature (opt.)** | `temperature` | Creativity of the rewrite, `0`–`1`. Lower is more predictable. Blank = engine default. |
|
||||
| **Prompt sent to the LLM** | `prompt` | The instruction used in `rewrite` mode (e.g. "Rewrite this as a polite, professional email"). Ignored in `transcribe`/`stream` mode. |
|
||||
|
||||
---
|
||||
|
||||
## General
|
||||
|
||||
<p align="center">
|
||||
<a href="Screenshots/settings-general.png"><img src="Screenshots/settings-general.png" alt="General page" width="100%"></a>
|
||||
</p>
|
||||
|
||||
Microphone, text delivery, language, notifications, the on-screen overlay, and
|
||||
autostart.
|
||||
|
||||
| Setting | TOML key | Description |
|
||||
|---|---|---|
|
||||
| **Microphone** | `mic` | Which input device Blitztext records from. |
|
||||
| **Input level** | — | Live level bar (read-only); should move when you speak. |
|
||||
| **Output** | `output` | `type` types the text key-by-key · `paste` copies it and presses Ctrl+V (faster for long text). |
|
||||
| **Language hint** | `language` | Spoken-language code (`de`, `en`, …). Blank = auto-detect. |
|
||||
| **Notifications** | `notify` | Show desktop notifications for recording/transcription status and errors (manual sessions). |
|
||||
| **Announce matched preset** | `notify_routing` | After a voice command, pop a notification showing which preset (and spoken keyword) matched — shown **even for hands-free** sessions, with the preset's emoji. Only fires on a real match. |
|
||||
| **Visual overlay** | `overlay_enabled` | Show a translucent bubble at the cursor while you dictate — a pulsing **microphone**, a **live waveform** of your mic level, and the **recognised text** (word-by-word with a streaming engine, or the final result as a brief confirmation). The tail points at where the text lands, and it gives **hands-free** sessions visible feedback. Click-through; never takes focus. *(X11 only.)* |
|
||||
| **Launch on login** | *(autostart file)* | Start Blitztext automatically when you log in (writes a desktop autostart entry, not `config.toml`). |
|
||||
|
||||
---
|
||||
|
||||
## Keyboard
|
||||
|
||||
<p align="center">
|
||||
<a href="Screenshots/settings-keyboard.png"><img src="Screenshots/settings-keyboard.png" alt="Keyboard page" width="100%"></a>
|
||||
</p>
|
||||
|
||||
Controls **how you start and stop** dictating with the keyboard, the noise filter, and audio cues.
|
||||
|
||||
### Input mode & keys
|
||||
|
||||
All keys live in the `[input]` section.
|
||||
|
||||
| Setting | TOML key | Description |
|
||||
|---|---|---|
|
||||
| **Input mode** | `mode` | `modifiers`: hold/press the keys below · `hotkeys`: each preset uses its own shortcut combo (set per preset). |
|
||||
| **Push-to-talk** | `push_to_talk` | When on, recording lasts only while the Start key is **held** (release to stop). When off, the keys **toggle** recording. |
|
||||
| **Start** | `start` | Key(s) to start recording. Default `<ctrl>+<cmd>` (Ctrl + Windows key). |
|
||||
| **Stop + paste** | `stop` | Stop recording and deliver the text. Default `<ctrl>`. |
|
||||
| **Stop + paste + Enter** | `send` | Stop, deliver, then press Enter (e.g. to send a chat message). Default `<alt>`. |
|
||||
| **Cancel** | `cancel` | Discard the current recording. Default `<esc>`. |
|
||||
|
||||
Click **Set** next to a key field and press the combination to rebind it.
|
||||
|
||||
### Quality gate
|
||||
|
||||
Filters out clips that aren't real speech before they're transcribed. Keys live
|
||||
in the `[quality]` section.
|
||||
|
||||
| Setting | TOML key | Description |
|
||||
|---|---|---|
|
||||
| **Min seconds** | `min_speech_seconds` | Minimum audio length; shorter clips are ignored. Default `0.4`. |
|
||||
| **Silence RMS** | `silence_rms` | Microphone-volume threshold below which a clip counts as silent and is dropped. Default `150.0`. |
|
||||
| **Reject hallucinations** | `reject_hallucinations` | Drop STT "ghost" outputs like *"Thank you."* / *"Bye."* that Whisper invents from silence. |
|
||||
| **Strip trailing punctuation** | `strip_trailing_punctuation` | Remove ending periods from delivered text — handy for code insertion. |
|
||||
|
||||
### Audio cues (manual dictation)
|
||||
|
||||
These control the chimes for **manual** (keyboard/hotkey) dictation only. The
|
||||
hands-free wakeword sounds are **separate and independent** (see Wakeword page).
|
||||
|
||||
| Setting | TOML key | Description |
|
||||
|---|---|---|
|
||||
| **Play audio cues** | `[sounds] enabled` | On/off for the **manual** start/stop chimes below. Does **not** affect the wakeword sounds. |
|
||||
| **Play before** | `[sounds] before` | Chime when recording **starts** (manual dictation). Empty = built-in system sound. |
|
||||
| **Play after** | `[sounds] after` | Chime when recording **stops** (paste, paste+Enter, or auto-stop on silence). Empty = built-in system sound. |
|
||||
|
||||
> Each sound row has ▶ (preview) and ⌫ (clear).
|
||||
|
||||
---
|
||||
|
||||
## Wakeword
|
||||
|
||||
<p align="center">
|
||||
<a href="Screenshots/settings-wakeword.png"><img src="Screenshots/settings-wakeword.png" alt="Wakeword page" width="100%"></a>
|
||||
</p>
|
||||
|
||||
Start dictation with a spoken keyword via an external
|
||||
[Wyoming](https://github.com/rhasspy/wyoming) openWakeWord server. Maps to the
|
||||
`[wakeword]` section.
|
||||
|
||||
| Setting | TOML key | Description |
|
||||
|---|---|---|
|
||||
| **Enable wakeword** | `enabled` | Turn hands-free detection on/off. |
|
||||
| **Wyoming URI** | `uri` | Address of the wakeword server. Default `tcp://127.0.0.1:10400`. The ⟳ button loads the available models from it. |
|
||||
| **Model name** | `model` | Which wake model to listen for (e.g. `computer`, `okay_computer`). Pick from the list loaded from the server. |
|
||||
| **Input level** | — | Live mic level bar (read-only) so you can confirm the microphone is being heard. |
|
||||
| **Test Wakeword** | — | Listens for 10 s and reports whether the wake word was detected. |
|
||||
| **Silence to stop (s)** | `silence_seconds` | After the wakeword starts recording, end it this many seconds after you stop speaking. Hands-free auto-stop (the wakeword can't be released like a key). Default `2.0`. |
|
||||
| **Sound: detected** | `sound_detected` | WAV/OGA played the instant the wake word fires and recording starts — your "speak now" cue (**hands-free sessions only**). **Empty = no sound.** Independent of the *Play audio cues* switch. |
|
||||
| **Sound: captured** | `sound_done` | Played when your spoken command is captured and recording stops (silence/stop) (**hands-free sessions only**). **Empty = no sound.** |
|
||||
|
||||
> **Tip:** A hands-free session suppresses desktop notifications, so these sounds
|
||||
> are its *only* feedback — that's why they're independent of the manual "Play
|
||||
> audio cues" switch, and why an empty field means silence (not a system chime).
|
||||
> You can also pause/resume detection from the tray ("Pause wakeword"), which
|
||||
> toggles the `/tmp/wake_muted` flag.
|
||||
|
||||
> **The two sound pairs differ by trigger *and* by empty-behaviour:**
|
||||
>
|
||||
> | | Plays on | Used for | When empty |
|
||||
> |---|---|---|---|
|
||||
> | *Sound: detected / captured* | start / stop | **hands-free wakeword** only | **silent** |
|
||||
> | *Play before / after* | start / stop | **manual** (keyboard) only | **system chime** |
|
||||
|
||||
---
|
||||
|
||||
## STT Engines
|
||||
|
||||
<p align="center">
|
||||
<a href="Screenshots/settings-stt-engines.png"><img src="Screenshots/settings-stt-engines.png" alt="STT Engines page" width="100%"></a>
|
||||
</p>
|
||||
|
||||
The **speech-to-text** engine turns your voice into text. Each engine can run
|
||||
locally or on a server. A **green dot** means it's reachable, **red** means
|
||||
offline. The active engine is the one selected in the top dropdown.
|
||||
|
||||
Buttons: **+ Add** (batch/cloud/OpenAI-style), **+ Stream** (realtime Riva/NIM),
|
||||
**Delete**, **Test** (records 4 s and transcribes), **Refresh** (re-check status).
|
||||
Each engine maps to a `[[stt_engine]]` entry; the active one is `[stt] active`.
|
||||
|
||||
| Setting | TOML key | Description |
|
||||
|---|---|---|
|
||||
| **Name** | `name` | A label for this engine (e.g. "faster-whisper GPU"). |
|
||||
| **Type** | `type` | `local` (in-process faster-whisper) · `openai` (any OpenAI-compatible `/v1` STT server) · `riva_realtime` (live streaming engine). |
|
||||
| **URL** | `url` | Server endpoint. Example: `http://localhost:8010/v1` · realtime: `http://localhost:8006/v1`. Ignored for `local`. |
|
||||
| **Model** | `model` | Model name. For `local`: `tiny`/`base`/`small`/`medium`/`large-v3` or a path. For remote: blank = server default, or pick from the searchable list fetched from the URL. |
|
||||
| **API key env** | `api_key_env` | *Name of the environment variable* holding the API key (e.g. `GROQ_API_KEY`). Optional. |
|
||||
|
||||
**Local engine (faster-whisper) — device & precision** (global, `[whisper]`):
|
||||
|
||||
| Setting | TOML key | Description |
|
||||
|---|---|---|
|
||||
| **Device** | `device` | `auto` (try CUDA, fall back to CPU) · `cpu` · `cuda`. |
|
||||
| **Compute type** | `compute_type` | `auto` · `int8` · `float16` · `int8_float16`. Lower precision is faster and uses less memory. |
|
||||
|
||||
---
|
||||
|
||||
## LLM Engines
|
||||
|
||||
<p align="center">
|
||||
<a href="Screenshots/settings-llm-engines.png"><img src="Screenshots/settings-llm-engines.png" alt="LLM Engines page" width="100%"></a>
|
||||
</p>
|
||||
|
||||
The **language model** rewrites your dictated text (e.g. into a polished email).
|
||||
Each engine can be a local LAN server or a cloud service. The active engine is
|
||||
the one selected in the top dropdown.
|
||||
|
||||
Buttons: **+ Add**, **Delete**, **Refresh**. Each maps to a `[[llm_engine]]`
|
||||
entry; the active one is `[llm] active`.
|
||||
|
||||
| Setting | TOML key | Description |
|
||||
|---|---|---|
|
||||
| **Name** | `name` | A label for this LLM (e.g. "Local Qwen"). |
|
||||
| **Type** | `type` | `local` (a server on this machine) or `cloud`. |
|
||||
| **Base URL** | `url` | OpenAI-compatible endpoint, e.g. `http://localhost:28080/v1` or `https://api.openai.com/v1`. |
|
||||
| **Model** | `model` | The model to use; pick from the list once the URL is set. |
|
||||
| **API key env** | `api_key_env` | Environment-variable name holding the key (e.g. `OPENAI_API_KEY`). Blank for local servers. |
|
||||
| **Temperature** | `temperature` | Default creativity for rewrites (e.g. `0.3`). Presets can override this. |
|
||||
|
||||
---
|
||||
|
||||
## Benchmark — STT
|
||||
|
||||
<p align="center">
|
||||
<a href="Screenshots/settings-benchmark-stt.png"><img src="Screenshots/settings-benchmark-stt.png" alt="Benchmark — STT page" width="100%"></a>
|
||||
</p>
|
||||
|
||||
Compare your STT engines for **speed and accuracy** on the same clip. Add an
|
||||
engine in the STT Engines page for each model you want to compare. No persistent
|
||||
settings — it's a one-off tool.
|
||||
|
||||
1. **Audio (.wav)** — a recording to transcribe.
|
||||
2. **Reference (.txt)** — a text file with *exactly* what is said. (Auto-filled if
|
||||
a matching `*.txt` / `*.reference.txt` sits next to the WAV.)
|
||||
3. **Run benchmark** — fills the table with one row per engine.
|
||||
|
||||
Result columns:
|
||||
|
||||
| Column | Description |
|
||||
|---|---|
|
||||
| **Engine** | Engine preset name |
|
||||
| **URL** | Server address (blank for local) |
|
||||
| **Model** | Model name used |
|
||||
| **Device** | `CPU`, `CUDA`, or `remote` |
|
||||
| **Best for** | `Short clips` · `Short / medium` · `Long / batch` · `Streaming` |
|
||||
| **Lang** | Supported languages from the server's `/v1/models` (`—` if unknown) |
|
||||
| **Time (s)** | Wall-clock seconds for this transcription |
|
||||
| **Accuracy** | `1 − WER` × 100 %. 100 % = word-perfect, case-sensitive |
|
||||
| **RAM (MB)** | RSS increase while the engine ran. Captures model load cost on first run. Remote engines show `—`. |
|
||||
| **Output** | Transcribed text (hover for full error on failure) |
|
||||
|
||||
A summary line names the **fastest** and **most accurate** engine. Click any column header to sort.
|
||||
|
||||
---
|
||||
|
||||
## Benchmark — Wakeword
|
||||
|
||||
<p align="center">
|
||||
<a href="Screenshots/settings-benchmark-wakeword.png"><img src="Screenshots/settings-benchmark-wakeword.png" alt="Benchmark — Wakeword page" width="100%"></a>
|
||||
</p>
|
||||
|
||||
Stress-test your wakeword detection by generating speech with a TTS server and
|
||||
checking whether the wake word fires correctly. Reports **recall** (how often it
|
||||
fires when it should) and **false-fire rate** (how often it fires on non-wake
|
||||
speech) across multiple synthetic voices.
|
||||
|
||||
---
|
||||
|
||||
## Log
|
||||
|
||||
<p align="center">
|
||||
<a href="Screenshots/settings-log.png"><img src="Screenshots/settings-log.png" alt="Log page" width="100%"></a>
|
||||
</p>
|
||||
|
||||
A live activity log — useful to watch a model load/download or to diagnose a
|
||||
problem (recording, transcription, routing, and wakeword events all appear here).
|
||||
|
||||
| Control | Description |
|
||||
|---|---|
|
||||
| **Level** dropdown | Filter by severity: **Verbose** (all), **Info** (default), **Warning**, **Error**. Switch to Warning or Error to cut noise when troubleshooting. |
|
||||
| **Copy** | Put the log on the clipboard when reporting an issue. |
|
||||
| **Clear** | Discard all current log entries. |
|
||||
| **Auto-scroll** | Keep the view scrolled to the latest entry. |
|
||||
|
||||
---
|
||||
|
||||
## Manual
|
||||
|
||||
<p align="center">
|
||||
<a href="Screenshots/settings-manual.png"><img src="Screenshots/settings-manual.png" alt="Manual page" width="100%"></a>
|
||||
</p>
|
||||
|
||||
Inline copy of this manual, readable without leaving the app.
|
||||
|
||||
---
|
||||
|
||||
## About
|
||||
|
||||
<p align="center">
|
||||
<a href="Screenshots/settings-about.png"><img src="Screenshots/settings-about.png" alt="About page" width="100%"></a>
|
||||
</p>
|
||||
|
||||
Read-only information:
|
||||
|
||||
- **Version** and a link to the source repository
|
||||
(`github.com/mARTin-B78/blitztext-app-linux`).
|
||||
- **License: MIT** · **Copyright: 2026 mARTin Bierschenk - Design**.
|
||||
- Sub-tabs with the full **Changelog** and **License** text.
|
||||
|
||||
---
|
||||
|
||||
## System-tray menu (quick reference)
|
||||
|
||||
| Item | What it does |
|
||||
|---|---|
|
||||
| **● status** | Current state (Ready / Recording / Transcribing / Error). |
|
||||
| *Preset names* | Click to trigger that preset. |
|
||||
| **Pause wakeword** | Reversible toggle to pause/resume hands-free detection (only shown when the wakeword is enabled). |
|
||||
| **Show panel** | Open the control panel window. |
|
||||
| **Settings…** | Open this Settings window. |
|
||||
| **Quit Blitztext** | Exit the app. |
|
||||
|
||||
---
|
||||
|
||||
## Config-only options
|
||||
|
||||
A few behaviours live in `config.toml` without a dedicated tab control:
|
||||
|
||||
- **`[routing]`** — voice-keyword routing: `enabled`, `hotkey` (one shortcut to
|
||||
dictate and let the spoken keyword pick the preset), `default` (preset used when
|
||||
no keyword matches), and `threshold` (`0`–`1` fuzzy-match strictness).
|
||||
- **`timeout`** — network timeout (seconds) for remote STT/LLM requests.
|
||||
- **`type_delay_ms`** — delay between simulated keystrokes in `type` output mode.
|
||||
- **`overlay_anchor`** — where the overlay's tail points: `caret` (best-effort —
|
||||
follows the focused app's text caret via AT-SPI accessibility, falling back to
|
||||
the pointer), `pointer` (always the mouse pointer), or `corner` (a fixed screen
|
||||
corner; also the automatic fallback on Wayland or when the cursor can't be
|
||||
located). Paired with the **Visual overlay** toggle above.
|
||||
604
README.md
@ -1,575 +1,157 @@
|
||||
# ⚡ Blitztext App Linux
|
||||
# Blitztext App
|
||||
|
||||
**Speak into any text field on your Linux desktop — instantly.**
|
||||
Blitztext App is an experimental open-source macOS menubar app for turning speech into text.
|
||||
|
||||
Blitztext is a native Linux dictation tool that captures your voice, transcribes it locally with [faster-whisper](https://github.com/SYSTRAN/faster-whisper), optionally rewrites the text through an LLM, and types the result directly into whatever application has focus. Think macOS Dictation, but open-source, extensible, and designed for power users who want full control over their speech-to-text pipeline.
|
||||
It is intentionally small and unfinished. The goal is to make a real workflow visible and hackable: press a hotkey, speak, get text back, optionally rewrite it, and paste it into the app you were using.
|
||||
|
||||
> **Status:** Experimental open-source Linux/X11 desktop app (v1.7.0).
|
||||
> No hosted backend — bring your own models and endpoints.
|
||||
This is a learning and experimentation project, not a polished product.
|
||||
|
||||
<p align="center">
|
||||
<img src="Screenshots/main-panel.png" alt="Blitztext control panel" width="360">
|
||||
</p>
|
||||
> Preview status: bring your own OpenAI API key, no hosted backend, no warranty, no support guarantee.
|
||||
|
||||
<p align="center">
|
||||
<img src="Screenshots/overlay-listening.png" alt="On-screen overlay while listening" width="360">
|
||||
|
||||
<img src="Screenshots/overlay-result.png" alt="On-screen overlay showing transcription result" width="360">
|
||||
</p>
|
||||
## What It Does
|
||||
|
||||
📖 **[User manual](MANUAL.md)** — every setting in every tab, explained.
|
||||
- **Blitztext**: record speech and transcribe it.
|
||||
- **Blitztext+**: record speech, transcribe it, then turn the rough draft into cleaner writing.
|
||||
- **Blitztext $%&!**: turn frustrated speech into a calmer message.
|
||||
- **Blitztext :)**: add fitting emojis to dictated text.
|
||||
|
||||
---
|
||||
## Important Preview Notes
|
||||
|
||||
## Inspiration & Credits
|
||||
- macOS only.
|
||||
- Bring your own OpenAI API key.
|
||||
- No hosted Blitztext backend is included or provided.
|
||||
- In online mode, audio and text are sent directly from the app to the OpenAI API.
|
||||
- Optional local transcription via WhisperKit/CoreML if you install a compatible model locally.
|
||||
- `./build.sh` creates a locally ad-hoc-signed development app. No notarized release binary is provided.
|
||||
- Not production ready.
|
||||
- No warranty and no support guarantee.
|
||||
|
||||
Blitztext App Linux is inspired by [cmagnussen/blitztext-app](https://github.com/cmagnussen/blitztext-app), the original macOS menu-bar app for turning speech into text and cleaner writing. This Linux version recreates the workflow using Linux-native components:
|
||||
You are welcome to use, fork, adapt, and share this project under the license terms.
|
||||
|
||||
| Upstream (macOS) | This project (Linux) |
|
||||
|---|---|
|
||||
| Swift / SwiftUI | Python 3.11+ / GTK 3 |
|
||||
| WhisperKit / CoreML | [faster-whisper](https://github.com/SYSTRAN/faster-whisper) (CTranslate2) |
|
||||
| macOS Accessibility API | `xdotool` (X11) |
|
||||
| Menu bar app | AppIndicator tray + GTK control panel |
|
||||
| — | Riva/NIM realtime WebSocket STT |
|
||||
| — | Voice-keyword routing |
|
||||
| — | Built-in STT benchmark |
|
||||
|
||||
Credit to the [SYSTRAN/faster-whisper](https://github.com/SYSTRAN/faster-whisper) project for the local Whisper inference engine, and to the [pynput](https://pypi.org/project/pynput/) library for global hotkey handling.
|
||||
|
||||
---
|
||||
|
||||
## Core Functionality & Key Differentiators
|
||||
|
||||
### What it does
|
||||
|
||||
```
|
||||
Batch: hotkey → record mic → faster-whisper (local) → [optional LLM rewrite] → xdotool types it
|
||||
Stream: hotkey → mic PCM chunks → Riva/NIM WebSocket → live words typed as you speak
|
||||
```
|
||||
|
||||
1. **Focus any text field** — terminal, browser, email client, IDE, chat app.
|
||||
2. **Press a hotkey** (or use modifier keys, or click the tray menu).
|
||||
3. **Speak naturally.**
|
||||
4. **Text appears** where your cursor is — plain transcript, polished email, calmed-down message, or emoji-enriched text.
|
||||
|
||||
### What makes it different
|
||||
|
||||
- **Runs on the host, not in a browser.** Because it uses `xdotool`, it can type into *any* X11 application — not just a web page or Electron app.
|
||||
- **Fully local STT.** Batch transcription via `faster-whisper` never leaves your machine. No cloud account needed for basic dictation.
|
||||
- **Pluggable engines.** Configure multiple STT and LLM backends as named presets — local `faster-whisper`, remote OpenAI-compatible batch endpoints, Riva/NIM realtime WebSocket servers, and any OpenAI-compatible chat API (OpenAI, vLLM, llama-swap, Ollama, LM Studio, Groq, OpenRouter).
|
||||
- **Voice-keyword routing.** One hotkey, multiple workflows. Say "nicer email" at the start or end of your speech and the email-rewrite preset activates automatically (fuzzy-matched, ASR-tolerant).
|
||||
- **Spoken cancel.** Say "abbrechen" (or "cancel") at the start or end of a clip and the whole dictation is discarded — never routed, rewritten, or typed. The rescue for an accidentally triggered (e.g. wakeword) recording. Configurable in Settings; empty list disables it.
|
||||
- **Quality gate.** Silent clips, too-short recordings, and Whisper hallucinations ("Thank you.", "Untertitel…") are caught and rejected before they reach your text field.
|
||||
- **Realtime streaming.** Connect a Riva/NIM realtime STT server and see stable words typed live as you speak.
|
||||
- **On-screen overlay at the cursor.** The moment you start dictating — by hotkey *or* wakeword — a translucent bubble pops up at the cursor with a pulsing microphone, a live waveform of your mic level, and the recognised text. When a voice keyword routes to a preset it shows that preset's icon, name, and the matched keyword on a banner (instead of a desktop notification), and streams the LLM rewrite into the bubble token-by-token so you watch it write. Its tail points at the text caret (via accessibility) and finally gives hands-free wakeword sessions visible feedback. Click-through, never steals focus; toggle in Settings → General.
|
||||
- **Built-in benchmarking.** Compare all your configured STT engines against a reference WAV + transcript to find the fastest and most accurate.
|
||||
|
||||
---
|
||||
The intent is not to ship a one-click finished app. The intent is to make a real AI workflow understandable: clone it, build it, read the code, change it, break it, fix it, and suggest improvements. If you only want to download something and never look inside, this preview will probably feel rough. If you want to learn how a small native macOS AI app is put together, you are in the right place.
|
||||
|
||||
## Screenshots
|
||||
|
||||
Everything is configured in the GTK **Settings** window — the sidebar gives quick
|
||||
access to every page. All controls have tooltips and screen-reader (ATK) support.
|
||||
Click any image to open it full size.
|
||||
|
||||
### Main panel & overlay
|
||||
|
||||
<p align="center">
|
||||
<a href="Screenshots/main-panel.png"><img src="Screenshots/main-panel.png" alt="Blitztext main panel" width="46%"></a>
|
||||
|
||||
<a href="Screenshots/overlay-listening.png"><img src="Screenshots/overlay-listening.png" alt="Overlay — listening" width="46%"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<em><b>Left:</b> Control panel listing all presets with icons, descriptions, and hotkeys.</em>
|
||||
|
||||
<em><b>Right:</b> On-screen overlay showing the live waveform while listening.</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="Screenshots/overlay-result.png"><img src="Screenshots/overlay-result.png" alt="Overlay — transcription result" width="46%"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<em>Overlay after transcription — preset name and recognised text appear at the cursor.</em>
|
||||
</p>
|
||||
|
||||
### Settings — General & Input
|
||||
|
||||
<p align="center">
|
||||
<a href="Screenshots/settings-presets.png"><img src="Screenshots/settings-presets.png" alt="Presets page" width="48%"></a>
|
||||
|
||||
<a href="Screenshots/settings-general.png"><img src="Screenshots/settings-general.png" alt="General page" width="48%"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<em><b>Presets</b> — dictation actions with keywords, hotkeys, LLM mode, and custom prompts.</em>
|
||||
|
||||
<em><b>General</b> — microphone, output mode, language hint, notifications, overlay, autostart.</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="Screenshots/settings-keyboard.png"><img src="Screenshots/settings-keyboard.png" alt="Keyboard page" width="48%"></a>
|
||||
|
||||
<a href="Screenshots/settings-wakeword.png"><img src="Screenshots/settings-wakeword.png" alt="Wakeword page" width="48%"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<em><b>Keyboard</b> — modifier-key scheme or direct hotkeys, quality gate, audio cues.</em>
|
||||
|
||||
<em><b>Wakeword</b> — hands-free dictation via a Wyoming/openWakeWord server, with live level meter and model picker.</em>
|
||||
</p>
|
||||
|
||||
### Settings — Engines
|
||||
|
||||
<p align="center">
|
||||
<a href="Screenshots/settings-stt-engines.png"><img src="Screenshots/settings-stt-engines.png" alt="STT Engines page" width="48%"></a>
|
||||
|
||||
<a href="Screenshots/settings-llm-engines.png"><img src="Screenshots/settings-llm-engines.png" alt="LLM Engines page" width="48%"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<em><b>STT Engines</b> — speech-to-text back-ends (local faster-whisper, OpenAI-compatible server, or Riva realtime), with green/red status dot and Test button.</em>
|
||||
|
||||
<em><b>LLM Engines</b> — language-model back-ends for text rewriting (LAN server or cloud service).</em>
|
||||
</p>
|
||||
|
||||
### Settings — Benchmark
|
||||
|
||||
<p align="center">
|
||||
<a href="Screenshots/settings-benchmark-stt.png"><img src="Screenshots/settings-benchmark-stt.png" alt="Benchmark — STT page" width="48%"></a>
|
||||
|
||||
<a href="Screenshots/settings-benchmark-wakeword.png"><img src="Screenshots/settings-benchmark-wakeword.png" alt="Benchmark — Wakeword page" width="48%"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<em><b>Benchmark — STT</b> — compare every configured engine against a reference WAV + transcript; results table shows speed, accuracy, device, and language support.</em>
|
||||
|
||||
<em><b>Benchmark — Wakeword</b> — stress-test wakeword detection using a TTS server to synthesise wake phrases in random voices, reporting recall and false-fire rates.</em>
|
||||
</p>
|
||||
|
||||
### Settings — Log & About
|
||||
|
||||
<p align="center">
|
||||
<a href="Screenshots/settings-log.png"><img src="Screenshots/settings-log.png" alt="Log page" width="48%"></a>
|
||||
|
||||
<a href="Screenshots/settings-about.png"><img src="Screenshots/settings-about.png" alt="About page" width="48%"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<em><b>Log</b> — live activity log for recording, transcription, routing, and wakeword events.</em>
|
||||
|
||||
<em><b>About</b> — version, source link, inline changelog, and licence.</em>
|
||||
</p>
|
||||
|
||||
### Setup Wizard
|
||||
|
||||
The first-run wizard guides you through the essentials in a few steps.
|
||||
|
||||
<p align="center">
|
||||
<a href="Screenshots/setup-welcome.png"><img src="Screenshots/setup-welcome.png" alt="Setup — Welcome" width="32%"></a>
|
||||
|
||||
<a href="Screenshots/setup-trigger.png"><img src="Screenshots/setup-trigger.png" alt="Setup — Trigger mode" width="32%"></a>
|
||||
|
||||
<a href="Screenshots/setup-shortcuts.png"><img src="Screenshots/setup-shortcuts.png" alt="Setup — Keyboard shortcuts" width="32%"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<em>Welcome · Choose trigger mode (keyboard / wakeword / both) · Set keyboard shortcuts</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="Screenshots/setup-voice.png"><img src="Screenshots/setup-voice.png" alt="Setup — Voice activation" width="32%"></a>
|
||||
|
||||
<a href="Screenshots/setup-stt.png"><img src="Screenshots/setup-stt.png" alt="Setup — STT engine" width="32%"></a>
|
||||
|
||||
<a href="Screenshots/setup-ai.png"><img src="Screenshots/setup-ai.png" alt="Setup — AI rewriting" width="32%"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<em>Voice activation (wakeword server) · Choose STT engine · Optional AI text rewriting</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="Screenshots/setup-done.png"><img src="Screenshots/setup-done.png" alt="Setup — All done" width="32%"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<em>Summary screen — ready to dictate.</em>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Target Use Cases
|
||||
|
||||
| Scenario | How Blitztext helps |
|
||||
|---|---|
|
||||
| **Quick replies** | Dictate an email or chat message instead of typing it |
|
||||
| **Rough-to-polished** | Speak freely, let the LLM rewrite it into a professional email |
|
||||
| **Multilingual dictation** | faster-whisper supports 99 languages; set `language = "de"` or `"en"` |
|
||||
| **Local-only transcription** | Use `faster-whisper` with no network calls at all |
|
||||
| **Live captioning** | Stream mode types words as you speak (with a Riva/NIM server) |
|
||||
| **Voice-driven workflows** | Trigger different presets by speaking a keyword |
|
||||
| **STT engine comparison** | Benchmark tab compares speed and accuracy across all configured engines |
|
||||
| **GPU or CPU** | Works on CPU (`int8`) out of the box; add a CUDA CTranslate2 build for GPU |
|
||||
|
||||
---
|
||||
<table>
|
||||
<tr>
|
||||
<td><img src="docs/screenshots/online-mode.png" alt="Blitztext online transcription mode" width="420"></td>
|
||||
<td><img src="docs/screenshots/local-mode.png" alt="Blitztext secure local transcription mode" width="420"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><img src="docs/screenshots/local-model-picker.png" alt="Blitztext local model picker" width="420"></td>
|
||||
<td><img src="docs/screenshots/settings-customize.png" alt="Blitztext settings and customization view" width="420"></td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Linux desktop with an X11 or Wayland session** (Wayland uses `wtype` or `ydotool`)
|
||||
- **Python 3.11+** (for source installs)
|
||||
- **Host tools:**
|
||||
- macOS 14 or newer
|
||||
- Xcode 16 or newer (Swift 5.10), with Command Line Tools installed and selected for `xcodebuild`
|
||||
- [XcodeGen](https://github.com/yonaskolb/XcodeGen) to generate the Xcode project
|
||||
- For online transcription and rewriting: an OpenAI API key with access to:
|
||||
- `whisper-1` for transcription
|
||||
- `gpt-4o-mini` and optionally `gpt-4o` for rewriting
|
||||
- For local-only transcription: a WhisperKit CoreML model in:
|
||||
`~/Library/Application Support/Blitztext/models/whisperkit/`
|
||||
|
||||
```bash
|
||||
sudo apt install xdotool libnotify-bin pipewire-bin python3-gi
|
||||
```
|
||||
The build also pulls one Swift Package dependency automatically:
|
||||
|
||||
- `xdotool` — text delivery into the focused window
|
||||
- `libnotify-bin` — desktop notifications (`notify-send`)
|
||||
- `pipewire-bin` — microphone recording (`pw-record`); alternatives: `pulseaudio-utils` (`parecord`) or `alsa-utils` (`arecord`)
|
||||
- `python3-gi` — GTK 3 / AppIndicator system tray
|
||||
- [`argmax-oss-swift`](https://github.com/argmaxinc/argmax-oss-swift) (WhisperKit) — used for local on-device transcription.
|
||||
|
||||
- **Optional:** An OpenAI-compatible chat endpoint for rewrite workflows
|
||||
- **Optional:** A Riva/NIM realtime server for live streaming STT
|
||||
|
||||
---
|
||||
|
||||
## Installation & Setup
|
||||
|
||||
### Option A — One-line installer (recommended)
|
||||
|
||||
Install on any Ubuntu/Debian machine with a single command:
|
||||
Install XcodeGen if needed:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/mARTin-B78/blitztext-app-linux/main/install-linux.sh | bash
|
||||
brew install xcodegen
|
||||
```
|
||||
|
||||
This clones the repo, builds a `.deb`, installs it with `apt` (pulling in all dependencies), and cleans up. After it finishes, **Blitztext** appears in your app grid.
|
||||
|
||||
### Option B — Build the Debian package yourself
|
||||
## Build And Run
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mARTin-B78/blitztext-app-linux.git
|
||||
cd blitztext-app-linux/linux
|
||||
bash packaging/build-deb.sh # → dist/blitztext_<ver>_<arch>.deb
|
||||
sudo apt install ./dist/blitztext_*.deb
|
||||
git clone https://github.com/cmagnussen/blitztext-app.git
|
||||
cd blitztext-app
|
||||
./build.sh --run
|
||||
```
|
||||
|
||||
This installs Blitztext to `/opt/blitztext`, adds a **Blitztext** entry to your application menu, installs the app icon, and pulls in system dependencies automatically. Remove with `sudo apt remove blitztext`.
|
||||
|
||||
### Option C — Run from Source (venv)
|
||||
For a local install into `/Applications`:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mARTin-B78/blitztext-app-linux.git
|
||||
cd blitztext-app-linux/linux
|
||||
./install.sh
|
||||
./build.sh --install --run
|
||||
```
|
||||
|
||||
`install.sh` creates a `.venv` with `--system-site-packages` (so it sees the system `python3-gi` for the GTK tray), installs dependencies from `requirements.txt`, and writes the default config.
|
||||
The generated `.app` is ad-hoc signed for local development only. Do not treat it as a trusted redistributable binary. A public binary release would need Developer ID signing and notarization.
|
||||
|
||||
> **Important:** Build the venv from `/usr/bin/python3`, not a conda/miniforge Python.
|
||||
> A conda Python can't see the apt-installed `python3-gi`, so the tray won't start.
|
||||
> The `.deb` package avoids this issue entirely.
|
||||
On first launch, either paste your own OpenAI API key for online workflows or install a WhisperKit CoreML model for local transcription. Rewriting workflows still require OpenAI.
|
||||
|
||||
### Environment Variables
|
||||
For fully local transcription, install a WhisperKit CoreML model and enable **Sicherer Lokaler Modus** in the app.
|
||||
|
||||
The only environment variable needed is for rewrite workflows that use a cloud LLM:
|
||||
For a slower, more explicit walkthrough, see [docs/setup.md](docs/setup.md).
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=sk-... # only if using OpenAI or a keyed endpoint
|
||||
## Permissions
|
||||
|
||||
Blitztext asks for:
|
||||
|
||||
- **Microphone**: to record your voice.
|
||||
- **Accessibility**: to paste the result back into the app you were using.
|
||||
|
||||
If you do not grant Accessibility permission, you can still copy results manually.
|
||||
|
||||
Full Disk Access is not required. If auto-paste does not work even though transcription succeeds, open **System Settings -> Privacy & Security -> Accessibility**, enable Blitztext there, restart Blitztext, and try again with the cursor focused in a text field. If macOS shows multiple Blitztext entries, remove or disable the old ones and grant the permission to the app you just built or installed.
|
||||
|
||||
## Data Flow
|
||||
|
||||
The preview has no custom backend.
|
||||
|
||||
```text
|
||||
Online transcription: Your Mac -> OpenAI Audio Transcriptions API
|
||||
Text rewriting: Your Mac -> OpenAI Chat Completions API
|
||||
Local transcription: Your Mac -> WhisperKit/CoreML on device
|
||||
```
|
||||
|
||||
The config file (`~/.config/blitztext/config.toml`) references environment variable *names*, never the keys themselves. Local STT and local LLM endpoints typically require no key.
|
||||
The app stores your OpenAI API key in the user's macOS Keychain.
|
||||
|
||||
---
|
||||
|
||||
## Quickstart Tutorial
|
||||
|
||||
**Goal:** Go from zero to dictating text into a terminal in under 5 minutes.
|
||||
|
||||
### 1. Install
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian — install host tools
|
||||
sudo apt install xdotool libnotify-bin pipewire-bin python3-gi
|
||||
|
||||
# Clone and set up
|
||||
git clone https://github.com/mARTin-B78/blitztext-app-linux.git
|
||||
cd blitztext-app-linux/linux
|
||||
./install.sh
|
||||
```
|
||||
|
||||
### 2. Launch
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m blitztext tray
|
||||
```
|
||||
|
||||
A microphone icon appears in your system tray. The first launch downloads the Whisper `small` model (~460 MB) — wait for the "Ready" notification.
|
||||
|
||||
### 3. Dictate
|
||||
|
||||
1. **Open a text editor** (gedit, VS Code, a terminal, a browser text field — anything).
|
||||
2. **Click in the text field** so it has focus.
|
||||
3. **Press `Ctrl+Super`** (Ctrl + Windows key) to start recording.
|
||||
4. **Speak** your text naturally.
|
||||
5. **Press `Ctrl`** to stop, transcribe, and type the result.
|
||||
|
||||
That's it. The transcribed text appears where your cursor was.
|
||||
|
||||
### 4. Try a rewrite workflow
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=sk-... # or point at a local LLM in Settings
|
||||
```
|
||||
|
||||
1. Press `Ctrl+Super` → speak something rough like "hey john can you send me the report"
|
||||
2. Press `Ctrl` — the text appears as a plain transcript.
|
||||
|
||||
Now try with voice routing:
|
||||
|
||||
1. Press `Ctrl+Alt+Space` → say **"nicer email** hey john can you send me the report"
|
||||
2. Press `Ctrl` — Blitztext detects the keyword, runs the "Nicer email" rewrite, and types a polished email.
|
||||
|
||||
### 4b. Cancel by voice
|
||||
|
||||
Started a recording by accident (or changed your mind)? Just say **"abbrechen"** (or **"cancel"**) at the start or end of what you say. The whole clip is discarded — nothing is transcribed onward, routed, rewritten, or typed, and the overlay briefly shows *✗ Abgebrochen*. This is especially handy with the hands-free wakeword, where a stray trigger could otherwise type ambient speech. Tune the words under **Settings → Mic/Cues → "Cancel words"** (or `[routing] cancel_keywords`); clear the list to switch it off.
|
||||
|
||||
### 5. Explore Settings
|
||||
|
||||
Click the ⚙️ gear icon in the panel header, or right-click the tray → **Settings…**
|
||||
|
||||
- **Presets** — edit workflows, hotkeys, prompts, keywords
|
||||
- **Engines** — manage STT and LLM backends, check online status, run tests
|
||||
- **Input** — switch between modifier keys and direct hotkeys
|
||||
- **General** — choose microphone, output mode, language, autostart
|
||||
- **Benchmark** — compare STT engines with a reference WAV
|
||||
- **Log** — inspect runtime messages
|
||||
- **About** — version, changelog, license
|
||||
|
||||
---
|
||||
|
||||
## CLI Reference
|
||||
|
||||
```bash
|
||||
blitztext tray # System tray + hotkeys (default)
|
||||
blitztext gui # GTK control panel window
|
||||
blitztext run # Headless daemon, hotkeys only
|
||||
blitztext transcribe f.wav # One-shot transcription, prints text
|
||||
blitztext config-path # Print config file location
|
||||
blitztext --version # Print version
|
||||
```
|
||||
|
||||
From a source checkout, prefix with `.venv/bin/python -m`:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m blitztext tray
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Default Workflows
|
||||
|
||||
| Hotkey | Workflow | Mode | What it does |
|
||||
|---|---|---|---|
|
||||
| `Ctrl+Alt+Space` | *(voice routing)* | auto | Routes to a preset by spoken keyword |
|
||||
| `Ctrl+Alt+E` | Nicer email | `rewrite` | Turns rough speech into a polished email |
|
||||
| `Ctrl+Alt+I` | Improve text | `rewrite` | Proofreads and improves wording |
|
||||
| `Ctrl+Alt+C` | Calm down | `rewrite` | Rewrites frustrated speech into a calm message |
|
||||
| `Ctrl+Alt+J` | Add emojis | `rewrite` | Adds fitting emojis to the text |
|
||||
|
||||
With the default `modifiers` input mode:
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `Ctrl+Super` | Start recording |
|
||||
| `Ctrl` | Stop → transcribe → type |
|
||||
| `Alt` | Stop → transcribe → type → press Enter |
|
||||
| `Esc` | Cancel (discard recording) |
|
||||
| say *"abbrechen"* / *"cancel"* | Cancel by voice — discard the clip (works hands-free too) |
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All settings live in `~/.config/blitztext/config.toml`. Edit through the Settings UI or directly as TOML.
|
||||
|
||||
### Local Whisper (batch STT)
|
||||
|
||||
```toml
|
||||
[whisper]
|
||||
model = "small" # tiny | base | small | medium | large-v3
|
||||
device = "auto" # auto | cuda | cpu
|
||||
compute_type = "auto" # auto | int8 | float16
|
||||
beam_size = 5
|
||||
```
|
||||
|
||||
### Remote batch STT
|
||||
|
||||
```toml
|
||||
[[stt_engine]]
|
||||
name = "faster-whisper-server"
|
||||
type = "openai"
|
||||
url = "http://localhost:8010/v1"
|
||||
model = "Systran/faster-whisper-base"
|
||||
```
|
||||
|
||||
### Realtime STT streaming
|
||||
|
||||
```toml
|
||||
[[stt_engine]]
|
||||
name = "Nemotron ASR Streaming"
|
||||
type = "riva_realtime"
|
||||
url = "http://127.0.0.1:8006/v1"
|
||||
|
||||
[[workflow]]
|
||||
name = "STT Streaming"
|
||||
hotkey = "<ctrl>+<alt>+s"
|
||||
mode = "stream"
|
||||
```
|
||||
|
||||
### LLM rewrite endpoint
|
||||
|
||||
```toml
|
||||
[[llm_engine]]
|
||||
name = "Default"
|
||||
type = "cloud"
|
||||
url = "https://api.openai.com/v1"
|
||||
model = "gpt-4o-mini"
|
||||
api_key_env = "OPENAI_API_KEY"
|
||||
temperature = 0.3
|
||||
```
|
||||
|
||||
For local rewriting, point at a local server:
|
||||
|
||||
```toml
|
||||
[[llm_engine]]
|
||||
name = "Local llama-swap"
|
||||
type = "local"
|
||||
url = "http://localhost:28080/v1"
|
||||
model = "Qwen3.5-4B"
|
||||
api_key_env = ""
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Privacy
|
||||
|
||||
Blitztext does **not** include a hosted backend. Where your data goes depends on what you configure:
|
||||
|
||||
```
|
||||
Local STT: microphone → local faster-whisper (never leaves your machine)
|
||||
Remote batch STT: microphone → your configured /audio/transcriptions endpoint
|
||||
Realtime STT: microphone → your configured Riva/NIM realtime endpoint
|
||||
Rewrite: transcript → your configured OpenAI-compatible chat endpoint
|
||||
Delivery: text → xdotool → focused X11 window
|
||||
```
|
||||
|
||||
API keys are stored as environment variable *names* in the config, never as values. See [docs/privacy.md](docs/privacy.md) for the full privacy model.
|
||||
|
||||
---
|
||||
Read [docs/privacy.md](docs/privacy.md) before using the preview with sensitive content.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
linux/
|
||||
blitztext/ Python package: GTK UI, tray, daemon, STT, LLM, config
|
||||
packaging/ Debian packaging, desktop entry, app icons
|
||||
install.sh Venv setup script
|
||||
requirements.txt Python dependencies
|
||||
CHANGELOG.md Linux app changelog
|
||||
README.md Detailed Linux usage guide
|
||||
docs/ Setup, privacy, and project documentation
|
||||
.github/ CI workflows, issue templates, Dependabot, secret scan
|
||||
README.md ← you are here
|
||||
```text
|
||||
BlitztextMac/
|
||||
App/ App lifecycle and paste handling
|
||||
Features/ Workflows, menu bar UI, settings
|
||||
Services/ Recording, OpenAI calls, hotkeys, local storage
|
||||
Views/ Shared SwiftUI views
|
||||
build.sh Local build script
|
||||
docs/ Setup, privacy, roadmap, preflight, landing page notes
|
||||
```
|
||||
|
||||
---
|
||||
## Local Models
|
||||
|
||||
## Run on Login
|
||||
Local transcription is available as an experimental WhisperKit/CoreML path. The app does not bundle a model; choose one in the app, click install, and then switch on **Sicherer Lokaler Modus** from the menu bar or settings.
|
||||
|
||||
### Autostart toggle (recommended)
|
||||
|
||||
Open **Settings → General → Launch on login** and enable it. This writes a freedesktop `.desktop` entry to `~/.config/autostart/`.
|
||||
|
||||
### systemd user service
|
||||
|
||||
```bash
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cp linux/blitztext.service ~/.config/systemd/user/
|
||||
# edit ExecStart if your checkout path differs
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now blitztext
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Current Limitations
|
||||
|
||||
- **Wayland support** requires `wtype` or `ydotool`. Wayland security prevents global window focus manipulation, so text is delivered to whatever window is active when delivery occurs.
|
||||
- **No automated tests yet.** Contributions welcome (routing, quality gate, config parsing are all highly testable).
|
||||
- **Realtime streaming** requires a compatible Riva/NIM server.
|
||||
- **The on-screen overlay is X11-only** (it positions a window at the cursor and reads the pointer/caret); on Wayland it falls back to a fixed screen corner. Caret-accurate anchoring further needs the focused app to expose its text caret over AT-SPI accessibility — otherwise it follows the mouse pointer.
|
||||
- **Local STT speed** depends on your hardware, Whisper model size, and CTranslate2 build (CPU `int8` by default).
|
||||
- This is experimental software provided as-is.
|
||||
|
||||
---
|
||||
See [docs/local-models.md](docs/local-models.md).
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
||||
Contributions are welcome, especially if they make the preview easier to build, understand, or fork.
|
||||
|
||||
**Good first contributions:**
|
||||
- Add unit tests for `routing.py`, `quality.py`, `config.py`, `benchmark.py`
|
||||
- Add a short demo GIF or additional screenshots
|
||||
- Improve error messages and first-run setup
|
||||
- Document known-good STT/LLM engine configurations
|
||||
- Document known-good Wayland configurations for specific compositors
|
||||
Please read [CONTRIBUTING.md](CONTRIBUTING.md) first.
|
||||
|
||||
**Quick development loop:**
|
||||
## Support And Roadmap
|
||||
|
||||
```bash
|
||||
cd linux
|
||||
./install.sh
|
||||
.venv/bin/python -m py_compile blitztext/*.py # syntax check
|
||||
.venv/bin/python -m blitztext --version # smoke test
|
||||
.venv/bin/python -m blitztext gui # run the GUI
|
||||
```
|
||||
This preview has no formal support promise. See [SUPPORT.md](SUPPORT.md) for how to ask for help without sharing secrets.
|
||||
|
||||
Please read [SECURITY.md](SECURITY.md) before reporting vulnerabilities.
|
||||
|
||||
---
|
||||
The current direction is documented in [ROADMAP.md](ROADMAP.md). Maintainer-facing release checks live in [docs/open-source-preflight.md](docs/open-source-preflight.md).
|
||||
|
||||
## License
|
||||
|
||||
This project is released under the **MIT License**. See [LICENSE](LICENSE).
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Blitztext contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
Code is released under the MIT License. See [LICENSE](LICENSE).
|
||||
|
||||
Project names, logos, and app icons are not automatically granted as trademarks or brand assets. See [TRADEMARKS.md](TRADEMARKS.md).
|
||||
|
||||
---
|
||||
|
||||
## Legal / Impressum & Datenschutz
|
||||
|
||||
This is an experimental, non-commercial open-source project, provided as-is under the MIT License without warranty or support. Nothing is sold here and no installation or operation is performed on your behalf.
|
||||
|
||||
The companion website (blitztext.de) is operated by Blackboat Internet GmbH:
|
||||
|
||||
- Impressum: https://martin-bierschenk.de/impressum/
|
||||
- Datenschutz / Privacy: https://martin-bierschenk.de/datenschutz/
|
||||
|
||||
- Impressum: https://www.blackboat.com/impressum
|
||||
- Datenschutz / Privacy: https://www.blackboat.com/datenschutz
|
||||
|
||||
36
ROADMAP.md
@ -4,32 +4,28 @@ This is a preview roadmap, not a promise.
|
||||
|
||||
## Current Scope
|
||||
|
||||
- Linux/X11 native dictation app
|
||||
- GTK control panel and AppIndicator tray
|
||||
- global hotkeys and modifier input mode
|
||||
- local batch transcription through `faster-whisper`
|
||||
- OpenAI-compatible batch STT endpoints
|
||||
- Riva/NIM realtime STT streaming through WebSocket
|
||||
- optional rewrite workflows through OpenAI-compatible chat endpoints
|
||||
- xdotool typing or clipboard paste into the focused window
|
||||
- Debian package for local Ubuntu/Debian installs
|
||||
- no hosted Blitztext backend
|
||||
- macOS menubar app
|
||||
- local recording and hotkeys
|
||||
- direct OpenAI API calls with a user-provided API key
|
||||
- transcription, rewriting, calmer-message, and emoji workflows
|
||||
- no hosted backend
|
||||
- no other platforms
|
||||
- no packaged public release
|
||||
|
||||
## Next Useful Work
|
||||
|
||||
- Capture and add current Linux screenshots for the control panel, settings, benchmark, and About tab.
|
||||
- Improve first-run setup for STT engines, microphones, and output mode.
|
||||
- Add a guided realtime STT streaming test that does not type into the active window.
|
||||
- Add automated tests around config parsing, routing, quality filters, URL handling, and streaming protocol message generation.
|
||||
- Improve Wayland support with `wtype` or `ydotool` as alternatives to `xdotool`.
|
||||
- Add safer clipboard handling and clearer output-mode diagnostics.
|
||||
- Add a lightweight release checklist for `.deb` builds and source installs.
|
||||
- Document known-good local LLM and STT server configurations.
|
||||
- Make first-run setup clearer.
|
||||
- Improve credential setup, validation, and recovery UX.
|
||||
- Add a small automated test layer around prompt construction and text quality filters.
|
||||
- Add provider boundaries so OpenAI and future local transcription can be swapped more cleanly.
|
||||
- Prototype local transcription with WhisperKit or whisper.cpp.
|
||||
- Reduce the Accessibility blast radius, ideally by moving synthetic paste into a smaller helper with narrower responsibilities.
|
||||
- Add stronger supply-chain checks around downloaded local speech models.
|
||||
- Add signed and notarized release builds when the project is ready for non-developer users.
|
||||
|
||||
## Not In Scope Yet
|
||||
|
||||
- Production support.
|
||||
- Accounts, sync, teams, or hosted infrastructure.
|
||||
- Claims that the app is offline or privacy-complete by default.
|
||||
- Claims that the app is offline or privacy-complete.
|
||||
- App Store distribution.
|
||||
- A polished one-click consumer release.
|
||||
|
||||
19
SECURITY.md
@ -1,6 +1,6 @@
|
||||
# Security Policy
|
||||
|
||||
Blitztext App Linux is experimental software.
|
||||
Blitztext macOS Preview is experimental software.
|
||||
|
||||
It is provided as-is, without warranty, support guarantees, or production-readiness claims.
|
||||
|
||||
@ -12,9 +12,11 @@ Only the current `main` branch is considered for security fixes.
|
||||
|
||||
Please do not open a public issue with sensitive security details.
|
||||
|
||||
Use GitHub private vulnerability reporting for this repository. If private vulnerability reporting is not available yet, open a minimal public issue titled `Security contact request` without technical details.
|
||||
Use GitHub private vulnerability reporting for this repository. Maintainers should enable it before making the repository public.
|
||||
|
||||
Do not include API keys, access tokens, private recordings, confidential transcripts, screenshots with sensitive text, or private endpoint URLs in a report.
|
||||
If private vulnerability reporting is not available yet, open a minimal public issue titled `Security contact request` without technical details.
|
||||
|
||||
Do not include OpenAI API keys, access tokens, private recordings, or confidential transcripts in a report.
|
||||
|
||||
Include:
|
||||
|
||||
@ -25,11 +27,10 @@ Include:
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Blitztext types into the focused X11 window through `xdotool` or uses the clipboard, depending on output mode.
|
||||
- Global hotkeys and synthetic typing are powerful desktop interactions; review the code before using it with sensitive workflows.
|
||||
- Batch transcription may create temporary audio files during processing; the app attempts to delete them when the workflow ends or is cancelled.
|
||||
- Remote STT, realtime STT, and rewrite workflows send data to the endpoints you configure.
|
||||
- The app does not store API keys directly; config entries name environment variables such as `OPENAI_API_KEY`.
|
||||
- The app does not include a hosted backend.
|
||||
- The app sends audio and text directly to OpenAI when you use the remote workflows.
|
||||
- Your OpenAI API key is stored in the user's macOS Keychain.
|
||||
- Temporary audio files may exist briefly during processing.
|
||||
- Accessibility permission allows the app to paste text into the current app.
|
||||
- The app currently runs **without** the macOS App Sandbox. This is a deliberate trade-off for the preview: the menubar workflow needs Accessibility-based paste into arbitrary frontmost apps, system-wide hotkeys, and Application Support paths for local WhisperKit models, all of which are awkward or impossible inside a strict sandbox. Hardened Runtime is enabled, and the entitlements are limited to microphone input and outbound network access. Reintroducing the sandbox is on the roadmap once these flows are reworked.
|
||||
|
||||
Do not use this preview for confidential or regulated data without your own review.
|
||||
|
||||
14
SUPPORT.md
@ -1,14 +1,13 @@
|
||||
# Support
|
||||
|
||||
Blitztext App Linux is an experimental preview. There is no service-level agreement, paid support channel, or guarantee that issues will be fixed.
|
||||
Blitztext App is an experimental preview. There is no service-level agreement, paid support channel, or guarantee that issues will be fixed.
|
||||
|
||||
## Before Asking For Help
|
||||
|
||||
- Make sure you can install from `linux/install.sh` or build the `.deb` with `linux/packaging/build-deb.sh`.
|
||||
- Confirm you are running an X11 session if you expect automatic typing through `xdotool`.
|
||||
- Check that your microphone works and that the Settings input meter moves.
|
||||
- Check that your selected STT engine is the right type: `local`, `openai`, or `riva_realtime`.
|
||||
- For rewrite workflows, confirm your OpenAI-compatible LLM endpoint and API key environment variable.
|
||||
- Make sure you can build the app with `./build.sh --debug`.
|
||||
- Check that your OpenAI API key is entered in the app settings.
|
||||
- Confirm that macOS microphone permission is granted.
|
||||
- Grant Accessibility permission if you expect automatic paste into other apps.
|
||||
- Read [docs/privacy.md](docs/privacy.md) before testing with sensitive content.
|
||||
|
||||
## Where To Ask
|
||||
@ -17,9 +16,8 @@ Use GitHub Issues for reproducible bugs and focused feature ideas.
|
||||
|
||||
Please do not post:
|
||||
|
||||
- API keys
|
||||
- OpenAI API keys
|
||||
- access tokens
|
||||
- private endpoint URLs
|
||||
- private audio recordings
|
||||
- confidential transcripts
|
||||
- screenshots that show sensitive content
|
||||
|
||||
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 202 KiB |
|
Before Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 145 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 219 KiB |
|
Before Width: | Height: | Size: 118 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 164 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 124 KiB |
@ -2,8 +2,6 @@
|
||||
|
||||
The source code in this repository is licensed under the MIT License.
|
||||
|
||||
Project names, logos, app icons, screenshots, and visual identity are not automatically granted as trademarks or brand assets by the MIT License.
|
||||
The project name, app name, logos, icons, and visual identity are not granted as trademarks or brand assets by the MIT License.
|
||||
|
||||
You may fork the code under the license terms. If you publish a fork as a separate app or service, use your own name, icon, and branding unless you have explicit permission.
|
||||
|
||||
This project credits [cmagnussen/blitztext-app](https://github.com/cmagnussen/blitztext-app) as inspiration. That credit is not a trademark grant from the original project.
|
||||
|
||||
186
build.sh
Executable file
@ -0,0 +1,186 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Blitztext macOS App - Build & Run
|
||||
# Voraussetzungen: Full Xcode with Command Line Tools, xcodegen
|
||||
|
||||
RUN_AFTER=false
|
||||
INSTALL_APP=false
|
||||
BUILD_CONFIGURATION="Release"
|
||||
UNIVERSAL_ARCHS="arm64 x86_64"
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--debug)
|
||||
BUILD_CONFIGURATION="Debug"
|
||||
;;
|
||||
--run)
|
||||
RUN_AFTER=true
|
||||
;;
|
||||
--install)
|
||||
INSTALL_APP=true
|
||||
;;
|
||||
--release)
|
||||
BUILD_CONFIGURATION="Release"
|
||||
;;
|
||||
*)
|
||||
echo "Unbekannte Option: $arg"
|
||||
echo "Verwendung: ./build.sh [--install] [--run] [--release] [--debug]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
verify_universal_app() {
|
||||
local app_path="$1"
|
||||
local app_name
|
||||
local binary_path
|
||||
local archs
|
||||
|
||||
app_name="$(basename "$app_path" .app)"
|
||||
binary_path="$app_path/Contents/MacOS/$app_name"
|
||||
|
||||
if [ ! -f "$binary_path" ]; then
|
||||
echo "❌ Konnte App-Binary nicht finden: $binary_path"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
archs="$(lipo -archs "$binary_path" 2>/dev/null || true)"
|
||||
|
||||
if [[ -z "$archs" ]]; then
|
||||
echo "❌ Konnte Architekturen nicht lesen: $binary_path"
|
||||
file "$binary_path" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ " $archs " != *" arm64 "* || " $archs " != *" x86_64 "* ]]; then
|
||||
echo "❌ Build ist nicht universal. Erwartet: arm64 + x86_64"
|
||||
echo " Gefunden: $archs"
|
||||
file "$binary_path" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Universal Binary verifiziert: $archs"
|
||||
}
|
||||
|
||||
ensure_xcodebuild_available() {
|
||||
if xcodebuild -version >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
|
||||
local default_xcode="/Applications/Xcode.app/Contents/Developer"
|
||||
if [ -d "$default_xcode" ]; then
|
||||
export DEVELOPER_DIR="$default_xcode"
|
||||
if xcodebuild -version >/dev/null 2>&1; then
|
||||
echo "⚠️ Aktiver Developer-Pfad nutzt kein vollständiges Xcode. Verwende: $DEVELOPER_DIR"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "❌ xcodebuild ist nicht verfügbar."
|
||||
echo " Installiere Xcode und wähle es mit:"
|
||||
echo " sudo xcode-select -s /Applications/Xcode.app/Contents/Developer"
|
||||
exit 1
|
||||
}
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_DIR="$SCRIPT_DIR/BlitztextMac"
|
||||
PROJECT_FILE="$PROJECT_DIR/BlitztextMac.xcodeproj"
|
||||
DERIVED_DATA_PATH="$SCRIPT_DIR/.derivedData-blitztextmac-build"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
ensure_xcodebuild_available
|
||||
|
||||
if command -v xcodegen &> /dev/null; then
|
||||
echo "⚙️ Generiere Xcode-Projekt ..."
|
||||
xcodegen generate 2>&1
|
||||
elif [ -d "$PROJECT_FILE" ]; then
|
||||
echo "⚠️ xcodegen nicht gefunden – nutze vorhandenes Xcode-Projekt."
|
||||
else
|
||||
echo "❌ xcodegen fehlt."
|
||||
echo " Installiere xcodegen explizit mit:"
|
||||
echo " brew install xcodegen"
|
||||
echo " Oder stelle sicher, dass $PROJECT_FILE vorhanden ist."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Bauen
|
||||
echo "🔨 Baue Blitztext ..."
|
||||
xcodebuild \
|
||||
-project BlitztextMac.xcodeproj \
|
||||
-scheme BlitztextMac \
|
||||
-destination 'platform=macOS' \
|
||||
-configuration "$BUILD_CONFIGURATION" \
|
||||
-derivedDataPath "$DERIVED_DATA_PATH" \
|
||||
ONLY_ACTIVE_ARCH=NO \
|
||||
ARCHS="$UNIVERSAL_ARCHS" \
|
||||
clean build
|
||||
|
||||
# App finden
|
||||
APP_PATH="$DERIVED_DATA_PATH/Build/Products/$BUILD_CONFIGURATION/Blitztext.app"
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
echo "❌ Build fehlgeschlagen – keine App gefunden."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
verify_universal_app "$APP_PATH"
|
||||
|
||||
# Resources manuell ins Bundle kopieren (xcodegen kopiert sie nicht automatisch)
|
||||
echo "📋 Kopiere Resources ..."
|
||||
RESOURCES_DIR="$APP_PATH/Contents/Resources"
|
||||
mkdir -p "$RESOURCES_DIR"
|
||||
cp -f "$PROJECT_DIR/Resources/AppIcon.icns" "$RESOURCES_DIR/" 2>/dev/null || true
|
||||
cp -f "$PROJECT_DIR/Resources/menubar_icon.png" "$RESOURCES_DIR/" 2>/dev/null || true
|
||||
cp -f "$PROJECT_DIR/Resources/menubar_icon@2x.png" "$RESOURCES_DIR/" 2>/dev/null || true
|
||||
|
||||
# In Projektordner kopieren
|
||||
DEST="$SCRIPT_DIR/Blitztext.app"
|
||||
rm -rf "$DEST"
|
||||
cp -R "$APP_PATH" "$DEST"
|
||||
echo "🔏 Signiere lokale Development-App ad-hoc. Dieses Artefakt ist nicht notarisiert."
|
||||
codesign --force --sign - "$DEST" 2>&1
|
||||
verify_universal_app "$DEST"
|
||||
|
||||
RUN_TARGET="$DEST"
|
||||
|
||||
if [ "$INSTALL_APP" = true ]; then
|
||||
APPS_DIR="/Applications"
|
||||
INSTALL_DEST="$APPS_DIR/Blitztext.app"
|
||||
if [ ! -w "$APPS_DIR" ]; then
|
||||
echo "❌ /Applications ist nicht beschreibbar."
|
||||
echo " Fuehre den Befehl mit passenden Rechten erneut aus oder ziehe die App manuell nach /Applications."
|
||||
exit 1
|
||||
fi
|
||||
rm -rf "$INSTALL_DEST"
|
||||
cp -R "$DEST" "$INSTALL_DEST"
|
||||
echo "🔏 Signiere lokale Development-App ad-hoc. Dieses Artefakt ist nicht notarisiert."
|
||||
codesign --force --sign - "$INSTALL_DEST" 2>&1
|
||||
verify_universal_app "$INSTALL_DEST"
|
||||
RUN_TARGET="$INSTALL_DEST"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ Fertig! App liegt unter:"
|
||||
echo " $DEST"
|
||||
if [ "$INSTALL_APP" = true ]; then
|
||||
echo " $RUN_TARGET"
|
||||
fi
|
||||
echo ""
|
||||
echo "Build-Typ: $BUILD_CONFIGURATION"
|
||||
echo "Architekturen: $UNIVERSAL_ARCHS"
|
||||
echo "Kompatibel: Apple Silicon + Intel (macOS 14+)"
|
||||
echo ""
|
||||
echo "Naechste Schritte:"
|
||||
echo "1. App starten"
|
||||
echo "2. Mikrofon erlauben"
|
||||
echo "3. Fuer direktes Einfuegen zusaetzlich Bedienungshilfen erlauben"
|
||||
echo "4. In Blitztext deinen eigenen OpenAI API Key eintragen"
|
||||
echo "5. Loslegen und bei Bedarf im Code weiterbauen"
|
||||
echo ""
|
||||
|
||||
# Optional: direkt starten
|
||||
if [ "$RUN_AFTER" = true ]; then
|
||||
echo "🚀 Starte Blitztext ..."
|
||||
open "$RUN_TARGET"
|
||||
fi
|
||||
@ -29,5 +29,5 @@ Protect `main`:
|
||||
|
||||
- Keep Issues enabled for bugs and focused requests.
|
||||
- Enable Discussions only if you want a lower-friction place for questions.
|
||||
- Set repository topics after the project is public: `linux`, `dictation`, `speech-to-text`, `gtk`, `x11`, `faster-whisper`, `riva`, `nim`, `openai-compatible`.
|
||||
- Set repository topics after the project is public.
|
||||
- Review the GitHub community profile before sharing the repo widely.
|
||||
|
||||
@ -1,22 +1,22 @@
|
||||
# Landing Page Brief
|
||||
|
||||
Domain: `blitztext.de`
|
||||
Domain: `blitztext.app`
|
||||
|
||||
Goal: a small, honest landing page for an experimental open-source Linux dictation preview.
|
||||
Goal: a very small landing page for an experimental open-source macOS preview.
|
||||
|
||||
## Hero
|
||||
|
||||
Headline:
|
||||
|
||||
> Blitztext App Linux
|
||||
> Blitztext macOS Preview
|
||||
|
||||
Subline:
|
||||
|
||||
> Speak into any focused Linux text field. Get text, cleaner writing, or live streaming transcripts.
|
||||
> Speak your thoughts. Turn them into text, cleaner writing, or calmer messages.
|
||||
|
||||
Body:
|
||||
|
||||
> An experimental open-source Linux/X11 dictation app inspired by the original macOS Blitztext workflow. Not finished, not hosted, not plug-and-play. Built to learn from, fork, and improve.
|
||||
> An experimental open-source macOS menubar app. Not finished, not hosted, not plug-and-play. Built to learn from, fork, and improve.
|
||||
|
||||
Primary CTA:
|
||||
|
||||
@ -28,61 +28,55 @@ Secondary CTA:
|
||||
|
||||
Small line:
|
||||
|
||||
> Local faster-whisper, optional Riva/NIM realtime STT, optional OpenAI-compatible rewriting. No hosted Blitztext backend.
|
||||
> Bring your own OpenAI API key. Optional local transcription. No hosted Blitztext backend.
|
||||
|
||||
## Sections
|
||||
|
||||
1. What it does
|
||||
- Dictate into the focused app
|
||||
- Improve or rewrite rough speech
|
||||
- Calm down messages
|
||||
- Dictate
|
||||
- Improve
|
||||
- Calm down
|
||||
- Add emojis
|
||||
- Stream live STT through Riva/NIM
|
||||
|
||||
2. How it works
|
||||
- Install the Linux app or run from source
|
||||
- Configure a local or remote STT engine
|
||||
- Build the app locally
|
||||
- Paste your own OpenAI API key
|
||||
- Press a hotkey and speak
|
||||
- Blitztext types into the focused X11 window
|
||||
- Get text back on the clipboard
|
||||
|
||||
3. Open-source preview
|
||||
- Linux/X11 first
|
||||
- macOS-only
|
||||
- MIT License
|
||||
- inspired by `cmagnussen/blitztext-app`
|
||||
- experimental
|
||||
- no warranty
|
||||
- no hosted backend
|
||||
- optional local transcription with user-installed WhisperKit models
|
||||
|
||||
4. Privacy, plainly
|
||||
- local batch STT can stay on device
|
||||
- realtime STT goes to the Riva/NIM endpoint you configure
|
||||
- rewriting goes to the OpenAI-compatible endpoint you configure
|
||||
- online workflows send audio and text to OpenAI
|
||||
- secure local mode keeps transcription on device
|
||||
- no public Blitztext backend
|
||||
- rewriting still uses OpenAI
|
||||
- do your own review before sensitive use
|
||||
|
||||
5. Roadmap
|
||||
- clearer setup
|
||||
- current Linux screenshots
|
||||
- Wayland support exploration
|
||||
- better streaming diagnostics
|
||||
- basic tests and release checks
|
||||
- easier setup
|
||||
- signed releases
|
||||
- local transcription experiments
|
||||
- clearer setup and security docs
|
||||
- community issues
|
||||
|
||||
## Do Not Promise
|
||||
|
||||
- offline use
|
||||
- production readiness
|
||||
- hosted service
|
||||
- free usage without API or infrastructure costs
|
||||
- no data leaves the device by default
|
||||
- free usage without API costs
|
||||
- no data leaves the device
|
||||
- guaranteed support
|
||||
- macOS support in the Linux app
|
||||
- bundled STT models
|
||||
- local rewriting unless the user configures a local LLM endpoint
|
||||
- other platforms
|
||||
- bundled local models
|
||||
- local rewriting
|
||||
|
||||
## Visual Direction
|
||||
|
||||
Use real Linux screenshots or a short demo GIF: GTK control panel, Settings > Engines, Benchmark, and About. Keep the page calm, sparse, and honest. Avoid fake metrics, oversized SaaS claims, and corporate origin story.
|
||||
|
||||
## Legal Links
|
||||
|
||||
- Impressum: https://martin-bierschenk.de/impressum/
|
||||
- Datenschutz / Privacy: https://martin-bierschenk.de/datenschutz/
|
||||
Use a real macOS screenshot or short demo GIF. Keep the page calm, sparse, and honest. Avoid fake metrics, oversized SaaS claims, and corporate origin story.
|
||||
|
||||
@ -1,78 +1,72 @@
|
||||
# Local And Realtime Speech Models
|
||||
# Local Models
|
||||
|
||||
Blitztext App Linux supports two local-first speech paths:
|
||||
Blitztext can run transcription locally with WhisperKit/CoreML. The app does not bundle a speech model, but it can download the selected compatible model from Hugging Face into the local cache.
|
||||
|
||||
- in-process `faster-whisper` for batch transcription
|
||||
- external Riva/NIM realtime servers for live streaming transcription
|
||||
## Recommended First Model
|
||||
|
||||
The app does not bundle speech models. You choose the model in Settings or in `~/.config/blitztext/config.toml`.
|
||||
Use Whisper Small for the first local test. It is multilingual, supports German, and is much lighter than the large variants.
|
||||
|
||||
## Local Batch Transcription
|
||||
- [argmaxinc/whisperkit-coreml: openai_whisper-small_216MB](https://huggingface.co/argmaxinc/whisperkit-coreml/tree/main/openai_whisper-small_216MB)
|
||||
|
||||
The default local engine uses `faster-whisper`.
|
||||
|
||||
Recommended first model:
|
||||
|
||||
```toml
|
||||
[whisper]
|
||||
model = "small"
|
||||
device = "auto"
|
||||
compute_type = "auto"
|
||||
```
|
||||
|
||||
Useful model sizes:
|
||||
|
||||
- `tiny`: fastest, lowest quality
|
||||
- `base`: small and responsive
|
||||
- `small`: good first default for dictation
|
||||
- `medium`: better quality, slower
|
||||
- `large-v3`: highest quality, much heavier
|
||||
|
||||
You can also use a local model path supported by `faster-whisper`.
|
||||
|
||||
## Realtime Riva/NIM Transcription
|
||||
|
||||
For live words while speaking, use a `riva_realtime` STT engine. The tested Nemotron ASR Streaming NIM exposes a WebSocket endpoint through `/v1/realtime` and reports this model:
|
||||
Local cache path:
|
||||
|
||||
```text
|
||||
cache-aware-parakeet-rnnt-en-US-asr-streaming-sortformer
|
||||
~/Library/Application Support/Blitztext/models/whisperkit/openai_whisper-small_216MB
|
||||
```
|
||||
|
||||
Recommended engine config:
|
||||
## Other Compatible Models
|
||||
|
||||
```toml
|
||||
[[stt_engine]]
|
||||
name = "Nemotron ASR Streaming"
|
||||
type = "riva_realtime"
|
||||
url = "http://127.0.0.1:8006/v1"
|
||||
model = ""
|
||||
You can also install larger WhisperKit CoreML models into the same cache directory:
|
||||
|
||||
- [openai_whisper-large-v3-v20240930_626MB](https://huggingface.co/argmaxinc/whisperkit-coreml/tree/main/openai_whisper-large-v3-v20240930_626MB)
|
||||
- [openai_whisper-large-v3-v20240930_turbo_632MB](https://huggingface.co/argmaxinc/whisperkit-coreml/tree/main/openai_whisper-large-v3-v20240930_turbo_632MB)
|
||||
|
||||
The app detects installed model folders that contain `AudioEncoder.mlmodelc`, `MelSpectrogram.mlmodelc`, and `TextDecoder.mlmodelc`.
|
||||
|
||||
## Install From The App
|
||||
|
||||
Open Blitztext, go to **Settings > Anpassen**, choose a local model, and click **Installieren**. You can also switch on **Sicherer Lokaler Modus** from the main popover; if the selected model is missing, Blitztext starts the download and installs it into the local cache.
|
||||
|
||||
After the model is installed, the Blitztext transcription workflow can run in local mode. The rewriting workflows still use OpenAI, so they are paused while secure local mode is active.
|
||||
|
||||
## Optional Manual Install
|
||||
|
||||
If you prefer the CLI path, install the Hugging Face CLI so the `hf` command is available:
|
||||
|
||||
```bash
|
||||
python3 -m pip install --upgrade "huggingface_hub[cli]"
|
||||
```
|
||||
|
||||
Use `model = ""` to keep the server default. For the tested Nemotron container, set the general language to English:
|
||||
Create the local model cache:
|
||||
|
||||
```toml
|
||||
[general]
|
||||
language = "en"
|
||||
```bash
|
||||
mkdir -p "$HOME/Library/Application Support/Blitztext/models/whisperkit"
|
||||
```
|
||||
|
||||
## Batch NIMs And Other STT Servers
|
||||
Download the recommended first model:
|
||||
|
||||
Use `type = "openai"` only for servers that implement batch `/v1/audio/transcriptions` correctly.
|
||||
|
||||
Example:
|
||||
|
||||
```toml
|
||||
[[stt_engine]]
|
||||
name = "Parakeet batch ASR"
|
||||
type = "openai"
|
||||
url = "http://127.0.0.1:8090/v1"
|
||||
model = "parakeet-tdt-0.6b-v3"
|
||||
```bash
|
||||
hf download argmaxinc/whisperkit-coreml \
|
||||
--include 'openai_whisper-small_216MB/*' \
|
||||
--local-dir "$HOME/Library/Application Support/Blitztext/models/whisperkit" \
|
||||
--max-workers 4
|
||||
```
|
||||
|
||||
Streaming-only NIMs may still show `/v1/audio/transcriptions` in Swagger, but return `bad model` or `No Offline ASR models found`. Use `riva_realtime` for those.
|
||||
Expected folder layout:
|
||||
|
||||
```text
|
||||
~/Library/Application Support/Blitztext/models/whisperkit/
|
||||
openai_whisper-small_216MB/
|
||||
AudioEncoder.mlmodelc/
|
||||
MelSpectrogram.mlmodelc/
|
||||
TextDecoder.mlmodelc/
|
||||
```
|
||||
|
||||
If the folder is nested differently, the app will not detect the model.
|
||||
|
||||
## Notes
|
||||
|
||||
- First local Whisper use can be slower because the model has to load or download.
|
||||
- Realtime streaming needs `sounddevice` and `websockets`; both are in `linux/requirements.txt`.
|
||||
- The benchmark tab is for batch engines. Streaming engines are live-only and are not benchmarked with WAV uploads.
|
||||
- First use can be slower because the model has to load and prewarm.
|
||||
- Local transcription avoids sending audio to OpenAI for the Blitztext workflow.
|
||||
- The app currently supports local transcription only, not local rewriting.
|
||||
- Models are downloaded on demand so the repository and app package stay small and auditable.
|
||||
|
||||
@ -1,18 +1,15 @@
|
||||
# Open Source Preflight
|
||||
|
||||
Use this checklist before making `blitztext-app-linux` public or cutting a public preview release.
|
||||
Use this checklist before making the repository public.
|
||||
|
||||
## P0 Before Public
|
||||
|
||||
- Run a local source install from `linux/install.sh`.
|
||||
- Run `linux/.venv/bin/python -m py_compile` across the app package.
|
||||
- Build a `.deb` with `linux/packaging/build-deb.sh` and install it on a clean Ubuntu/Debian test machine.
|
||||
- Run a local build with `./build.sh --debug`.
|
||||
- Run a secret scan across the working tree and commit history.
|
||||
- Confirm there are no private URLs, hosted backend credentials, internal docs, private recordings, or confidential transcripts.
|
||||
- Confirm old macOS-only claims have been replaced with Linux/X11 wording.
|
||||
- Confirm the root `LICENSE`, `README.md`, `SECURITY.md`, `CONTRIBUTING.md`, `SUPPORT.md`, and `TRADEMARKS.md` are present.
|
||||
- Keep the preview status explicit: experimental, no hosted backend, no warranty, no support guarantee.
|
||||
- Credit the inspiration project: `cmagnussen/blitztext-app`.
|
||||
- Confirm there are no private URLs, hosted backend credentials, internal docs, or old project references.
|
||||
- Keep the repository private until another maintainer has reviewed the first public commit.
|
||||
- Confirm the root `LICENSE`, `README.md`, `SECURITY.md`, `CONTRIBUTING.md`, and `SUPPORT.md` are present.
|
||||
- Make the preview status explicit: experimental, bring your own OpenAI API key, no hosted backend, no warranty.
|
||||
- Enable GitHub private vulnerability reporting, secret scanning, and push protection before switching the repo public.
|
||||
- Enable Dependabot alerts.
|
||||
- Protect `main` with pull requests, at least one review, and required CI checks.
|
||||
@ -20,15 +17,15 @@ Use this checklist before making `blitztext-app-linux` public or cutting a publi
|
||||
|
||||
## P1 Soon After Public
|
||||
|
||||
- Enable private vulnerability reporting.
|
||||
- Decide whether Issues alone are enough or whether Discussions should be enabled for questions.
|
||||
- Add repository topics such as `linux`, `dictation`, `speech-to-text`, `gtk`, `x11`, `faster-whisper`, `riva`, `nim`, and `openai-compatible`.
|
||||
- Add current Linux screenshots to `docs/screenshots/`.
|
||||
- Add a small test layer for config parsing, workflow routing, and streaming URL handling.
|
||||
- Add release notes for `.deb` artifacts.
|
||||
- Add repository topics such as `macos`, `swift`, `menubar`, `speech-to-text`, and `openai`.
|
||||
- Add a lightweight release process only after the build is signed and notarized.
|
||||
- Add basic tests once provider boundaries are extracted.
|
||||
|
||||
## P2 Later
|
||||
|
||||
- Add CODEOWNERS if multiple maintainers become active.
|
||||
- Add local model cleanup after the in-app download/install flow.
|
||||
- Consider CodeQL once the repo has enough surface area to justify scheduled scans.
|
||||
- Add Wayland support notes or implementation.
|
||||
- Add signed release artifacts if the project becomes useful beyond developer previews.
|
||||
- Add signed and notarized release artifacts for non-developer testers.
|
||||
|
||||
@ -1,46 +1,36 @@
|
||||
# Privacy Notes
|
||||
|
||||
Blitztext App Linux does not include a hosted backend.
|
||||
Blitztext macOS Preview does not include a hosted backend.
|
||||
|
||||
Data goes only to the engines and endpoints you configure:
|
||||
When you use the online workflows, your Mac sends data directly to OpenAI:
|
||||
|
||||
- local `faster-whisper` for local batch transcription
|
||||
- your configured OpenAI-compatible STT endpoint for remote batch transcription
|
||||
- your configured Riva/NIM realtime endpoint for streaming transcription
|
||||
- your configured OpenAI-compatible chat endpoint for rewriting workflows
|
||||
- audio recordings for transcription
|
||||
- transcribed or typed text for rewriting
|
||||
- custom terms and prompt context if you configured them
|
||||
|
||||
You are responsible for API access, billing, endpoint security, and data handling for any remote or local service you connect.
|
||||
When **Sicherer Lokaler Modus** is enabled and a WhisperKit/CoreML model is installed, transcription runs on your Mac and does not send audio to OpenAI. Rewriting workflows still require OpenAI and are paused while secure local mode is active.
|
||||
|
||||
You are responsible for your OpenAI account, API usage, costs, and data handling.
|
||||
|
||||
## Local Data
|
||||
|
||||
The app stores:
|
||||
|
||||
- workflow, hotkey, engine, model, microphone, and UI settings in `~/.config/blitztext/config.toml`
|
||||
- temporary audio files while a batch transcription is being processed; the app attempts to delete each recording when the workflow ends or is cancelled
|
||||
- local Python dependencies in the source venv or bundled package venv
|
||||
- your OpenAI API key in the user's macOS Keychain
|
||||
- workflow settings in local app support storage
|
||||
- optional WhisperKit/CoreML model folders in local app support storage
|
||||
- temporary audio files while a transcription is being processed; the app attempts to delete each recording when the workflow ends or is cancelled
|
||||
|
||||
Blitztext does not store API keys itself. Instead, config entries name environment variables such as `OPENAI_API_KEY`; you provide those variables in your shell, service, or desktop environment.
|
||||
Workflow output may also be placed on your clipboard so it can be pasted into another app. Auto-paste marks the clipboard entry as concealed for compatible clipboard managers, but the generated text intentionally remains on the clipboard as a fallback if automatic paste is blocked. Clipboard managers, macOS, or other apps may still observe clipboard contents while they are present.
|
||||
|
||||
Workflow output may be typed directly into the focused X11 window or placed on the clipboard, depending on the configured output mode. Clipboard managers and other apps may observe clipboard contents while they are present.
|
||||
The app uses the system TLS trust store for OpenAI and Hugging Face requests. It does not currently pin certificates. A user-installed or managed root certificate can therefore affect HTTPS trust decisions on that Mac.
|
||||
|
||||
## Network Data Flow
|
||||
|
||||
```text
|
||||
Local batch STT: microphone -> temporary WAV -> local faster-whisper
|
||||
Remote batch STT: microphone -> temporary WAV -> configured /audio/transcriptions endpoint
|
||||
Realtime STT: microphone PCM chunks -> configured Riva/NIM realtime WebSocket endpoint
|
||||
Rewrite workflows: transcript text -> configured OpenAI-compatible chat endpoint
|
||||
Delivery: generated text -> xdotool / clipboard -> focused app
|
||||
```
|
||||
|
||||
The app uses your system trust store for HTTPS connections made by Python libraries. It does not pin certificates.
|
||||
Settings such as custom prompts, custom terms, and context are stored in local app support storage as plain JSON. Do not put secrets into those fields.
|
||||
|
||||
## Offline Scope
|
||||
|
||||
Batch transcription can run locally with `faster-whisper`. Realtime transcription can be local if your Riva/NIM server is local. Rewriting is local only if you configure a local OpenAI-compatible LLM endpoint.
|
||||
|
||||
Do not describe a workflow as fully offline unless every configured endpoint is local and you have verified the network path.
|
||||
Only transcription can run locally. Any workflow that rewrites, improves, or transforms text still uses OpenAI.
|
||||
|
||||
## Sensitive Content
|
||||
|
||||
Do not use this preview with confidential, regulated, or highly sensitive content unless you have reviewed the code, your local services, your remote provider settings, and your legal/privacy requirements.
|
||||
Do not use this preview with confidential, regulated, or highly sensitive content unless you have reviewed the code, your OpenAI settings, and your legal/privacy requirements.
|
||||
|
||||
BIN
docs/screenshots/local-mode.png
Normal file
|
After Width: | Height: | Size: 511 KiB |
BIN
docs/screenshots/local-model-picker.png
Normal file
|
After Width: | Height: | Size: 631 KiB |
BIN
docs/screenshots/online-mode.png
Normal file
|
After Width: | Height: | Size: 728 KiB |
BIN
docs/screenshots/settings-customize.png
Normal file
|
After Width: | Height: | Size: 567 KiB |
137
docs/setup.md
@ -1,115 +1,80 @@
|
||||
# Setup
|
||||
|
||||
This guide is for people who want to build and inspect Blitztext App Linux themselves.
|
||||
This guide is for people who want to build and inspect the preview themselves.
|
||||
|
||||
## 1. Requirements
|
||||
|
||||
- Linux desktop with an X11 session
|
||||
- Python 3.11+
|
||||
- `xdotool`
|
||||
- `notify-send` from `libnotify-bin`
|
||||
- one recorder: `pw-record`, `parecord`, or `arecord`
|
||||
- GTK/PyGObject for the tray and settings UI (`python3-gi` on Ubuntu/Debian)
|
||||
- Optional rewrite workflows: an OpenAI-compatible chat endpoint and API key if needed
|
||||
- Optional realtime STT streaming: a Riva/NIM realtime server such as Nemotron ASR Streaming
|
||||
- macOS 14 or newer
|
||||
- Full Xcode, with Command Line Tools installed
|
||||
- XcodeGen
|
||||
- Homebrew, if you want to install XcodeGen with `brew install xcodegen`
|
||||
- Optional for online workflows: an OpenAI API key
|
||||
- Optional for secure local transcription: a local WhisperKit/CoreML model
|
||||
|
||||
On Ubuntu/Debian:
|
||||
Install XcodeGen manually if needed:
|
||||
|
||||
```bash
|
||||
sudo apt install xdotool libnotify-bin pipewire-bin python3-gi
|
||||
brew install xcodegen
|
||||
```
|
||||
|
||||
## 2. Clone And Install
|
||||
## 2. Clone And Build
|
||||
|
||||
```bash
|
||||
git clone https://github.com/mARTin-B78/blitztext-app-linux.git
|
||||
cd blitztext-app-linux/linux
|
||||
./install.sh
|
||||
git clone https://github.com/cmagnussen/blitztext-app.git
|
||||
cd blitztext-app
|
||||
./build.sh --debug
|
||||
```
|
||||
|
||||
If your local repository still uses the older `blitztext-app` name, the commands are the same once you `cd linux`.
|
||||
|
||||
## 3. Run
|
||||
To launch after building:
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m blitztext tray
|
||||
./build.sh --run
|
||||
```
|
||||
|
||||
Alternatives:
|
||||
## 3. Configure OpenAI For Online Workflows
|
||||
|
||||
```bash
|
||||
.venv/bin/python -m blitztext gui
|
||||
.venv/bin/python -m blitztext run
|
||||
.venv/bin/python -m blitztext config-path
|
||||
Open the app settings and paste your own OpenAI API key if you want online transcription or rewriting workflows.
|
||||
|
||||
The preview currently uses:
|
||||
|
||||
- `whisper-1` for transcription
|
||||
- `gpt-4o-mini` for lightweight rewriting
|
||||
- `gpt-4o` for the calmer-message workflow
|
||||
|
||||
You are responsible for API access, billing, and data handling in your own OpenAI account.
|
||||
|
||||
Never commit your API key into this repository, issues, logs, or screenshots.
|
||||
|
||||
You can skip this step if you only want to test local transcription with a local WhisperKit model.
|
||||
|
||||
## 4. Optional Local Transcription
|
||||
|
||||
To use secure local transcription, choose a compatible WhisperKit CoreML model in the app and click **Installieren**. Blitztext stores models in:
|
||||
|
||||
```text
|
||||
~/Library/Application Support/Blitztext/models/whisperkit/
|
||||
```
|
||||
|
||||
## 4. Debian Package
|
||||
Recommended first model: `openai_whisper-small_216MB`.
|
||||
|
||||
```bash
|
||||
cd linux
|
||||
bash packaging/build-deb.sh
|
||||
sudo apt install ./dist/blitztext_*.deb
|
||||
blitztext tray
|
||||
```
|
||||
See [local-models.md](local-models.md) for the exact command, model links, and expected folder layout.
|
||||
|
||||
The package installs Blitztext under `/opt/blitztext`, adds a desktop entry, and bundles the Python dependencies from `requirements.txt`.
|
||||
## 5. macOS Permissions
|
||||
|
||||
## 5. Configure STT Engines
|
||||
The app needs Microphone permission to record audio.
|
||||
|
||||
Open **Settings > Engines**.
|
||||
For automatic paste into the previous app, grant Accessibility permission in macOS System Settings. Without it, you can still copy and paste manually.
|
||||
|
||||
Common options:
|
||||
|
||||
- `local`: in-process `faster-whisper`
|
||||
- `openai`: OpenAI-compatible batch `/audio/transcriptions` endpoint
|
||||
- `riva_realtime`: Riva/NIM realtime WebSocket transcription for `mode = "stream"`
|
||||
|
||||
For Nemotron ASR Streaming:
|
||||
|
||||
```toml
|
||||
[[stt_engine]]
|
||||
name = "Nemotron ASR Streaming"
|
||||
type = "riva_realtime"
|
||||
url = "http://127.0.0.1:8006/v1"
|
||||
model = ""
|
||||
```
|
||||
|
||||
Then create or edit a workflow with:
|
||||
|
||||
```toml
|
||||
mode = "stream"
|
||||
```
|
||||
|
||||
## 6. Configure Rewrite Workflows
|
||||
|
||||
Rewrite workflows use an OpenAI-compatible chat endpoint. You can point them at OpenAI, LiteLLM, llama-swap, vLLM, LM Studio, or another compatible server.
|
||||
|
||||
For OpenAI:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
Then set the LLM engine in **Settings > Engines** or edit `~/.config/blitztext/config.toml`.
|
||||
|
||||
Never commit API keys into this repository, issues, logs, or screenshots.
|
||||
|
||||
## 7. Permissions And Desktop Session
|
||||
|
||||
Blitztext needs microphone access through your Linux audio stack and uses `xdotool` to type into the currently focused X11 window.
|
||||
|
||||
If text delivery does not work:
|
||||
|
||||
- confirm you are on X11, not Wayland
|
||||
- check that `xdotool getactivewindow` works in a terminal
|
||||
- focus a normal text field before triggering a workflow
|
||||
- try `output = "paste"` or `output = "type"` in config
|
||||
Blitztext does not need Full Disk Access. Auto-paste uses the Accessibility permission because the app simulates Cmd+V after putting the result on the clipboard.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- If the tray does not start, confirm `python3-gi` is visible to the venv. `install.sh` uses `--system-site-packages` for this reason.
|
||||
- If local Whisper is slow on arm64, use a smaller model such as `small`, `base`, or `tiny`.
|
||||
- If realtime streaming connects but produces poor text, confirm the server language. The tested Nemotron ASR Streaming NIM is `en-US`.
|
||||
- If a batch STT endpoint returns `bad model`, check whether it is actually a streaming-only NIM. Use `riva_realtime` for realtime services and `openai` only for batch-compatible services.
|
||||
- If audio is missing, check the selected microphone in **Settings > General** and watch the input level meter.
|
||||
- If rewriting fails, verify your LLM endpoint, model name, API key environment variable, and account billing if using a cloud provider.
|
||||
- If `xcodebuild` reports that the active developer directory is only Command Line Tools, run `sudo xcode-select -s /Applications/Xcode.app/Contents/Developer`.
|
||||
- If the build cannot find XcodeGen, install it explicitly with `brew install xcodegen`.
|
||||
- If online transcription fails immediately, check whether the API key is present and valid.
|
||||
- If secure local mode is disabled, check whether a WhisperKit model is installed in the expected folder.
|
||||
- If transcription works but paste does not, this is not an OpenAI billing issue. Check **Privacy & Security -> Accessibility**, restart Blitztext after changing the permission, and make sure the cursor is focused in a text field before starting the workflow.
|
||||
- If macOS shows multiple Blitztext entries under Accessibility, remove or disable stale entries, run the app from the final location (`/Applications` if you used `./build.sh --install`), then grant the permission again.
|
||||
- If the target app blocks synthetic paste or the target app was not detected, the result still stays on the clipboard so you can press Cmd+V manually.
|
||||
- If audio is missing, check Microphone permission and macOS input settings.
|
||||
- If you see OpenAI errors, verify model access and account billing.
|
||||
|
||||
100
install-linux.sh
@ -1,100 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Install Blitztext for Linux from GitHub.
|
||||
#
|
||||
# Usage (any Ubuntu/Debian machine):
|
||||
# curl -fsSL https://raw.githubusercontent.com/mARTin-B78/blitztext-app-linux/main/install-linux.sh | bash
|
||||
#
|
||||
# Or clone first and run locally:
|
||||
# git clone https://github.com/mARTin-B78/blitztext-app-linux.git
|
||||
# bash blitztext-app-linux/install-linux.sh
|
||||
#
|
||||
# What it does:
|
||||
# 1. Installs build tools (git, fakeroot, dpkg-dev) if missing
|
||||
# 2. Clones the repo to a temp directory
|
||||
# 3. Builds a .deb package
|
||||
# 4. Installs it with apt (pulls in all runtime dependencies)
|
||||
# 5. Cleans up the temp directory
|
||||
#
|
||||
# After install:
|
||||
# - "Blitztext" appears in your app grid
|
||||
# - Or run: blitztext tray
|
||||
# - Remove: sudo apt remove blitztext
|
||||
set -euo pipefail
|
||||
|
||||
REPO="https://github.com/mARTin-B78/blitztext-app-linux.git"
|
||||
BRANCH="${BRANCH:-main}"
|
||||
|
||||
# --- helpers ----------------------------------------------------------------
|
||||
info() { echo -e "\033[1;34m==>\033[0m \033[1m$*\033[0m"; }
|
||||
ok() { echo -e "\033[1;32m==>\033[0m \033[1m$*\033[0m"; }
|
||||
fail() { echo -e "\033[1;31m==>\033[0m \033[1m$*\033[0m" >&2; exit 1; }
|
||||
|
||||
command_exists() { command -v "$1" &>/dev/null; }
|
||||
|
||||
# --- preflight --------------------------------------------------------------
|
||||
info "Blitztext for Linux — installer"
|
||||
|
||||
# Detect package manager
|
||||
if command_exists apt; then
|
||||
PKG=apt
|
||||
elif command_exists apt-get; then
|
||||
PKG=apt-get
|
||||
else
|
||||
fail "This installer requires apt (Ubuntu/Debian). For other distros, install from source: see the README."
|
||||
fi
|
||||
|
||||
# Ensure we can sudo
|
||||
if ! sudo -n true 2>/dev/null; then
|
||||
info "This installer needs sudo to install packages."
|
||||
sudo true || fail "sudo failed."
|
||||
fi
|
||||
|
||||
# --- install build dependencies ---------------------------------------------
|
||||
BUILD_DEPS=()
|
||||
command_exists git || BUILD_DEPS+=(git)
|
||||
command_exists fakeroot || BUILD_DEPS+=(fakeroot)
|
||||
command_exists dpkg-deb || BUILD_DEPS+=(dpkg-dev)
|
||||
|
||||
if ((${#BUILD_DEPS[@]})); then
|
||||
info "Installing build tools: ${BUILD_DEPS[*]}"
|
||||
sudo $PKG install -y "${BUILD_DEPS[@]}"
|
||||
fi
|
||||
|
||||
# --- clone ------------------------------------------------------------------
|
||||
TMPDIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
|
||||
info "Cloning $REPO ($BRANCH)…"
|
||||
git clone --depth 1 --branch "$BRANCH" "$REPO" "$TMPDIR/blitztext-app-linux"
|
||||
|
||||
# --- build .deb -------------------------------------------------------------
|
||||
info "Building .deb package…"
|
||||
LINUX_DIR="$TMPDIR/blitztext-app-linux/linux"
|
||||
OUT_DIR="$TMPDIR/out"
|
||||
mkdir -p "$OUT_DIR"
|
||||
OUT_DIR="$OUT_DIR" bash "$LINUX_DIR/packaging/build-deb.sh"
|
||||
|
||||
DEB="$(ls "$OUT_DIR"/*.deb 2>/dev/null | head -1)"
|
||||
if [ -z "$DEB" ] || [ ! -f "$DEB" ]; then
|
||||
fail "Build failed — no .deb produced."
|
||||
fi
|
||||
|
||||
# --- install ----------------------------------------------------------------
|
||||
info "Installing $(basename "$DEB")…"
|
||||
sudo $PKG install -y "$DEB"
|
||||
|
||||
# --- done -------------------------------------------------------------------
|
||||
VER="$(dpkg -s blitztext 2>/dev/null | grep '^Version:' | cut -d' ' -f2)"
|
||||
ok "Blitztext ${VER:-} installed!"
|
||||
echo ""
|
||||
echo " Launch from your app grid, or run:"
|
||||
echo " blitztext tray # system tray (recommended)"
|
||||
echo " blitztext gui # control-panel window"
|
||||
echo " blitztext run # headless, hotkeys only"
|
||||
echo ""
|
||||
echo " For rewrite workflows, set your API key first:"
|
||||
echo " export OPENAI_API_KEY=sk-..."
|
||||
echo ""
|
||||
echo " Remove:"
|
||||
echo " sudo apt remove blitztext"
|
||||
echo ""
|
||||
1
linux/.gitignore
vendored
@ -1,4 +1,3 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
dist/
|
||||
|
||||
@ -9,812 +9,6 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2.03.41] - 2026-06-10
|
||||
|
||||
### Added
|
||||
- **Cancel button shown during transcription and rewriting.** The `×` button in
|
||||
the top-right corner of the overlay is now visible and clickable in the
|
||||
`busy` state (Transcribing… / Rewriting…), not only while recording.
|
||||
Clicking it during transcription discards the result once the STT call
|
||||
returns. Clicking it during a rewrite breaks out of the LLM stream
|
||||
immediately — the partial text is discarded and nothing is typed.
|
||||
|
||||
## [2.03.40] - 2026-06-10
|
||||
|
||||
### Changed
|
||||
- **Documentation overhaul with screenshots.** Renamed 21 raw screenshots to
|
||||
meaningful filenames (`main-panel.png`, `overlay-listening.png`,
|
||||
`settings-presets.png`, `settings-general.png`, `settings-keyboard.png`,
|
||||
`settings-wakeword.png`, `settings-stt-engines.png`,
|
||||
`settings-llm-engines.png`, `settings-benchmark-stt.png`,
|
||||
`settings-benchmark-wakeword.png`, `settings-log.png`,
|
||||
`settings-manual.png`, `settings-about.png`, and 7 setup-wizard screens).
|
||||
- **README.md / linux/README.md** updated with organized screenshot sections
|
||||
(Main panel & overlay, Settings — General & Input, Settings — Engines,
|
||||
Settings — Benchmark, Settings — Log & About, Setup wizard) using
|
||||
click-to-enlarge image links.
|
||||
- **MANUAL.md** rewritten to match the new sidebar navigation: intro updated;
|
||||
section headers renamed (Engines tab → STT Engines / LLM Engines; Input tab
|
||||
→ Keyboard + Wakeword; Benchmark tab → Benchmark — STT / Benchmark —
|
||||
Wakeword); screenshot added at the top of every section including Manual and
|
||||
About pages.
|
||||
|
||||
## [2.03.39] - 2026-06-10
|
||||
|
||||
### Fixed
|
||||
- **Remaining horizontal scrollbars eliminated.** Root causes: (1) infobox
|
||||
`max_width_chars` was 72 — at typical system font sizes (9 px/char) this
|
||||
produced a natural width wider than the ~677 px content area; reduced to 58.
|
||||
(2) Engine-selector combos (`stt_combo`, `llm_combo`, `ww_combo`) and the
|
||||
`_combo()` helper had no constraint on CellRendererText width — long engine
|
||||
names or ALSA device names drove the combo's natural width to 300–500 px.
|
||||
Fixed by adding `_ellipsize_combo()` (sets `ellipsize=END` and
|
||||
`max-width-chars=28` on the internal CellRendererText) to all combos.
|
||||
(3) `_STT_TYPES`, `_LLM_TYPES`, `_DEVICE_OPTIONS`, `_COMPUTE_OPTIONS`
|
||||
labels were 40–52 characters; shortened to ≤27 chars.
|
||||
|
||||
### Changed
|
||||
- **Wakeword Cancel/Send word rows split into two rows.** Keywords and
|
||||
keyboard shortcut are now on separate lines inside the card, avoiding the
|
||||
cramped single-row layout.
|
||||
- **Benchmark — Wakeword: "Run wakeword benchmark" button moved** from the
|
||||
bottom of the settings pane to the top of the results pane; pane divider
|
||||
adjusted from 390 → 340 px. The button is now always visible without
|
||||
scrolling and sits logically above the results it produces.
|
||||
|
||||
## [2.03.38] - 2026-06-10
|
||||
|
||||
### Fixed
|
||||
- **STT Engines and Wakeword no longer have horizontal scrollbars.** Root
|
||||
cause: `Gtk.Entry` widgets compute natural width from placeholder text
|
||||
(e.g. `"http://localhost:8010/v1 · realtime: http://localhost:8006/v1"`
|
||||
≈ 500 px). Without `set_width_chars(1)` the entry cannot shrink below its
|
||||
natural width even when placed in an expanding container. Added
|
||||
`set_width_chars(1)` to all entry-creating helpers: `_entry()`, `_url_field()`,
|
||||
`ModelPicker`, `_kw_shortcut_row`, and `_sound_field`. Also added
|
||||
`set_max_width_chars(50)` to the `stt_result` wrapping label.
|
||||
|
||||
## [2.03.37] - 2026-06-10
|
||||
|
||||
### Fixed
|
||||
- **STT Engines page no longer appears empty.** `_refresh_status()` called
|
||||
`_stt_commit()` / `_llm_commit()` and accessed `stt_dot` / `llm_dot`
|
||||
unconditionally; if the STT page was opened before the LLM page was built
|
||||
(lazy), the builder crashed silently with `AttributeError`. Added `hasattr`
|
||||
guards so each section is only committed / updated when its widgets exist.
|
||||
- **Wakeword page no longer causes horizontal scrollbar.** The `ww_status`
|
||||
label (showing model list like "7 models loaded: okay_nabu, hey_jarvis…")
|
||||
had no width limit and expanded the page. Added `set_max_width_chars(30)`
|
||||
and `set_ellipsize(END)`.
|
||||
- **Benchmark STT engine list no longer causes horizontal scrollbar.**
|
||||
`sel_sw` used `NEVER` horizontal policy, propagating long engine-name labels
|
||||
(~800 px) up through the paned. Changed to `AUTOMATIC` so content scrolls
|
||||
internally.
|
||||
- **General page and LLM Engines no longer cause horizontal scrollbar.**
|
||||
`_combo()` and `_type_combo()` lacked `set_size_request(10, -1)`, so
|
||||
ComboBoxText widgets (e.g. long microphone device names) could not shrink
|
||||
below their natural width. Added the size request to both helpers.
|
||||
|
||||
## [2.03.36] - 2026-06-10
|
||||
|
||||
### Changed
|
||||
- **STT Engines split from LLM Engines.** The single "STT Engines" sidebar
|
||||
page is now two pages — "STT Engines" (speech-to-text configuration) and
|
||||
"LLM Engines" (language model / rewrite configuration) — each with its
|
||||
own infobox. The underlying `_stt_section()` and `_llm_section()` methods
|
||||
are unchanged.
|
||||
|
||||
## [2.03.35] - 2026-06-10
|
||||
|
||||
### Fixed
|
||||
- **Settings pages no longer widen the dialog.** `_switch_row` description
|
||||
labels had `set_line_wrap(True)` but no `set_max_width_chars`, so GTK
|
||||
computed their natural width as the full un-wrapped text (87 chars × ~8 px
|
||||
= ~700 px). With `NEVER` horizontal policy on the page `ScrolledWindow`,
|
||||
that propagated directly to the dialog width, making Keyboard, Wakeword,
|
||||
STT Engines, and Benchmark pages ~1000–1360 px wide. Fixed by adding
|
||||
`set_max_width_chars(50)` (≈ 375 px) to description labels, and changed the
|
||||
page `ScrolledWindow` horizontal policy from `NEVER` to `AUTOMATIC` as a
|
||||
safety net for any other wide widget.
|
||||
|
||||
## [2.03.34] - 2026-06-10
|
||||
|
||||
### Fixed
|
||||
- **Settings window no longer grows wide or tall when switching pages.**
|
||||
`Stack.set_homogeneous(True)` was causing the Stack to request the maximum
|
||||
natural size of all its children (both width and height), so pages like
|
||||
Benchmark — STT (wide TreeView) inflated the dialog to 1000+ px wide for
|
||||
every other page. Reverted to `False`; the dialog now stays at its default
|
||||
860 × 700 and each page scrolls if its content is taller than the window.
|
||||
Also removed the erroneous `NEVER/NEVER` ScrolledWindow policy on the two
|
||||
benchmark pages that was propagating natural TreeView width to the Stack.
|
||||
|
||||
## [2.03.33] - 2026-06-10
|
||||
|
||||
### Fixed
|
||||
- **Settings window no longer resizes when switching pages.** Added
|
||||
`Gtk.Stack.set_homogeneous(True)` so the dialog always allocates the
|
||||
maximum page height, preventing the window from growing or shrinking
|
||||
as pages are visited.
|
||||
- **Benchmark — Wakeword: "Engines to test" section no longer hidden.**
|
||||
The paned divider position was raised from 260 to 390 px so all TTS
|
||||
config fields, the engine checkboxes, wakeword model selector, sample
|
||||
count, and run button are fully visible without scrolling. Both
|
||||
benchmark pages also disable the page-level `ScrolledWindow` so the
|
||||
paned correctly fills the viewport height rather than expanding
|
||||
past it.
|
||||
|
||||
## [2.03.32] - 2026-06-10
|
||||
|
||||
### Changed
|
||||
- **Settings dialog navigation redesigned.** Replaced the `Gtk.Notebook` tab bar
|
||||
with a 170 px left sidebar (`Gtk.ListBox` of flat buttons with section headers)
|
||||
and a `Gtk.Stack` for the content area. The dialog is now 860 × 700 px by default.
|
||||
Lazy-loading is preserved: each page is built only on first visit.
|
||||
- **Input page split into Keyboard and Wakeword.** The former "Input" tab is now
|
||||
two separate pages — "Keyboard" (input mode, hotkeys, quality gate, audio cues)
|
||||
and "Wakeword" (enable switch, mic level, test button, silence timeout, cancel/send
|
||||
words, engine preset selector, engine config card, wakeword sound cues). Either
|
||||
page may be visited first; the mic level-meter starts on whichever is opened.
|
||||
- **Benchmark page split into Benchmark — STT and Benchmark — Wakeword.** The
|
||||
single "Benchmark" tab is now two dedicated pages. All field names and collect
|
||||
logic are unchanged.
|
||||
## [2.03.31] - 2026-06-10
|
||||
|
||||
### Added
|
||||
- **First-run setup wizard.** A paged GTK dialog (`setup_wizard.py`) guides
|
||||
new users through five steps: trigger method (keyboard / wakeword / both),
|
||||
keyboard shortcut assignment (with live key capture), wakeword server
|
||||
configuration (with connection test), speech-to-text engine selection
|
||||
(local Whisper model size or remote API), and optional AI rewriting (LLM
|
||||
endpoint + model). Navigation has Back, Next, and Skip buttons. The wizard
|
||||
shows automatically on first launch (before the daemon starts) and can be
|
||||
reopened via the "Setup Wizard…" button in the Settings header bar.
|
||||
Completing the wizard sets `setup_complete = true` in the config so it
|
||||
does not reappear.
|
||||
|
||||
## [2.03.30] - 2026-06-10
|
||||
|
||||
### Added
|
||||
- **Dedicated wakeword models for cancel and send.** Two new optional fields in
|
||||
the wakeword settings card — "Cancel model" and "Send model" — let you assign
|
||||
a specific wakeword model (e.g. a custom "stop" or "send it" ONNX model) to
|
||||
each action. When configured, a `WakewordActionListener` opens a second
|
||||
Wyoming connection during recording and fires the action the instant the model
|
||||
triggers — no Whisper pass, no silence timer. The Whisper-based cancel watcher
|
||||
from v2.03.29 remains as a fallback when no cancel wakeword model is set.
|
||||
Model dropdowns are populated from the same server fetch as the trigger model.
|
||||
|
||||
## [2.03.29] - 2026-06-10
|
||||
|
||||
### Changed
|
||||
- **Cancel keywords now fire immediately during wakeword recording.** A
|
||||
real-time `_CancelWatcher` accumulates raw PCM from the VAD level-meter,
|
||||
then every ~0.6 s of new audio runs a fast `beam_size=1` local transcription
|
||||
pass to check for cancel keywords. When one is found it calls
|
||||
`cancel_dictation()` instantly — no waiting for the silence timer or a full
|
||||
transcription of the whole clip. Falls back to the existing post-transcription
|
||||
check if no local transcriber is loaded or no cancel keywords are configured.
|
||||
|
||||
## [2.03.28] - 2026-06-10
|
||||
|
||||
### Fixed
|
||||
- **Dropdowns no longer change on accidental scroll.** All `ComboBoxText`
|
||||
widgets in the settings dialog now swallow scroll events so hovering over
|
||||
a combo and scrolling doesn't silently change the selected value.
|
||||
|
||||
## [2.03.27] - 2026-06-10
|
||||
|
||||
### Added
|
||||
- **Overlay × cancel button.** A small × button appears in the top-right corner
|
||||
of the on-screen waveform HUD while recording or streaming. Clicking it
|
||||
cancels the current dictation. The rest of the overlay remains fully
|
||||
click-through; only the button area receives pointer events.
|
||||
- **Cancel/Send keyboard shortcuts in Wakeword tab.** The "Cancel words" and
|
||||
"Send words" rows in the Input → Wakeword section now include an inline
|
||||
shortcut entry + "Set" button, so you can configure `key_cancel` /
|
||||
`key_send` right next to the spoken-word equivalents without visiting the
|
||||
keyboard-mode card.
|
||||
|
||||
## [2.03.26] - 2026-06-10
|
||||
|
||||
### Fixed
|
||||
- **Cancel key now works during wakeword-triggered recording.** Previously the
|
||||
`ModifierScheme` state machine stayed in "idle" when the wakeword fired
|
||||
(it bypasses the key-press path), so the cancel hotkey was silently ignored.
|
||||
It now checks `daemon.is_recording` as a fallback so it fires regardless of
|
||||
how recording started.
|
||||
|
||||
### Added
|
||||
- **"✕ Cancel recording" in the tray menu.** Always visible; grayed out when
|
||||
idle, enabled as soon as recording starts (wakeword or manual). The primary
|
||||
escape hatch when the wakeword fires on audiobook / TV audio and spoken
|
||||
cancel words can't be heard over the background audio.
|
||||
|
||||
## [2.03.25] - 2026-06-10
|
||||
|
||||
### Changed
|
||||
- **Smart Save — no restart popup anymore.** "Save" now diffs the changed
|
||||
settings against what requires a daemon restart. If only safe settings
|
||||
changed (language, sounds, LLM prompt, keywords, overlay, …) it shows
|
||||
"✓ Settings applied" inline in the header bar for 4 s and closes the
|
||||
dialog. If restart-required fields changed (STT engine, hotkeys,
|
||||
microphone, wakeword server) it shows "⚠ Saved — restart needed for: …"
|
||||
and highlights "Save & Restart" so you can act on it. No modal popups.
|
||||
|
||||
## [2.03.24] - 2026-06-10
|
||||
|
||||
### Fixed
|
||||
- **Input level meter works without visiting General first.** The level meter
|
||||
was only started inside `_build_general()` and referenced `mic_level`
|
||||
unconditionally. If Input was opened first the meter never started. Now
|
||||
`_build_input()` also starts the meter when it isn't running yet, and both
|
||||
level bars (`mic_level` in General and `ww_mic_level` in Input) are updated
|
||||
defensively via `hasattr` so either tab can be visited in any order.
|
||||
|
||||
## [2.03.23] - 2026-06-10
|
||||
|
||||
### Fixed
|
||||
- **Wakeword results table actually resizable.** The controls pane is now
|
||||
wrapped in a ScrolledWindow with `shrink=True`, so dragging the divider
|
||||
upward collapses the controls and expands the table freely.
|
||||
|
||||
## [2.03.22] - 2026-06-10
|
||||
|
||||
### Changed
|
||||
- **Wakeword benchmark uses a split pane.** The TTS config / engine selector
|
||||
controls sit in the top pane; the results table sits in the bottom pane.
|
||||
Drag the divider to give the table as much vertical space as needed.
|
||||
|
||||
## [2.03.21] - 2026-06-10
|
||||
|
||||
### Added
|
||||
- **Wakeword results table: sortable columns.** Click any column header to sort
|
||||
ascending/descending. Numeric columns (Detected, Total, Recall %, False fires,
|
||||
Time) sort numerically.
|
||||
- **Wakeword results table: CSV export.** "Copy as CSV" copies the table to the
|
||||
clipboard; "Save CSV…" opens a file chooser to write a `.csv` file.
|
||||
|
||||
## [2.03.20] - 2026-06-10
|
||||
|
||||
### Added
|
||||
- **Wakeword benchmark results table.** Results are now shown in a TreeView
|
||||
with one row per engine per voice: Engine | Wakeword | Voice | Detected |
|
||||
Total | Recall % | False fires | Time. Rows are colour-coded green/orange/red
|
||||
by recall. An aggregate "ALL (N voices)" row is appended per engine.
|
||||
|
||||
### Fixed
|
||||
- **Section header icons now vertically centred with the headline text.**
|
||||
The `.bt-section` CSS class was inadvertently applied to the icon widget,
|
||||
giving it a 14 px top margin and pushing it down. The image no longer
|
||||
receives that class; a `set_pixel_size(14)` pin ensures consistent sizing.
|
||||
|
||||
## [2.03.19] - 2026-06-10
|
||||
|
||||
### Added
|
||||
- **Wakeword engine checkboxes in benchmark.** A row of checkboxes above the
|
||||
"Run wakeword benchmark" button lets you pick which engines to include.
|
||||
All are checked by default.
|
||||
- **Wakeword model selector in benchmark.** A "Wakeword" combo lets you
|
||||
override which wakeword phrase (model) to test. Leave empty for the default
|
||||
(each engine uses its own configured model). Pick a specific model (e.g.
|
||||
`okay_computer`) to test that phrase on every selected engine.
|
||||
|
||||
## [2.03.18] - 2026-06-10
|
||||
|
||||
### Fixed
|
||||
- **TTS model dropdown no longer floods with voice names.** Servers like Kokoro
|
||||
expose each voice as a `/models` entry. The ⟳ button now detects this case
|
||||
and skips filling the model combo, prompting the user to type the model id
|
||||
manually (e.g. `kokoro`). The status line shows "type model id manually" as
|
||||
a hint.
|
||||
|
||||
### Changed
|
||||
- **Wakeword benchmark runs across all engines and shows per-engine results.**
|
||||
Previously a callback signature mismatch caused the benchmark to crash when
|
||||
more than one engine was configured. Now progress shows `[1/3] engine name`,
|
||||
and the results panel lists Recall / False fires / time per engine.
|
||||
|
||||
## [2.03.17] - 2026-06-10
|
||||
|
||||
### Added
|
||||
- **Wakeword model fetch feedback.** The ⟳ button now shows a status line while
|
||||
connecting; after loading it reports how many models were found (with their
|
||||
names) or "Unreachable" if the server is down.
|
||||
- **Wakeword Quickstart covers all four ports.** The Quickstart menu now lists
|
||||
presets for ports 10400–10403, plus `hey_jarvis` and `alexa` variants.
|
||||
- **Wakeword info box.** An info banner explains how wyoming-openwakeword works,
|
||||
where to put model files, and lists the common built-in models.
|
||||
|
||||
## [2.03.16] - 2026-06-10
|
||||
|
||||
### Added
|
||||
- **MP3/OGG/FLAC support for sound cues.** The sound file picker now accepts
|
||||
WAV, MP3, OGG, FLAC, M4A, AAC, AIFF, and Opus. Playback uses `ffplay` or
|
||||
`gst-play-1.0` as a universal fallback when the native `pw-play`/`paplay`
|
||||
can't handle the format.
|
||||
- **Browse dialog with auto-preview.** The 📁 browse button opens a
|
||||
`FileChooserDialog`; selecting a file auto-plays it so you can hear it before
|
||||
confirming. The ▶ play button still works on the current selection.
|
||||
|
||||
## [2.03.15] - 2026-06-10
|
||||
|
||||
### Added
|
||||
- **Wakeword engine CRUD.** The wakeword server section now has the same full
|
||||
management UI as STT engines: a named-preset selector combo, + Add, Quickstart
|
||||
(with 4 common wyoming-openwakeword templates), ⟳ reload, and Delete. Existing
|
||||
users are migrated: their `wakeword_uri` / `wakeword_model` become the first
|
||||
preset automatically.
|
||||
|
||||
## [2.03.14] - 2026-06-10
|
||||
|
||||
### Fixed
|
||||
- **Engines tab.** Removed the "Internal engine — device & precision" section
|
||||
header. The Device and Compute type fields already only appear when a local
|
||||
engine type is selected; the separate header was redundant.
|
||||
|
||||
## [2.03.13] - 2026-06-10
|
||||
|
||||
### Changed
|
||||
- **Settings header bar.** Save and Save & Restart moved from the bottom button
|
||||
bar into the title bar (GTK HeaderBar). The X button closes without saving.
|
||||
Bottom button row removed.
|
||||
- **Section icon alignment.** Icons in section headers are now vertically
|
||||
centred with the label text (`SMALL_TOOLBAR` size, `valign=CENTER`).
|
||||
|
||||
## [2.03.12] - 2026-06-10
|
||||
|
||||
### Added
|
||||
- **Icons in settings.** All tab labels (Presets, Engines, Input, General,
|
||||
Benchmark, Log, Manual, About) and every section header inside each tab now
|
||||
show a small GTK symbolic icon, making the layout easier to scan.
|
||||
|
||||
### Fixed
|
||||
- **Resize grip position.** The grip indicator now appears correctly at the
|
||||
bottom-right corner below the notebook, not misplaced in the tab bar.
|
||||
|
||||
## [2.03.11] - 2026-06-09
|
||||
|
||||
### Added
|
||||
- **Resize grip indicator.** A dotted SE-corner grip is drawn over the
|
||||
bottom-right of the settings window so users discover it is resizable.
|
||||
|
||||
## [2.03.10] - 2026-06-09
|
||||
|
||||
### Fixed
|
||||
- **Server RAM probe.** Prometheus `/metrics` is almost always at the server
|
||||
root (`http://host:port/metrics`), not under `/v1`. Now tries the root URL
|
||||
first before falling back to the API base path.
|
||||
|
||||
## [2.03.09] - 2026-06-09
|
||||
|
||||
### Added
|
||||
- **Server RAM in benchmark.** For remote/Docker STT engines the benchmark now
|
||||
probes the server's Prometheus `/metrics` endpoint for
|
||||
`process_resident_memory_bytes` (standard Python/Go exporter) or
|
||||
`container_memory_rss` (cAdvisor) and shows the server-side RSS in MB in the
|
||||
RAM column. Falls back to `server` when the endpoint is not exposed.
|
||||
|
||||
## [2.03.08] - 2026-06-09
|
||||
|
||||
### Fixed
|
||||
- **Engines tab layout.** The "Internal engine — device & precision" section is
|
||||
now hidden when a remote (Server) or streaming engine type is selected —
|
||||
removing the confusing whitespace gap and irrelevant device controls for
|
||||
non-local engines.
|
||||
|
||||
## [2.03.01] - 2026-06-09
|
||||
|
||||
### Added
|
||||
- **RAM usage column in benchmark.** The results table now shows a **RAM (MB)**
|
||||
column — the increase in process RSS while the transcription ran. For local
|
||||
models this captures the memory cost of loading the model on first use; for
|
||||
remote engines it shows `—` (work happens server-side). Values are measured via
|
||||
`/proc/self/status` (VmRSS), so they reflect actual resident memory, not
|
||||
virtual address space.
|
||||
|
||||
## [2.03.00] - 2026-06-09
|
||||
|
||||
### Fixed
|
||||
- **"Not responding" / system instability on Save.** `_collect()` was calling
|
||||
`socket.create_connection()` *synchronously* on the GTK main thread when
|
||||
wakeword is enabled — freezing the UI for up to 1.5 s (longer if DNS is slow).
|
||||
The check is now done on a daemon thread and the result is logged instead of
|
||||
blocking the save path.
|
||||
- **GTK thread-safety crash in wakeword model load.** `_ww_load()` read
|
||||
`self.ww_uri.get_text()` from inside a background thread — unsafe. The URI is
|
||||
now captured on the main thread before the thread is spawned.
|
||||
- **HTTP 404 with WhisperX and other non-standard endpoints.** The remote
|
||||
transcription call always appended `/audio/transcriptions` to the base URL, but
|
||||
services like WhisperX use `/transcribe` as the full path. The URL path is now
|
||||
inspected: if it is anything other than empty / `/v1` / `/v1.0`, the URL is
|
||||
used as the complete endpoint with nothing appended — so
|
||||
`http://host:8081/transcribe` works out of the box.
|
||||
- **Log levels.** `logbuffer` now stores `(timestamp, level, message)` tuples and
|
||||
accepts a `level=` keyword (`DEBUG` / `INFO` / `WARNING` / `ERROR`). The Log
|
||||
tab gains a **Level** dropdown (Verbose · Info · Warning · Error) that filters
|
||||
the displayed entries live. Wakeword and socket errors are now tagged
|
||||
`WARNING`; library records are forwarded at their native level.
|
||||
|
||||
### Added
|
||||
- **Wakeword server preset dropdown** (Input → Hands-free wakeword). A
|
||||
**Server preset** combo lists all configured wakeword server engines by name.
|
||||
Picking one auto-fills the URI and model fields and re-probes reachability.
|
||||
The selection is persisted as `wakeword_active` in config.
|
||||
|
||||
## [2.02.03] - 2026-06-09
|
||||
|
||||
### Added
|
||||
- Wakeword server preset dropdown in Input tab.
|
||||
|
||||
## [2.02.02] - 2026-06-09
|
||||
|
||||
### Fixed
|
||||
- License tab now renders with markdown styling.
|
||||
- Benchmark pane minimum height (320 px, `shrink=False`) prevents the engine
|
||||
list or results table from collapsing to zero when the window is small.
|
||||
|
||||
## [2.02.01] - 2026-06-09
|
||||
|
||||
### Added
|
||||
- Last benchmark time and accuracy shown on the selected STT engine in the
|
||||
Engines tab. Persisted to config so it survives restarts.
|
||||
|
||||
## [2.02.00] - 2026-06-09
|
||||
|
||||
### Added
|
||||
- **Language metadata in benchmark.** The engine checkbox list shows supported
|
||||
language codes next to each engine (fetched async). Filter box searches by
|
||||
language code. Results table has a **Lang** column. Data comes from the
|
||||
`/v1/models` `language` field (faster-whisper-server) or NVIDIA NIM `/metadata`.
|
||||
|
||||
## [1.9.5] - 2026-06-09
|
||||
|
||||
### Added
|
||||
- **Emoji picker search.** A search field at the top of the emoji picker filters
|
||||
all categories in real time using Unicode character names (e.g. "fire", "dog",
|
||||
"heart"). Typing hides the category bar and shows matching results; clearing
|
||||
restores the category view.
|
||||
|
||||
### Fixed
|
||||
- **Manual tab now shows content.** `MANUAL.md` is copied next to the package
|
||||
module so the Manual tab finds it in both venv and deb installs.
|
||||
- **Info banner no longer bright blue.** The `.bt-infobox` background now uses
|
||||
a neutral 5 % tint of the foreground colour instead of the theme accent
|
||||
colour, so text stays readable on any theme.
|
||||
|
||||
## [1.9.4] - 2026-06-09
|
||||
|
||||
### Changed
|
||||
- **Settings UI completely redesigned.** All six settings tabs (Presets, Engines,
|
||||
Input, General, Input, General) now use a card-based layout following GTK3 best
|
||||
practices: related fields are grouped inside visually distinct cards with bold
|
||||
section titles. CSS is injected at start-up to give cards a consistent rounded
|
||||
border (`boxed-list` + `bt-card`) and a styled info banner at the top of each
|
||||
tab.
|
||||
- **Dialog is larger (740×700 px) and every tab scrolls.** The notebook pages
|
||||
now wrap their content in a `Gtk.ScrolledWindow` so no fields are ever clipped,
|
||||
even on small screens.
|
||||
- **Engines toolbar reorganised.** Creation actions (+ Add, + Stream, Quickstart)
|
||||
are left-aligned; destructive/status actions (Delete, Test, ⟳) are
|
||||
right-aligned via `pack_end`, making the bar scannable at a glance.
|
||||
- **Section titles replace plain separators.** The old `Gtk.Separator` +
|
||||
unstyled `Gtk.Label` pattern is gone; every section now has a small, dimmed,
|
||||
bold all-caps header rendered with markup.
|
||||
- **Cleaner section names.** "WW - Wakeword (Hands-free)" → "Hands-free
|
||||
wakeword"; "Audio cues (manual dictation)" → "Audio cues (keyboard / hotkey
|
||||
dictation)"; "Local engine … device & precision" → "Internal engine — device &
|
||||
precision".
|
||||
|
||||
## [1.9.3] - 2026-06-09
|
||||
|
||||
### Added
|
||||
- **ⓘ info buttons on every settings field.** Each field in every tab now has a
|
||||
small information icon that opens a plain-language help popover when clicked —
|
||||
so non-technical users can understand what each setting does without hovering
|
||||
or reading the manual.
|
||||
- **Manual tab in Settings.** A new "Manual" tab shows the full `MANUAL.md`
|
||||
reference doc directly inside the Settings window.
|
||||
- **Quickstart templates for engines.** A "Quickstart ▾" button in the STT and
|
||||
LLM engine toolbars opens a menu of common services (OpenAI, Groq, OpenRouter,
|
||||
Ollama, LM Studio, vLLM, llama-swap, faster-whisper-server, NVIDIA Riva) and
|
||||
pre-fills the form — one click to configure a provider.
|
||||
|
||||
### Changed
|
||||
- **Engine type names are now human-readable.** STT types now read "Internal —
|
||||
faster-whisper, runs inside the app", "Server — OpenAI-compatible API (LAN or
|
||||
cloud)", and "Realtime — NVIDIA Riva / NIM streaming" instead of the raw
|
||||
identifiers. LLM types read "LAN server — runs on your machine or local
|
||||
network" and "Cloud service — OpenAI, Groq, OpenRouter, …".
|
||||
- **Device selector now shows "GPU (CUDA)" instead of "cuda"**, and compute
|
||||
types have plain-language descriptions (e.g. "int8 — fast, less memory").
|
||||
|
||||
## [1.9.2] - 2026-06-09
|
||||
|
||||
### Added
|
||||
- **Emoji picker for preset icons.** The "Icon (emoji)" field in Settings →
|
||||
Presets now has a 😀 button that opens a scrollable emoji grid (60 common
|
||||
emojis across six categories). Click any emoji to insert it — or keep typing
|
||||
directly into the field as before.
|
||||
|
||||
## [1.9.1] - 2026-06-08
|
||||
|
||||
### Changed
|
||||
- **Settings opens instantly.** Each tab's contents are now built the first time
|
||||
you view it instead of all up front, so the dialog no longer pauses ~1.3s
|
||||
constructing the file-choosers in the Input/Benchmark tabs. Saving force-builds
|
||||
any tab you didn't visit first, so no field is ever missed.
|
||||
- **Connection dots moved beside their field.** The Wakeword and TTS reachability
|
||||
dots now sit just left of the URL entry (matching the Engines tab) instead of
|
||||
at the far right of the row.
|
||||
|
||||
### Fixed
|
||||
- **Settings could be opened more than once.** Choosing Settings while it's
|
||||
already open now raises the existing window instead of stacking a second copy.
|
||||
|
||||
## [1.9.0] - 2026-06-08
|
||||
|
||||
### Added
|
||||
- **Connection indicators** for remote endpoints. The **Wakeword engine** field
|
||||
(Input tab — renamed from "Wyoming URI" to read more generally) and the **TTS
|
||||
URL** field (Benchmark tab) now show a coloured dot: green when the server is
|
||||
reachable, red when it's configured but unreachable, grey when blank — mirroring
|
||||
the STT/LLM engine dots. It's a lightweight background TCP probe, refreshed when
|
||||
the dialog opens, when you press ⟳, and when you leave the field.
|
||||
|
||||
## [1.8.1] - 2026-06-08
|
||||
|
||||
### Fixed
|
||||
- **Settings dialog and control panel wouldn't open on some desktops.** When the
|
||||
gvfs `org.gtk.vfs.UDisks2VolumeMonitor` dbus service fails to activate (common
|
||||
on headless or minimal sessions), every `Gtk.FileChooserButton` blocked ~25s on
|
||||
a `StartServiceByName` timeout while realizing — so the Settings dialog never
|
||||
finished appearing, and the stalled GTK main loop froze the panel too. Blitztext
|
||||
now selects GIO's native `/proc/mounts` volume monitor
|
||||
(`GIO_USE_VOLUME_MONITOR=unix`) before any window is realized, so file choosers
|
||||
open instantly with no dbus dependency.
|
||||
|
||||
## [1.8.0] - 2026-06-08
|
||||
|
||||
### Added
|
||||
- **Send by voice**: say a distinctive phrase like **"computer send"** at the
|
||||
start or end of a clip and the word is stripped, then the rest is typed **and
|
||||
submitted with Enter** — the spoken equivalent of "stop + paste + Enter".
|
||||
Mainly for hands-free use, where you can't press a key. Configure under
|
||||
Settings → Input → "Send words", or `[routing] send_keywords`. Off by default;
|
||||
because it presses Enter, use a multi-word phrase (e.g. your wakeword + "send")
|
||||
so a sentence that merely ends in "send" doesn't submit by accident. Matched
|
||||
the same edge-anchored, ASR-tolerant way as routing/cancel keywords.
|
||||
- **Wakeword benchmark** (Settings → Benchmark): stress-test hands-free
|
||||
detection. It synthesizes short sentences with your wake phrase spoken in
|
||||
random voices (plus pure-filler utterances with none), streams them to your
|
||||
wyoming-openwakeword server, and reports **recall** (how reliably it fires),
|
||||
**false fires**, and a **per-voice** breakdown. Speech comes from any
|
||||
OpenAI-compatible TTS server (Kokoro-FastAPI, XTTS, OpenAI, …): set its URL,
|
||||
optional API-key env var, model, and voices under the new `[tts]` config / the
|
||||
Benchmark tab, and use **Connect** to test it (it auto-fills the voice list
|
||||
when the server exposes one).
|
||||
|
||||
## [1.7.1] - 2026-06-08
|
||||
|
||||
### Fixed
|
||||
- **Overlay waveform and silence countdown ring never appeared** on systems
|
||||
where PortAudio/`sounddevice` can't open the default input — notably PipeWire
|
||||
boxes, where opening an input stream simply hangs. Both the live waveform and
|
||||
the auto-stop countdown are driven by a single level meter, which was the only
|
||||
part of the app still using `sounddevice` (everything else records via
|
||||
`pw-record`). The meter now streams raw PCM from the **same system recorder as
|
||||
the WAV recorder** (`pw-record`/`parecord`/`arecord`) and computes the level
|
||||
itself, so it works wherever recording works — on both the hotkey and
|
||||
hands-free (wakeword) paths, plus the mic-level preview in Settings. No more
|
||||
PortAudio dependency for metering.
|
||||
- **App reported itself as "`__main__.py`"** in the taskbar and in GNOME's
|
||||
"… is not responding" dialog. Launched via `python -m blitztext`, GTK's default
|
||||
program name is `argv[0]`'s basename. It now sets `prgname`/application name to
|
||||
**Blitztext** before any window is realized (and the desktop file gains
|
||||
`StartupWMClass=blitztext` for the .desktop match + icon), without touching the
|
||||
`-m blitztext` entry point.
|
||||
|
||||
## [1.7.0] - 2026-06-07
|
||||
|
||||
### Added
|
||||
- **Spoken cancel keyword**: say a word like **"abbrechen"** (or "cancel") at the
|
||||
start or end of a clip and the whole dictation is **discarded** — it is never
|
||||
routed, rewritten, or typed anywhere. Mainly rescues an accidentally triggered
|
||||
(e.g. wakeword) recording. Configure under Settings → Mic/Cues → "Cancel words",
|
||||
or `[routing] cancel_keywords` (default `["abbrechen", "cancel"]`; empty list
|
||||
disables it). Matched the same edge-anchored, ASR-tolerant way as routing
|
||||
keywords, so the word buried mid-sentence won't trip it.
|
||||
|
||||
## [1.6.0] - 2026-06-07
|
||||
|
||||
### Fixed
|
||||
- **Session freeze when the overlay's caret tracking was active** (could lock up
|
||||
the whole GNOME/X11 desktop, forcing a logout/reboot). The AT-SPI caret tracker
|
||||
subscribed to the high-frequency `object:text-caret-moved` signal and made
|
||||
**synchronous, blocking AT-SPI reads from inside the event handler** — which
|
||||
re-enters the accessibility dispatcher and is stormed by the app's *own*
|
||||
`xdotool` typing (one event per character), congesting the a11y bus until the
|
||||
desktop stopped responding. It now tracks **focus changes only** and reads the
|
||||
caret rectangle lazily (once, when the overlay shows), never from inside an
|
||||
event dispatch.
|
||||
|
||||
### Changed
|
||||
- **Matched preset is fused into the overlay instead of a desktop notification**:
|
||||
when voice routing picks a preset, the overlay shows its emoji icon, name, and
|
||||
the spoken keyword on a banner, and narrates the phase ("Transcribing…" →
|
||||
"Rewriting…"). With the overlay on, the redundant per-dictation notifications
|
||||
are suppressed (errors still notify); headless/overlay-off keeps notifications.
|
||||
|
||||
### Added
|
||||
- **Live LLM rewrite in the overlay**: rewrite presets now stream the model's
|
||||
output into the bubble token-by-token, so you watch it write. The delivered
|
||||
text is still the complete result, typed once the rewrite finishes.
|
||||
|
||||
## [1.5.1] - 2026-06-07
|
||||
|
||||
### Added
|
||||
- **Silence auto-stop countdown ring** on the dictation overlay: when you stop
|
||||
speaking, a full circle wrapping the microphone glyph drains clockwise as the
|
||||
trailing-silence timer runs out, recolouring from calm cyan to an urgent red
|
||||
and emptying exactly as the recording auto-stops. It spans the configured
|
||||
"Silence to stop (s)" window (`[wakeword] silence_seconds`), fades back in/out
|
||||
as you pause and resume, and so finally makes the hands-free auto-stop visible
|
||||
instead of a silent surprise.
|
||||
|
||||
## [1.5.0] - 2026-06-07
|
||||
|
||||
### Added
|
||||
- **On-screen dictation overlay** (Settings → General → "Visual overlay", or
|
||||
`[general] overlay_enabled`, default on): the moment recording starts — by
|
||||
hotkey **or** wakeword — a translucent bubble appears at the cursor showing a
|
||||
pulsing **microphone**, a **live waveform** of your mic level, and the
|
||||
**recognised text** (word-by-word with a realtime streaming STT engine, or the
|
||||
final result as a brief confirmation otherwise). Its tail points at where the
|
||||
text will land: it follows the **text caret** when the focused app exposes it
|
||||
over accessibility (AT-SPI), otherwise the **mouse pointer**, otherwise a
|
||||
screen corner — tune via `[general] overlay_anchor = "caret" | "pointer" |
|
||||
"corner"`. The window is click-through and never takes focus, and it finally
|
||||
gives **hands-free wakeword sessions** visible feedback (their notifications
|
||||
are suppressed by design). X11 only; falls back to a corner where the cursor
|
||||
can't be located.
|
||||
|
||||
### Changed
|
||||
- **Presets are speakable by name**: voice routing now matches a preset's *name*
|
||||
as an implicit keyword, so a preset works by voice even with no keywords
|
||||
configured (e.g. just say "nicer email …"). Explicit keywords still take
|
||||
precedence, and preset names also bias the STT for better recognition.
|
||||
- **General settings switches** moved to the far right of each row, each with an
|
||||
inline description so it's clear what the toggle does without hovering.
|
||||
- **About**: added a "Copyright: 2026 mARTin Bierschenk - Design" line.
|
||||
|
||||
## [1.4.0] - 2026-06-07
|
||||
|
||||
### Added
|
||||
- **"Announce matched preset" notification** (Settings → General, or
|
||||
`[general] notify_routing`, default on): after a voice command, a notification
|
||||
shows which preset and spoken keyword matched — **shown even for hands-free
|
||||
wakeword sessions**, so you can see what you triggered. It only fires on a real
|
||||
match, so it never spams when nothing is said.
|
||||
- **Per-preset emoji icon** (Presets → "Icon (emoji)"): give each preset a
|
||||
distinct emoji, shown in the matched-preset notification so you can tell at a
|
||||
glance which fired.
|
||||
|
||||
### Fixed
|
||||
- **Voice-routing default went to a rewrite**: when no `[routing] default` preset
|
||||
is set, the no-keyword fallback used the *first* preset — which, if that happened
|
||||
to be an LLM rewrite (e.g. "Improve text"), sent every unrouted wakeword command
|
||||
to the language model (and failed when the LLM was down). The fallback now
|
||||
prefers a `transcribe` preset, so the default action is plain transcription.
|
||||
|
||||
## [1.3.0] - 2026-06-07
|
||||
|
||||
### Added
|
||||
- **Pause wakeword (tray)**: a reversible "Pause wakeword" toggle appears in the
|
||||
system-tray menu when the wakeword is enabled. It pauses/resumes hands-free
|
||||
detection by toggling the `/tmp/wake_muted` flag (external scripts may toggle
|
||||
the same file).
|
||||
- **"Play audio cues" switch** (Settings → Input → Audio cues, or
|
||||
`[sounds] enabled`): on/off for the **manual** (keyboard/hotkey) start/stop
|
||||
chimes. Defaults to on. The hands-free wakeword sounds are independent of it.
|
||||
- **Configurable wakeword auto-stop silence** (Settings → Input → Hands-free →
|
||||
"Silence to stop (s)", or `[wakeword] silence_seconds`): end a hands-free
|
||||
recording this many seconds after you stop speaking. Defaults to `2.0`
|
||||
(previously hard-coded to 2.5 s).
|
||||
|
||||
### Fixed
|
||||
- **Wakeword sounds silenced by the manual cue switch**: the "Play audio cues"
|
||||
master switch wrongly muted the hands-free *Sound: detected/captured* cues too.
|
||||
Wakeword cues are now independent — they play whenever a file is set and stay
|
||||
silent when cleared (no surprise system-chime fallback), regardless of the
|
||||
manual switch.
|
||||
- **PortAudio/ALSA teardown noise**: the level meter no longer leaks
|
||||
`pthread_join ... failed` / `PaUnixThread_Terminate ... failed` lines to the
|
||||
terminal when a clip ends — that C-library chatter (written straight to fd 2)
|
||||
is now suppressed around the stream open/close.
|
||||
- **Wakeword stuck muted**: a leftover `/tmp/wake_muted` flag silently disabled
|
||||
detection with no in-app way to clear it. The state is now exposed and
|
||||
reversible from the tray, so a stale flag no longer kills hands-free use. The
|
||||
daemon also logs a clear `Starting PAUSED` warning when it boots muted.
|
||||
- **Away-from-keyboard "Busy" storm**: a wakeword hit arriving while the previous
|
||||
clip was still transcribing went through `toggle()` and popped a "Busy"
|
||||
notification. Wakeword triggers now go straight to `start_dictation()`, so a
|
||||
busy/not-ready state is ignored silently instead.
|
||||
- **Quiet hands-free errors**: transcription/rewrite failures during a
|
||||
wakeword-triggered session no longer raise critical desktop notifications —
|
||||
they are logged instead, keeping background sessions silent.
|
||||
- **Notification storm / lock-screen pile-up**: desktop notifications are now
|
||||
sent as transient with a short expiry and reuse a single bubble, so they no
|
||||
longer stack in the notification log or persist on the lock screen.
|
||||
- **Quiet hands-free sessions**: per-dictation notifications are suppressed for
|
||||
wakeword-triggered sessions (audio cues are used instead).
|
||||
|
||||
## [1.2.0] - 2026-06-05
|
||||
|
||||
### Added
|
||||
- **Wyoming Wakeword Support**: Complete hands-free integration via Wyoming protocol (e.g., openWakeWord), with live configuration testing and model fetching in the UI.
|
||||
- **ATK Screen Reader Accessibility**: Fully mapped GTK labels, inputs, tooltips, and properties to the ATK bridge, enabling seamless navigation for blind users via screen readers like Orca.
|
||||
- **Drag-and-Drop Workflow Ordering**: Workflows in the main tray menu can now be reordered via native drag-and-drop.
|
||||
- **Voice Activity Detection (VAD) Auto-Stop**: Dictation now automatically stops after detecting 2.5 seconds of silence, removing the need to manually click Stop.
|
||||
- **Audio Feedback**: Added audible start/stop/cancel chimes mapping to system-native alert sounds.
|
||||
- **Benchmark Autocomplete**: The Benchmark UI automatically fills in the reference `.txt` transcript if it matches the selected audio file.
|
||||
- **Realtime STT streaming mode**: new `mode = "stream"` workflow support and a
|
||||
`riva_realtime` STT engine for Riva/NIM WebSocket transcription, including a
|
||||
Settings shortcut for Nemotron ASR Streaming on `http://127.0.0.1:8006/v1`.
|
||||
- **Settings About tab** with the app version, source link, changelog, and
|
||||
license text.
|
||||
- **STT & LLM engine manager**: add, rename, and delete engine presets, each
|
||||
with an online/offline status dot, a per-engine type (local/cloud), and a
|
||||
**searchable model dropdown** populated from the server's `/models` (with a
|
||||
reload button). Local Whisper device/precision now live with the STT engine.
|
||||
- **Benchmark tab**: run a reference clip through every STT engine and compare
|
||||
**time**, **case-sensitive accuracy** (WER), and a **CPU/GPU/remote device**
|
||||
column, with the fastest and most accurate highlighted.
|
||||
- **Custom audio cues**: pick your own WAV files to play when recording starts
|
||||
and stops (covers stop+paste, stop+paste+Enter, and silence auto-stop), each
|
||||
with play-test and clear-to-default buttons. Built-in system sounds otherwise.
|
||||
- **Settings Log tab**: a live activity log (model load/download, transcriptions,
|
||||
errors) with Copy and Clear, so a long "Loading…" is no longer opaque.
|
||||
- **Per-tab info boxes** and expanded **tooltips** across Settings, written in
|
||||
plain language and exposed to screen readers (ATK) — for non-technical and
|
||||
blind users (barrierefrei).
|
||||
- **Click-to-bind hotkeys**: a *Set* button captures the next keypress (including
|
||||
modifier-only chords like Ctrl+Win) into any hotkey field.
|
||||
|
||||
### Changed
|
||||
- **GUI rebuilt in GTK 3** (replacing tkinter): a native GNOME panel unified with
|
||||
the tray, with the Ubuntu font and a dropdown+editor pattern in Settings.
|
||||
- **Voice-keyword routing** and a **modifier hotkey scheme** (Ctrl+Win start,
|
||||
Ctrl stop+paste, Alt stop+paste+Enter, Esc cancel) replace per-preset combos as
|
||||
the default way to dictate.
|
||||
- **Quality gate** rejects silent/too-short clips and stock Whisper
|
||||
hallucinations before they reach the screen.
|
||||
|
||||
## [1.1.0] - 2026-06-04
|
||||
|
||||
### Added
|
||||
- **Debian package** (`packaging/build-deb.sh`) producing an installable
|
||||
`blitztext_<ver>_arm64.deb` with a desktop entry, app icon, and a `blitztext`
|
||||
launcher. Installs via the Software app or `apt install ./…deb`. Bundles a
|
||||
relocatable venv with all Python deps (no pip/network at install) and declares
|
||||
system deps (python3-gi, xdotool, libnotify-bin, a recorder) so they pull in
|
||||
automatically. The bundled venv is built on the system `/usr/bin/python3`, so
|
||||
the tray works out of the box.
|
||||
|
||||
### Notes
|
||||
- `python3-gi` is already present on a standard Ubuntu GNOME install; the tray
|
||||
only seemed unavailable from source when the project venv was built from a
|
||||
non-system Python (e.g. conda/miniforge). The `.deb` avoids this entirely.
|
||||
|
||||
## [1.0.1] - 2026-06-03
|
||||
|
||||
### Changed
|
||||
- Redesigned the control-panel window: minimal flat layout, Ubuntu font
|
||||
throughout, clickable workflow rows with hover (click to record / stop),
|
||||
subtle dividers, and text-style Settings/Quit actions. Dropped the monogram
|
||||
avatars and per-row buttons in favour of a cleaner, simpler look. The Settings
|
||||
window picks up the same font and styling.
|
||||
|
||||
## [1.0.0] - 2026-06-03
|
||||
|
||||
First release of the Linux port. The upstream project is a macOS-only menu-bar
|
||||
@ -855,9 +49,5 @@ into that field.
|
||||
AppIndicator typelibs and GNOME `ubuntu-appindicators` extension are already
|
||||
present on the target host).
|
||||
|
||||
[Unreleased]: https://github.com/mARTin-B78/blitztext-app-linux/compare/v1.5.1...HEAD
|
||||
[1.5.1]: https://github.com/mARTin-B78/blitztext-app-linux/compare/v1.5.0...v1.5.1
|
||||
[1.5.0]: https://github.com/mARTin-B78/blitztext-app-linux/compare/v1.4.0...v1.5.0
|
||||
[1.1.0]: https://github.com/mARTin-B78/blitztext-app-linux/compare/v1.0.1...v1.1.0
|
||||
[1.0.1]: https://github.com/mARTin-B78/blitztext-app-linux/compare/v1.0.0...v1.0.1
|
||||
[1.0.0]: https://github.com/mARTin-B78/blitztext-app-linux/releases/tag/v1.0.0
|
||||
[Unreleased]: https://github.com/mARTin-B78/blitztext-app/compare/v1.0.0...HEAD
|
||||
[1.0.0]: https://github.com/mARTin-B78/blitztext-app/releases/tag/v1.0.0
|
||||
|
||||
216
linux/README.md
@ -8,23 +8,8 @@ This runs **on the host** (not in a container), so it can type into *any*
|
||||
application — the Linux equivalent of the macOS app's Accessibility-based
|
||||
auto-paste. (A sandboxed Docker/browser version can't do that; an earlier
|
||||
experiment along those lines was moved out to
|
||||
`~/Docker/correspondence/blitztext`.) Batch transcription is **local** via
|
||||
[faster-whisper]; live streaming can use a local Riva/NIM realtime server. Only
|
||||
the optional rewrite step calls out to an LLM.
|
||||
|
||||
<p align="center">
|
||||
<img src="../Screenshots/main-panel.png" alt="Blitztext control panel" width="360">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="../Screenshots/overlay-listening.png" alt="On-screen overlay while listening" width="360">
|
||||
|
||||
<img src="../Screenshots/overlay-result.png" alt="On-screen overlay showing transcription result" width="360">
|
||||
</p>
|
||||
|
||||
## Inspiration
|
||||
|
||||
Blitztext App Linux is inspired by [cmagnussen/blitztext-app](https://github.com/cmagnussen/blitztext-app), the original macOS menu-bar workflow for turning speech into text and cleaner writing. This Linux version keeps the workflow but uses Linux-native pieces: GTK, AppIndicator, global hotkeys, `faster-whisper`, optional Riva/NIM realtime STT, and `xdotool`.
|
||||
`~/Docker/correspondence/blitztext`.) Transcription is **local** via
|
||||
[faster-whisper]; only the optional rewrite step calls out to an LLM.
|
||||
|
||||
## How it works
|
||||
|
||||
@ -35,121 +20,10 @@ hotkey ──▶ record mic (pw-record/arecord) ──▶ faster-whisper (local)
|
||||
│ └── mode "rewrite": LLM (OpenAI-compatible)
|
||||
▼
|
||||
xdotool types it into the focused window
|
||||
|
||||
mode "stream" ──▶ mic PCM chunks ──▶ Riva/NIM realtime WebSocket ──▶ live xdotool typing
|
||||
```
|
||||
|
||||
Each normal hotkey **toggles**: press to start recording, press again to stop —
|
||||
then it transcribes, optionally rewrites, and types the result where your cursor
|
||||
is. Streaming workflows type stable words live while you speak.
|
||||
|
||||
**Cancel by voice:** say *"abbrechen"* (or *"cancel"*) at the start or end of a
|
||||
clip and the whole dictation is discarded — never routed, rewritten, or typed.
|
||||
It's the rescue for an accidentally triggered (e.g. wakeword) recording. Set the
|
||||
words under `[routing] cancel_keywords` (default `["abbrechen", "cancel"]`; an
|
||||
empty list turns it off).
|
||||
|
||||
**Send by voice:** say a distinctive phrase like *"computer send"* at the start
|
||||
or end of a clip and the word is stripped, then the rest is typed **and submitted
|
||||
with Enter** — the spoken equivalent of "stop + paste + Enter", ideal hands-free.
|
||||
Off by default; set the phrases under `[routing] send_keywords` (use a multi-word
|
||||
phrase so a sentence merely ending in "send" doesn't submit by accident).
|
||||
|
||||
While you dictate, an optional **on-screen overlay** (Settings → General →
|
||||
"Visual overlay", default on) shows a translucent bubble at the cursor with a
|
||||
pulsing microphone, a live waveform of your mic level, and the recognised text —
|
||||
word-by-word in streaming mode, or the final result as a brief confirmation. Its
|
||||
tail points at the text caret (via AT-SPI accessibility) or the mouse pointer; it
|
||||
is click-through, never steals focus, and also gives hands-free wakeword sessions
|
||||
visible feedback. Tune the anchor with `[general] overlay_anchor`. X11 only.
|
||||
|
||||
## Screenshots
|
||||
|
||||
Everything is configured in the **Settings** window — the sidebar gives quick
|
||||
access to every page. Click any image to open it full size.
|
||||
|
||||
### Main panel & overlay
|
||||
|
||||
<p align="center">
|
||||
<a href="../Screenshots/main-panel.png"><img src="../Screenshots/main-panel.png" alt="Blitztext main panel" width="46%"></a>
|
||||
|
||||
<a href="../Screenshots/overlay-listening.png"><img src="../Screenshots/overlay-listening.png" alt="Overlay — listening" width="46%"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<em><b>Left:</b> Control panel listing all presets with icons, descriptions, and hotkeys.</em>
|
||||
|
||||
<em><b>Right:</b> On-screen overlay showing the live waveform while listening.</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="../Screenshots/overlay-result.png"><img src="../Screenshots/overlay-result.png" alt="Overlay — transcription result" width="46%"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<em>Overlay after transcription — preset name and recognised text appear at the cursor.</em>
|
||||
</p>
|
||||
|
||||
### Settings — General & Input
|
||||
|
||||
<p align="center">
|
||||
<a href="../Screenshots/settings-presets.png"><img src="../Screenshots/settings-presets.png" alt="Presets page" width="48%"></a>
|
||||
|
||||
<a href="../Screenshots/settings-general.png"><img src="../Screenshots/settings-general.png" alt="General page" width="48%"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<em><b>Presets</b> — dictation actions with keywords, hotkeys, LLM mode, and custom prompts.</em>
|
||||
|
||||
<em><b>General</b> — microphone, output mode, language hint, notifications, overlay, autostart.</em>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="../Screenshots/settings-keyboard.png"><img src="../Screenshots/settings-keyboard.png" alt="Keyboard page" width="48%"></a>
|
||||
|
||||
<a href="../Screenshots/settings-wakeword.png"><img src="../Screenshots/settings-wakeword.png" alt="Wakeword page" width="48%"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<em><b>Keyboard</b> — modifier-key scheme or direct hotkeys, quality gate, audio cues.</em>
|
||||
|
||||
<em><b>Wakeword</b> — hands-free dictation via a Wyoming/openWakeWord server, with live level meter and model picker.</em>
|
||||
</p>
|
||||
|
||||
### Settings — Engines
|
||||
|
||||
<p align="center">
|
||||
<a href="../Screenshots/settings-stt-engines.png"><img src="../Screenshots/settings-stt-engines.png" alt="STT Engines page" width="48%"></a>
|
||||
|
||||
<a href="../Screenshots/settings-llm-engines.png"><img src="../Screenshots/settings-llm-engines.png" alt="LLM Engines page" width="48%"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<em><b>STT Engines</b> — speech-to-text back-ends (local faster-whisper, OpenAI-compatible server, or Riva realtime), with green/red status dot and Test button.</em>
|
||||
|
||||
<em><b>LLM Engines</b> — language-model back-ends for text rewriting (LAN server or cloud service).</em>
|
||||
</p>
|
||||
|
||||
### Settings — Benchmark
|
||||
|
||||
<p align="center">
|
||||
<a href="../Screenshots/settings-benchmark-stt.png"><img src="../Screenshots/settings-benchmark-stt.png" alt="Benchmark — STT page" width="48%"></a>
|
||||
|
||||
<a href="../Screenshots/settings-benchmark-wakeword.png"><img src="../Screenshots/settings-benchmark-wakeword.png" alt="Benchmark — Wakeword page" width="48%"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<em><b>Benchmark — STT</b> — compare engines against a reference WAV + transcript; table shows speed, accuracy, device, and language support.</em>
|
||||
|
||||
<em><b>Benchmark — Wakeword</b> — stress-test wakeword detection via a TTS server, reporting recall and false-fire rates per voice.</em>
|
||||
</p>
|
||||
|
||||
### Settings — Log & About
|
||||
|
||||
<p align="center">
|
||||
<a href="../Screenshots/settings-log.png"><img src="../Screenshots/settings-log.png" alt="Log page" width="48%"></a>
|
||||
|
||||
<a href="../Screenshots/settings-about.png"><img src="../Screenshots/settings-about.png" alt="About page" width="48%"></a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<em><b>Log</b> — live activity log for recording, transcription, routing, and wakeword events.</em>
|
||||
|
||||
<em><b>About</b> — version, source link, inline changelog, and licence.</em>
|
||||
</p>
|
||||
Each hotkey **toggles**: press to start recording, press again to stop — then it
|
||||
transcribes, optionally rewrites, and types the result where your cursor is.
|
||||
|
||||
## Requirements
|
||||
|
||||
@ -160,45 +34,20 @@ access to every page. Click any image to open it full size.
|
||||
sudo apt install xdotool libnotify-bin pipewire-bin
|
||||
```
|
||||
- Python 3.11+.
|
||||
- Optional realtime STT streaming: a Riva/NIM realtime server such as Nemotron
|
||||
ASR Streaming, reachable through `/v1/realtime`.
|
||||
|
||||
## Install
|
||||
|
||||
### Option A — Debian package (recommended on Ubuntu/Debian)
|
||||
|
||||
Build a `.deb` and install it with the Software app or apt:
|
||||
|
||||
```bash
|
||||
cd linux
|
||||
bash packaging/build-deb.sh # -> dist/blitztext_<ver>_arm64.deb
|
||||
sudo apt install ./dist/blitztext_*.deb # or double-click the .deb in Files
|
||||
```
|
||||
|
||||
This installs `blitztext` to `/opt/blitztext` (a self-contained bundle — no pip
|
||||
step), adds a **Blitztext** entry to your app grid, and pulls in the system deps
|
||||
(`python3-gi`, `xdotool`, `libnotify-bin`, a recorder). Launch it from the app
|
||||
grid, or run `blitztext` / `blitztext gui` from a terminal. Remove with
|
||||
`sudo apt remove blitztext`.
|
||||
|
||||
### Option B — run from source (venv)
|
||||
|
||||
```bash
|
||||
cd linux
|
||||
./install.sh
|
||||
```
|
||||
|
||||
This creates `.venv`, installs the Python dependencies from `requirements.txt`,
|
||||
and writes the default config to `~/.config/blitztext/config.toml`.
|
||||
|
||||
> For the **tray** from source, the venv must be built on a Python that can see
|
||||
> the system `python3-gi` — `install.sh` uses `python3 -m venv
|
||||
> --system-site-packages`, so use the system `/usr/bin/python3` (a conda/miniforge
|
||||
> Python won't see the apt-installed `gi`). The `.deb` handles this for you.
|
||||
This creates `.venv`, installs `faster-whisper` + `pynput`, and writes the
|
||||
default config to `~/.config/blitztext/config.toml`.
|
||||
|
||||
## Run
|
||||
|
||||
Three front-ends, same engine layer (STT engines + global hotkeys + xdotool typing):
|
||||
Three front-ends, same engine (local Whisper + global hotkeys + xdotool typing):
|
||||
|
||||
```bash
|
||||
# optional: only needed for the "rewrite" workflows
|
||||
@ -209,45 +58,21 @@ export OPENAI_API_KEY=sk-...
|
||||
.venv/bin/python -m blitztext run # headless, hotkeys only
|
||||
```
|
||||
|
||||
### Realtime STT streaming
|
||||
|
||||
For Nemotron ASR Streaming, add a realtime engine in **Settings > Engines** with
|
||||
`+ Stream`, save/restart, then create or edit a workflow with `mode = "stream"`.
|
||||
The default realtime URL is:
|
||||
|
||||
```toml
|
||||
[[stt_engine]]
|
||||
name = "Nemotron ASR Streaming"
|
||||
type = "riva_realtime"
|
||||
url = "http://127.0.0.1:8006/v1"
|
||||
model = ""
|
||||
|
||||
[[workflow]]
|
||||
name = "STT Streaming"
|
||||
hotkey = "<ctrl>+<alt>+s"
|
||||
mode = "stream"
|
||||
```
|
||||
|
||||
The current Nemotron ASR Streaming model exposed by the tested NIM is English
|
||||
`en-US`, so use `language = "en"` or `language = "en-US"` in `[general]` for
|
||||
that engine.
|
||||
|
||||
### System tray (recommended)
|
||||
|
||||
The tray is the closest match to the macOS menu-bar app: a status icon with a
|
||||
menu listing every workflow (click to record), plus **Show panel**, **Settings…**,
|
||||
and **Quit**. It needs PyGObject (`python3-gi`) and the GTK/AppIndicator
|
||||
typelibs — already present on a standard Ubuntu GNOME install (the `.deb`
|
||||
declares them as dependencies):
|
||||
and **Quit**. It needs PyGObject once (the GTK/AppIndicator typelibs and the
|
||||
GNOME `ubuntu-appindicators` extension are already present here):
|
||||
|
||||
```bash
|
||||
sudo apt install python3-gi # usually already installed
|
||||
sudo apt install python3-gi # one-time; no build, just the bindings
|
||||
.venv/bin/python -m blitztext tray
|
||||
```
|
||||
|
||||
If PyGObject isn't visible, `tray` prints this hint and falls back to the
|
||||
window. The venv is created with `--system-site-packages` so it can see the
|
||||
system `gi` — build it from `/usr/bin/python3`, not a conda/miniforge Python.
|
||||
If PyGObject is missing, `tray` prints this hint and falls back to the window.
|
||||
(The venv is created with `--system-site-packages` so it can see the
|
||||
apt-installed `gi`.)
|
||||
|
||||
Either way, focus any text field and trigger a workflow — by tray menu, panel
|
||||
button, or hotkey (defaults):
|
||||
@ -316,18 +141,3 @@ python -m blitztext config-path # print config location
|
||||
```
|
||||
|
||||
[faster-whisper]: https://github.com/SYSTRAN/faster-whisper
|
||||
|
||||
## License
|
||||
|
||||
Code is released under the MIT License. See [../LICENSE](../LICENSE).
|
||||
|
||||
Project names, logos, and app icons are not automatically granted as trademarks or brand assets. See [../TRADEMARKS.md](../TRADEMARKS.md).
|
||||
|
||||
## Legal / Impressum & Datenschutz
|
||||
|
||||
This is an experimental, non-commercial open-source project, provided as-is under the MIT License without warranty or support. Nothing is sold here and no installation or operation is performed on your behalf.
|
||||
|
||||
The companion website (blitztext.de) is operated by Blackboat Internet GmbH:
|
||||
|
||||
- Impressum: https://martin-bierschenk.de/impressum/
|
||||
- Datenschutz / Privacy: https://martin-bierschenk.de/datenschutz/
|
||||
|
||||
@ -6,4 +6,4 @@ counterpart to the macOS Blitztext menu bar app: it runs natively on the host
|
||||
(not in a container) so it can type into any application via xdotool.
|
||||
"""
|
||||
|
||||
__version__ = "2.03.41"
|
||||
__version__ = "1.0.0"
|
||||
|
||||
@ -1,6 +1,58 @@
|
||||
"""Module entry point so `python -m blitztext` runs the app in blitztext.py."""
|
||||
"""CLI entrypoint: `python -m blitztext [run|transcribe|config-path]`."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from . import __version__
|
||||
from .config import CONFIG_PATH, ensure_default, load
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="blitztext", description="Native dictation for Linux.")
|
||||
parser.add_argument("--version", action="version", version=f"blitztext {__version__}")
|
||||
sub = parser.add_subparsers(dest="cmd")
|
||||
|
||||
sub.add_parser("tray", help="Run in the system tray with a workflow menu (default).")
|
||||
sub.add_parser("gui", help="Open the control panel window.")
|
||||
sub.add_parser("run", help="Start the headless hotkey daemon (no window/tray).")
|
||||
sub.add_parser("config-path", help="Print the config file path and exit.")
|
||||
p_tx = sub.add_parser("transcribe", help="Transcribe a WAV file and print the text (no hotkeys).")
|
||||
p_tx.add_argument("audio", type=Path)
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
cmd = args.cmd or "tray"
|
||||
|
||||
if cmd == "config-path":
|
||||
print(ensure_default(CONFIG_PATH))
|
||||
return 0
|
||||
|
||||
if cmd in ("gui", "tray"):
|
||||
ensure_default(CONFIG_PATH)
|
||||
from .gui import run_gui
|
||||
|
||||
return run_gui(tray_mode=(cmd == "tray"))
|
||||
|
||||
cfg = load()
|
||||
|
||||
if cmd == "transcribe":
|
||||
from .transcribe import Transcriber
|
||||
|
||||
tx = Transcriber(cfg.model, cfg.device, cfg.compute_type, cfg.beam_size)
|
||||
print(tx.transcribe(args.audio, language=cfg.language))
|
||||
return 0
|
||||
|
||||
# cmd == "run"
|
||||
from .daemon import Daemon
|
||||
|
||||
try:
|
||||
Daemon(cfg).run()
|
||||
except KeyboardInterrupt:
|
||||
print("\n[blitztext] stopped.", file=sys.stderr)
|
||||
return 0
|
||||
|
||||
from .blitztext import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||