Compare commits

...

No commits in common. "v1.1.0" and "main" have entirely different histories.
v1.1.0 ... main

149 changed files with 12382 additions and 6849 deletions

View File

@ -13,7 +13,7 @@ Did you use AI-assisted coding tools? If yes, briefly mention where.
## Checklist
- [ ] I ran `./build.sh --debug` or explained why not.
- [ ] I ran the Linux app or its test suite (`cd linux && PYTHONPATH=. pytest tests`) 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 macOS preview scope.
- [ ] I kept the change focused on the Linux preview scope.

View File

@ -5,3 +5,7 @@ updates:
directory: /
schedule:
interval: weekly
- package-ecosystem: pip
directory: /linux
schedule:
interval: weekly

View File

@ -10,14 +10,14 @@ permissions:
contents: read
jobs:
build-macos:
name: Build macOS app
runs-on: macos-14
timeout-minutes: 30
test-linux:
name: Test Linux app
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v4
- name: Secret hygiene scan
run: |
@ -39,14 +39,26 @@ jobs:
exit 1
fi
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode_16.2.app || sudo xcode-select -s /Applications/Xcode.app
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: "pip"
- name: Install XcodeGen
- name: Install dependencies
run: |
if ! command -v xcodegen >/dev/null 2>&1; then
brew install xcodegen
fi
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
- name: Build
run: ./build.sh --debug
- name: Syntax check
run: |
cd linux
python -m py_compile blitztext/*.py
- name: Run Pytest
run: |
cd linux
PYTHONPATH=. pytest tests

8
.gitignore vendored
View File

@ -30,3 +30,11 @@ 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/

View File

@ -1,618 +0,0 @@
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
}

View File

@ -1,153 +0,0 @@
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
}
}
}
}

View File

@ -1,380 +0,0 @@
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
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,137 +0,0 @@
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)
}
}

View File

@ -1,837 +0,0 @@
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))
}
}

View File

@ -1,118 +0,0 @@
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)
}
}
}
}

View File

@ -1,118 +0,0 @@
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)
}
}
}
}

View File

@ -1,113 +0,0 @@
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)
}
}
}
}

View File

@ -1,138 +0,0 @@
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)
}
}
}
}

View File

@ -1,223 +0,0 @@
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"
}
}
}
}

Binary file not shown.

View File

@ -1,80 +0,0 @@
{
"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
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 388 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 601 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 601 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 931 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 931 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -1,6 +0,0 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -1,12 +0,0 @@
<?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>

View File

@ -1,28 +0,0 @@
<?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>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 B

View File

@ -1,34 +0,0 @@
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)
}
}

View File

@ -1,57 +0,0 @@
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
)
}
}

View File

@ -1,98 +0,0 @@
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"
}
}
}
}

View File

@ -1,108 +0,0 @@
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)
}
}

View File

@ -1,176 +0,0 @@
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)
}
}

View File

@ -1,131 +0,0 @@
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)
}
}

View File

@ -1,91 +0,0 @@
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)"
}
}
}

View File

@ -1,209 +0,0 @@
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
}
}

View File

@ -1,55 +0,0 @@
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."
}
}
}

View File

@ -1,310 +0,0 @@
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)
}
}

View File

@ -1,35 +0,0 @@
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
}
}

View File

@ -1,135 +0,0 @@
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)
}
}
}

View File

@ -1,96 +0,0 @@
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)
}
}

View File

@ -1,56 +0,0 @@
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

View File

@ -1,17 +1,22 @@
# Contributing
Thanks for taking a look at Blitztext macOS Preview.
Thanks for taking a look at Blitztext App Linux.
This repository is intentionally a preview. Contributions should make it easier to learn from, build, fork, or safely extend.
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.
## Good First Contributions
- improve build instructions
- improve Linux setup instructions
- add current Linux screenshots
- fix confusing UI text
- improve error messages
- add tests around parsing or quality filters
- document local model experiments
- simplify setup
- 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
## Before Opening A Pull Request
@ -27,24 +32,33 @@ Keep changes small when possible. Avoid unrelated cleanup in the same PR.
## Local Build
```bash
./build.sh --debug
cd linux
./install.sh
.venv/bin/python -m blitztext gui
```
Package build:
```bash
cd linux
bash packaging/build-deb.sh
```
## Security And Privacy
- Never commit API keys, tokens, private audio, or confidential transcripts.
- Never commit API keys, tokens, private audio, confidential transcripts, or private endpoint URLs.
- 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 OpenAI workflows as offline or local.
- Keep the preview honest: do not describe remote STT or rewrite workflows as offline or local.
## Project Boundaries
This preview currently does not include:
- other platforms
- a hosted backend
- packaged releases
- bundled local model files
- local text rewriting
- production support
- bundled STT model files
- guaranteed Wayland support
- local text rewriting unless the user configures a local OpenAI-compatible LLM endpoint
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.
Those can be discussed in issues, but please keep PRs focused unless a maintainer agrees on a larger direction first.

331
MANUAL.md Normal file
View File

@ -0,0 +1,331 @@
# 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
View File

@ -1,157 +1,575 @@
# Blitztext App
# Blitztext App Linux
Blitztext App is an experimental open-source macOS menubar app for turning speech into text.
**Speak into any text field on your Linux desktop — instantly.**
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.
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.
This is a learning and experimentation project, not a polished product.
> **Status:** Experimental open-source Linux/X11 desktop app (v1.7.0).
> No hosted backend — bring your own models and endpoints.
> Preview status: bring your own OpenAI API key, no hosted backend, no warranty, no support guarantee.
<p align="center">
<img src="Screenshots/main-panel.png" alt="Blitztext control panel" width="360">
</p>
## What It Does
<p align="center">
<img src="Screenshots/overlay-listening.png" alt="On-screen overlay while listening" width="360">
&nbsp;&nbsp;
<img src="Screenshots/overlay-result.png" alt="On-screen overlay showing transcription result" width="360">
</p>
- **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.
📖 **[User manual](MANUAL.md)** — every setting in every tab, explained.
## Important Preview Notes
---
- 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.
## Inspiration & Credits
You are welcome to use, fork, adapt, and share this project under the license terms.
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:
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.
| 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.
---
## Screenshots
<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>
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>
&nbsp;
<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>
&nbsp;&nbsp;
<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>
&nbsp;
<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>
&nbsp;&nbsp;
<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>
&nbsp;
<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>
&nbsp;&nbsp;
<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>
&nbsp;
<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>
&nbsp;&nbsp;
<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>
&nbsp;
<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>
&nbsp;&nbsp;
<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>
&nbsp;
<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>
&nbsp;&nbsp;
<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>
&nbsp;
<a href="Screenshots/setup-trigger.png"><img src="Screenshots/setup-trigger.png" alt="Setup — Trigger mode" width="32%"></a>
&nbsp;
<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 &nbsp;·&nbsp; Choose trigger mode (keyboard / wakeword / both) &nbsp;·&nbsp; 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>
&nbsp;
<a href="Screenshots/setup-stt.png"><img src="Screenshots/setup-stt.png" alt="Setup — STT engine" width="32%"></a>
&nbsp;
<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) &nbsp;·&nbsp; Choose STT engine &nbsp;·&nbsp; 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 |
---
## Requirements
- 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/`
- **Linux desktop with an X11 or Wayland session** (Wayland uses `wtype` or `ydotool`)
- **Python 3.11+** (for source installs)
- **Host tools:**
The build also pulls one Swift Package dependency automatically:
```bash
sudo apt install xdotool libnotify-bin pipewire-bin python3-gi
```
- [`argmax-oss-swift`](https://github.com/argmaxinc/argmax-oss-swift) (WhisperKit) — used for local on-device transcription.
- `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
Install XcodeGen if needed:
- **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:
```bash
brew install xcodegen
curl -fsSL https://raw.githubusercontent.com/mARTin-B78/blitztext-app-linux/main/install-linux.sh | bash
```
## Build And Run
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
```bash
git clone https://github.com/cmagnussen/blitztext-app.git
cd blitztext-app
./build.sh --run
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
```
For a local install into `/Applications`:
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)
```bash
./build.sh --install --run
git clone https://github.com/mARTin-B78/blitztext-app-linux.git
cd blitztext-app-linux/linux
./install.sh
```
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.
`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.
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.
> **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.
For fully local transcription, install a WhisperKit CoreML model and enable **Sicherer Lokaler Modus** in the app.
### Environment Variables
For a slower, more explicit walkthrough, see [docs/setup.md](docs/setup.md).
The only environment variable needed is for rewrite workflows that use a cloud LLM:
## 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
```bash
export OPENAI_API_KEY=sk-... # only if using OpenAI or a keyed endpoint
```
The app stores your OpenAI API key in the user's macOS Keychain.
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.
Read [docs/privacy.md](docs/privacy.md) before using the preview with sensitive content.
---
## 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.
---
## Project Structure
```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
```
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
```
## Local Models
---
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.
## Run on Login
See [docs/local-models.md](docs/local-models.md).
### 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.
---
## Contributing
Contributions are welcome, especially if they make the preview easier to build, understand, or fork.
See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
Please read [CONTRIBUTING.md](CONTRIBUTING.md) first.
**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
## Support And Roadmap
**Quick development loop:**
This preview has no formal support promise. See [SUPPORT.md](SUPPORT.md) for how to ask for help without sharing secrets.
```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
```
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).
Please read [SECURITY.md](SECURITY.md) before reporting vulnerabilities.
---
## License
Code is released under the MIT License. See [LICENSE](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.
```
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://www.blackboat.com/impressum
- Datenschutz / Privacy: https://www.blackboat.com/datenschutz
- Impressum: https://martin-bierschenk.de/impressum/
- Datenschutz / Privacy: https://martin-bierschenk.de/datenschutz/

View File

@ -4,28 +4,32 @@ This is a preview roadmap, not a promise.
## Current Scope
- 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
- 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
## Next Useful Work
- 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.
- 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.
## Not In Scope Yet
- Production support.
- Accounts, sync, teams, or hosted infrastructure.
- Claims that the app is offline or privacy-complete.
- Claims that the app is offline or privacy-complete by default.
- App Store distribution.
- A polished one-click consumer release.

View File

@ -1,6 +1,6 @@
# Security Policy
Blitztext macOS Preview is experimental software.
Blitztext App Linux is experimental software.
It is provided as-is, without warranty, support guarantees, or production-readiness claims.
@ -12,11 +12,9 @@ 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. Maintainers should enable it before making the repository public.
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.
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.
Do not include API keys, access tokens, private recordings, confidential transcripts, screenshots with sensitive text, or private endpoint URLs in a report.
Include:
@ -27,10 +25,11 @@ Include:
## Security Notes
- 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.
- 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.
Do not use this preview for confidential or regulated data without your own review.

View File

@ -1,13 +1,14 @@
# Support
Blitztext App is an experimental preview. There is no service-level agreement, paid support channel, or guarantee that issues will be fixed.
Blitztext App Linux 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 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.
- 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.
- Read [docs/privacy.md](docs/privacy.md) before testing with sensitive content.
## Where To Ask
@ -16,8 +17,9 @@ Use GitHub Issues for reproducible bugs and focused feature ideas.
Please do not post:
- OpenAI API keys
- API keys
- access tokens
- private endpoint URLs
- private audio recordings
- confidential transcripts
- screenshots that show sensitive content

BIN
Screenshots/main-panel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
Screenshots/panel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

BIN
Screenshots/setup-ai.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
Screenshots/setup-done.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

BIN
Screenshots/setup-stt.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
Screenshots/setup-voice.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
Screenshots/tray-menu.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
Screenshots/wakeword.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

View File

@ -2,6 +2,8 @@
The source code in this repository is licensed under 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.
Project names, logos, app icons, screenshots, and visual identity are not automatically 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
View File

@ -1,186 +0,0 @@
#!/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

View File

@ -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.
- Set repository topics after the project is public: `linux`, `dictation`, `speech-to-text`, `gtk`, `x11`, `faster-whisper`, `riva`, `nim`, `openai-compatible`.
- Review the GitHub community profile before sharing the repo widely.

View File

@ -1,22 +1,22 @@
# Landing Page Brief
Domain: `blitztext.app`
Domain: `blitztext.de`
Goal: a very small landing page for an experimental open-source macOS preview.
Goal: a small, honest landing page for an experimental open-source Linux dictation preview.
## Hero
Headline:
> Blitztext macOS Preview
> Blitztext App Linux
Subline:
> Speak your thoughts. Turn them into text, cleaner writing, or calmer messages.
> Speak into any focused Linux text field. Get text, cleaner writing, or live streaming transcripts.
Body:
> An experimental open-source macOS menubar app. Not finished, not hosted, not plug-and-play. Built to learn from, fork, and improve.
> 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.
Primary CTA:
@ -28,55 +28,61 @@ Secondary CTA:
Small line:
> Bring your own OpenAI API key. Optional local transcription. No hosted Blitztext backend.
> Local faster-whisper, optional Riva/NIM realtime STT, optional OpenAI-compatible rewriting. No hosted Blitztext backend.
## Sections
1. What it does
- Dictate
- Improve
- Calm down
- Dictate into the focused app
- Improve or rewrite rough speech
- Calm down messages
- Add emojis
- Stream live STT through Riva/NIM
2. How it works
- Build the app locally
- Paste your own OpenAI API key
- Install the Linux app or run from source
- Configure a local or remote STT engine
- Press a hotkey and speak
- Get text back on the clipboard
- Blitztext types into the focused X11 window
3. Open-source preview
- macOS-only
- Linux/X11 first
- MIT License
- inspired by `cmagnussen/blitztext-app`
- experimental
- no warranty
- optional local transcription with user-installed WhisperKit models
- no hosted backend
4. Privacy, plainly
- online workflows send audio and text to OpenAI
- secure local mode keeps transcription on device
- 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
- no public Blitztext backend
- rewriting still uses OpenAI
- do your own review before sensitive use
5. Roadmap
- easier setup
- signed releases
- local transcription experiments
- clearer setup and security docs
- community issues
- clearer setup
- current Linux screenshots
- Wayland support exploration
- better streaming diagnostics
- basic tests and release checks
## Do Not Promise
- offline use
- production readiness
- hosted service
- free usage without API costs
- no data leaves the device
- free usage without API or infrastructure costs
- no data leaves the device by default
- guaranteed support
- other platforms
- bundled local models
- local rewriting
- macOS support in the Linux app
- bundled STT models
- local rewriting unless the user configures a local LLM endpoint
## Visual Direction
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.
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/

View File

@ -1,72 +1,78 @@
# Local Models
# Local And Realtime Speech Models
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.
Blitztext App Linux supports two local-first speech paths:
## Recommended First Model
- in-process `faster-whisper` for batch transcription
- external Riva/NIM realtime servers for live streaming transcription
Use Whisper Small for the first local test. It is multilingual, supports German, and is much lighter than the large variants.
The app does not bundle speech models. You choose the model in Settings or in `~/.config/blitztext/config.toml`.
- [argmaxinc/whisperkit-coreml: openai_whisper-small_216MB](https://huggingface.co/argmaxinc/whisperkit-coreml/tree/main/openai_whisper-small_216MB)
## Local Batch Transcription
Local cache path:
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:
```text
~/Library/Application Support/Blitztext/models/whisperkit/openai_whisper-small_216MB
cache-aware-parakeet-rnnt-en-US-asr-streaming-sortformer
```
## Other Compatible Models
Recommended engine config:
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]"
```toml
[[stt_engine]]
name = "Nemotron ASR Streaming"
type = "riva_realtime"
url = "http://127.0.0.1:8006/v1"
model = ""
```
Create the local model cache:
Use `model = ""` to keep the server default. For the tested Nemotron container, set the general language to English:
```bash
mkdir -p "$HOME/Library/Application Support/Blitztext/models/whisperkit"
```toml
[general]
language = "en"
```
Download the recommended first model:
## Batch NIMs And Other STT Servers
```bash
hf download argmaxinc/whisperkit-coreml \
--include 'openai_whisper-small_216MB/*' \
--local-dir "$HOME/Library/Application Support/Blitztext/models/whisperkit" \
--max-workers 4
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"
```
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.
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.
## Notes
- 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.
- 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.

View File

@ -1,15 +1,18 @@
# Open Source Preflight
Use this checklist before making the repository public.
Use this checklist before making `blitztext-app-linux` public or cutting a public preview release.
## P0 Before Public
- Run a local build with `./build.sh --debug`.
- 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 secret scan across the working tree and commit history.
- 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.
- 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`.
- 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.
@ -17,15 +20,15 @@ Use this checklist before making the repository public.
## 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 `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.
- 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.
## 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 signed and notarized release artifacts for non-developer testers.
- Add Wayland support notes or implementation.
- Add signed release artifacts if the project becomes useful beyond developer previews.

View File

@ -1,36 +1,46 @@
# Privacy Notes
Blitztext macOS Preview does not include a hosted backend.
Blitztext App Linux does not include a hosted backend.
When you use the online workflows, your Mac sends data directly to OpenAI:
Data goes only to the engines and endpoints you configure:
- audio recordings for transcription
- transcribed or typed text for rewriting
- custom terms and prompt context if you configured them
- 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
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.
You are responsible for API access, billing, endpoint security, and data handling for any remote or local service you connect.
## Local Data
The app stores:
- 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
- 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
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.
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.
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.
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.
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.
## 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.
## Offline Scope
Only transcription can run locally. Any workflow that rewrites, improves, or transforms text still uses OpenAI.
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.
## Sensitive Content
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.
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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 511 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 728 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 567 KiB

View File

@ -1,80 +1,115 @@
# Setup
This guide is for people who want to build and inspect the preview themselves.
This guide is for people who want to build and inspect Blitztext App Linux themselves.
## 1. Requirements
- 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
- 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
Install XcodeGen manually if needed:
On Ubuntu/Debian:
```bash
brew install xcodegen
sudo apt install xdotool libnotify-bin pipewire-bin python3-gi
```
## 2. Clone And Build
## 2. Clone And Install
```bash
git clone https://github.com/cmagnussen/blitztext-app.git
cd blitztext-app
./build.sh --debug
git clone https://github.com/mARTin-B78/blitztext-app-linux.git
cd blitztext-app-linux/linux
./install.sh
```
To launch after building:
If your local repository still uses the older `blitztext-app` name, the commands are the same once you `cd linux`.
## 3. Run
```bash
./build.sh --run
.venv/bin/python -m blitztext tray
```
## 3. Configure OpenAI For Online Workflows
Alternatives:
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/
```bash
.venv/bin/python -m blitztext gui
.venv/bin/python -m blitztext run
.venv/bin/python -m blitztext config-path
```
Recommended first model: `openai_whisper-small_216MB`.
## 4. Debian Package
See [local-models.md](local-models.md) for the exact command, model links, and expected folder layout.
```bash
cd linux
bash packaging/build-deb.sh
sudo apt install ./dist/blitztext_*.deb
blitztext tray
```
## 5. macOS Permissions
The package installs Blitztext under `/opt/blitztext`, adds a desktop entry, and bundles the Python dependencies from `requirements.txt`.
The app needs Microphone permission to record audio.
## 5. Configure STT Engines
For automatic paste into the previous app, grant Accessibility permission in macOS System Settings. Without it, you can still copy and paste manually.
Open **Settings > Engines**.
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.
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
## Troubleshooting
- 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.
- 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.

100
install-linux.sh Executable file
View File

@ -0,0 +1,100 @@
#!/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 ""

View File

@ -9,6 +9,787 @@ 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 300500 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 4052 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 ~10001360 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 1040010403, 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
@ -74,7 +855,9 @@ 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/compare/v1.1.0...HEAD
[1.1.0]: https://github.com/mARTin-B78/blitztext-app/compare/v1.0.1...v1.1.0
[1.0.1]: https://github.com/mARTin-B78/blitztext-app/compare/v1.0.0...v1.0.1
[1.0.0]: https://github.com/mARTin-B78/blitztext-app/releases/tag/v1.0.0
[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

View File

@ -8,8 +8,23 @@ 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`.) Transcription is **local** via
[faster-whisper]; only the optional rewrite step calls out to an LLM.
`~/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">
&nbsp;&nbsp;
<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`.
## How it works
@ -20,10 +35,121 @@ 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 hotkey **toggles**: press to start recording, press again to stop — then it
transcribes, optionally rewrites, and types the result where your cursor is.
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>
&nbsp;
<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>
&nbsp;&nbsp;
<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>
&nbsp;
<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>
&nbsp;&nbsp;
<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>
&nbsp;
<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>
&nbsp;&nbsp;
<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>
&nbsp;
<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>
&nbsp;&nbsp;
<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>
&nbsp;
<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>
&nbsp;&nbsp;
<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>
&nbsp;
<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>
&nbsp;&nbsp;
<em><b>About</b> — version, source link, inline changelog, and licence.</em>
</p>
## Requirements
@ -34,6 +160,8 @@ transcribes, optionally rewrites, and types the result where your cursor is.
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
@ -60,8 +188,8 @@ cd linux
./install.sh
```
This creates `.venv`, installs `faster-whisper` + `pynput`, and writes the
default config to `~/.config/blitztext/config.toml`.
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
@ -70,7 +198,7 @@ default config to `~/.config/blitztext/config.toml`.
## Run
Three front-ends, same engine (local Whisper + global hotkeys + xdotool typing):
Three front-ends, same engine layer (STT engines + global hotkeys + xdotool typing):
```bash
# optional: only needed for the "rewrite" workflows
@ -81,6 +209,29 @@ 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
@ -165,3 +316,18 @@ 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/

View File

@ -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__ = "1.1.0"
__version__ = "2.03.41"

View File

@ -1,58 +1,6 @@
"""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
"""Module entry point so `python -m blitztext` runs the app in blitztext.py."""
from .blitztext import main
if __name__ == "__main__":
raise SystemExit(main())

142
linux/blitztext/audio.py Normal file
View File

@ -0,0 +1,142 @@
"""Audio helpers: enumerate input devices and a live input-level meter.
Mic enumeration uses pactl (PipeWire/PulseAudio source names, which pw-record
and parecord accept via --target/-d). The level meter shells out to the same
system recorder the app uses (pw-record/parecord/arecord), reading raw PCM from
its stdout and reporting a 0..1 level to a callback no Python audio binding,
so it works wherever the recorder does (PortAudio/sounddevice can't open the
default input on some PipeWire systems).
"""
from __future__ import annotations
import shutil
import subprocess
import threading
from .recorder import detect_recorder
# Raw-PCM (s16le, 16 kHz mono) variants of the recorders, streamed to stdout so
# we can RMS each chunk directly. Mirrors recorder.py's WAV commands but emits
# headerless PCM. pw-record/parecord default to stdout; arecord uses "-t raw".
_METER_ARGV: dict[str, list[str]] = {
"pw-record": ["pw-record", "--rate=16000", "--channels=1", "--format=s16", "-"],
"parecord": ["parecord", "--rate=16000", "--channels=1", "--format=s16le"],
"arecord": ["arecord", "-q", "-f", "S16_LE", "-r", "16000", "-c", "1", "-t", "raw"],
}
# How to point each recorder at a specific pactl/pipewire source (mirrors
# recorder._DEVICE_FLAG; arecord uses ALSA names, so it stays on the default).
_DEVICE_FLAG: dict[str, list[str]] = {
"pw-record": ["--target"],
"parecord": ["-d"],
"arecord": [],
}
_CHUNK_BYTES = 3200 # 100 ms of 16 kHz, 16-bit, mono → ~10 Hz level updates
def list_mics() -> list[tuple[str, str]]:
"""Return [(source_name, friendly_label)] for real input sources.
The first entry is always the system default ("", "Default device").
Monitor sources (loopback of outputs) are excluded.
"""
mics: list[tuple[str, str]] = [("", "Default device")]
if not shutil.which("pactl"):
return mics
try:
out = subprocess.run(
["pactl", "list", "short", "sources"], capture_output=True, text=True, check=True
).stdout
except (OSError, subprocess.CalledProcessError):
return mics
for line in out.splitlines():
parts = line.split("\t")
if len(parts) < 2:
continue
name = parts[1]
if name.endswith(".monitor"):
continue
label = name.replace("alsa_input.", "").replace("-", " ")
mics.append((name, label))
return mics
class LevelMeter:
"""Stream mic audio via a system recorder and call `on_level(0..1)` ~10x/s.
Uses pw-record/parecord/arecord (the same recorders as the WAV recorder)
rather than a Python audio binding, so it works on PipeWire boxes where
PortAudio can't open the default input. Best-effort: ``start()`` returns
False if no recorder is available or the device can't be opened.
"""
def __init__(self, device: str = "", on_level=None, recorder: str = "auto", on_chunk=None):
self.device = device or ""
self.on_level = on_level
self.on_chunk = on_chunk # optional: called with raw s16le PCM bytes each chunk
self._recorder = recorder
self._proc: subprocess.Popen | None = None
self._thread: threading.Thread | None = None
self._stop = threading.Event()
def _argv(self, recorder: str) -> list[str]:
argv = list(_METER_ARGV[recorder])
flag = _DEVICE_FLAG.get(recorder, [])
if self.device and flag:
argv += flag + [self.device]
return argv
def start(self) -> bool:
try:
recorder = detect_recorder(self._recorder)
except RuntimeError:
return False
if recorder not in _METER_ARGV:
return False
try:
self._proc = subprocess.Popen(
self._argv(recorder), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL
)
except OSError:
self._proc = None
return False
self._stop.clear()
self._thread = threading.Thread(target=self._loop, daemon=True, name="LevelMeter")
self._thread.start()
return True
def _loop(self) -> None:
import numpy as np
proc = self._proc
if proc is None or proc.stdout is None:
return
try:
while not self._stop.is_set() and proc.poll() is None:
chunk = proc.stdout.read(_CHUNK_BYTES)
if not chunk:
break
if self.on_chunk:
self.on_chunk(chunk)
samples = np.frombuffer(chunk, dtype=np.int16).astype(np.float32) / 32768.0
level = float(np.sqrt(np.mean(np.square(samples)))) if samples.size else 0.0
if self.on_level:
# Scale RMS (typically small) into a usable 0..1 range.
self.on_level(min(1.0, level * 12.0))
except Exception: # noqa: BLE001 - metering is eye-candy; never crash the app
pass
def stop(self) -> None:
self._stop.set()
proc, self._proc = self._proc, None
if proc is not None and proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=1.0)
except subprocess.TimeoutExpired:
proc.kill()
thread, self._thread = self._thread, None
if thread is not None:
thread.join(timeout=1.0)

Some files were not shown because too many files have changed in this diff Show More