Initial commit: HotKeet + parakeet-cli

HotKeet: Push-to-Talk Diktier-App mit Parakeet-Transkription
- Konfiguration per Datei-/Ordner-Dialog
- Mikrofon-Auswahl persistent
- Tray, Einstellungen, Signaltöne

parakeet-cli: Transkriptions-CLI für HotKeet
- ONNX-basierte Spracherkennung
- WAV-Input, JSON-Output

Made-with: Cursor
This commit is contained in:
2026-03-06 19:40:32 +01:00
commit e7cc3fbb50
28 changed files with 8356 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Rust
target/
**/*.rs.bk
# IDE
.idea/
.vscode/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
desktop.ini
# Build-Artefakte
*.exe
*.dll
*.pdb
*.exp
*.lib
# Temporäre Dateien
*.tmp
*.temp
*.log
# Assets (generierte ICOs)
assets/*.ico

View File

@@ -0,0 +1,6 @@
# Linker-Pfad für msvcrt.lib (Visual Studio 2022 MSVC)
[target.x86_64-pc-windows-msvc]
rustflags = [
"-C",
"link-arg=/LIBPATH:C:\\Program Files\\Microsoft Visual Studio\\2022\\Community\\VC\\Tools\\MSVC\\14.44.35207\\lib\\x64",
]

4702
HotKeet/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

36
HotKeet/Cargo.toml Normal file
View File

@@ -0,0 +1,36 @@
[package]
name = "hotkeet"
version = "0.1.0"
edition = "2021"
description = "HotKeet Push-to-Talk Diktier-App mit Parakeet-Transkription"
build = "build.rs"
[features]
default = ["glow"]
# glow = OpenGL, oft kompatibel mit Windows Server / RDP
glow = ["eframe/glow"]
# wgpu = DirectX, bessere Performance auf Systemen mit GPU
wgpu = ["eframe/wgpu"]
[dependencies]
rdev = { git = "https://github.com/rustdesk-org/rdev" }
cpal = "0.16"
tokio = { version = "1", features = ["net", "rt-multi-thread", "sync", "io-util", "macros", "time"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
enigo = "0.2"
eframe = { version = "0.29", default-features = false, features = ["default_fonts"] }
egui = "0.29"
tray-item = "0.10"
hound = "3.5"
arboard = "3.2"
chrono = "0.4"
raw-window-handle = "0.6"
rfd = "0.14"
[build-dependencies]
winresource = "0.1"
[target.'cfg(windows)'.dependencies]
winapi = { version = "0.3", features = ["shellapi", "winuser", "processthreadsapi"] }
windows-sys = { version = "0.52", features = ["Win32_UI_WindowsAndMessaging", "Win32_Media_Audio"] }

75
HotKeet/PLATFORM.md Normal file
View File

@@ -0,0 +1,75 @@
# HotKeet Plattform-Unterstützung
## Übersicht
| Plattform | Status | Hinweise |
|-----------|--------|----------|
| **Windows** | ✅ Voll unterstützt | Primäre Zielplattform |
| **Linux** | ⚠️ Lauffähig mit Anpassungen | Tray, Audio, Hotkeys funktionieren |
| **macOS** | ⚠️ Lauffähig mit Anpassungen | Tray-Events müssen im Main-Thread laufen |
## Windows
- System-Tray mit Icon
- PlaySound/MessageBeep für Signaltöne
- WM_CLOSE → Minimieren ins Tray
- Konfiguration: `%LOCALAPPDATA%\hotkeet\settings.json`
- Parakeet-Standardpfade: `C:\voice2text\`
## Linux
### Voraussetzungen
- **Tray**: `libappindicator3` oder `libayatana-appindicator3` (für System-Tray)
- **Audio**: `cpal` nutzt ALSA/PulseAudio
- **Hotkeys**: `rdev` benötigt evtl. Root oder Input-Gruppe für globale Hotkeys
### Konfiguration
- Pfad: `~/.config/hotkeet/settings.json` (oder `$XDG_CONFIG_HOME/hotkeet/`)
- Parakeet-CLI Standard: `/usr/local/bin/parakeet-cli`
- Modell Standard: `/usr/local/share/voice2text/models/parakeet-tdt-0.6b-v3-int8`
### Bekannte Einschränkungen
- **Wayland**: Tray-Support kann je nach Desktop-Umgebung variieren
- **Signaltöne**: Terminal-Bell (`\x07`) statt System-Sounds
- **Fokus-Wiederherstellung**: `target_hwnd` (SetForegroundWindow) nur unter Windows
## macOS
### Voraussetzungen
- **Tray**: `tray-item` nutzt Cocoa; macOS erfordert, dass UI-Events im Main-Thread verarbeitet werden
- **Audio**: `cpal` nutzt CoreAudio
- **Hotkeys**: `rdev` ggf. Berechtigungen für Eingabemonitoring nötig
### Konfiguration
- Pfad: `~/.config/hotkeet/settings.json` (bzw. `$XDG_CONFIG_HOME/hotkeet/`)
- Parakeet-CLI Standard: `/usr/local/bin/parakeet-cli`
### Bekannte Einschränkungen
- **Tray**: Unter macOS müssen Tray-Events im Main-Thread laufen; ggf. Anpassung nötig
- **Signaltöne**: Terminal-Bell statt System-Sounds
- **Fokus**: Kein HWND-Äquivalent
## Build
```bash
# Windows (MSVC)
cargo build --release
# Linux
cargo build --release
# macOS
cargo build --release
```
**Hinweis**: Die `.cargo/config.toml` enthält Windows-spezifische Linker-Flags und wird unter Linux/macOS ignoriert.
## Migration von mediSchnack-dictate
Alte Konfiguration unter `%LOCALAPPDATA%\medischnack\dictate-settings.json` wird nicht automatisch übernommen. Bei Bedarf manuell nach `hotkeet/settings.json` kopieren.

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

21
HotKeet/build.cmd Normal file
View File

@@ -0,0 +1,21 @@
@echo off
REM Build-Skript: Setzt LIB-Pfad für msvcrt.lib (Visual Studio 2022)
REM Orientierung: parakeet-cli - gleiche Toolchain
set "LIB_PATH=C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\lib\x64"
if defined LIB (
set "LIB=%LIB_PATH%;%LIB%"
) else (
set "LIB=%LIB_PATH%"
)
if "%~1"=="" (
echo Building debug...
cargo build
if errorlevel 1 exit /b 1
echo Building release...
cargo build --release
if errorlevel 1 exit /b 1
echo Done.
) else (
cargo build %*
)

9
HotKeet/build.ps1 Normal file
View File

@@ -0,0 +1,9 @@
# Build-Skript: Setzt LIB-Pfad für msvcrt.lib (Visual Studio 2022)
# Orientierung: parakeet-cli gleiche Toolchain
$libPath = "C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.44.35207\lib\x64"
if ($env:LIB) {
$env:LIB = "$libPath;$env:LIB"
} else {
$env:LIB = $libPath
}
cargo build @args

41
HotKeet/build.rs Normal file
View File

@@ -0,0 +1,41 @@
//! Build-Script: PNG → ICO (ImageMagick), Icon einbetten (winresource)
fn main() {
println!("cargo:rerun-if-changed=assets/dictate-icon.png");
#[cfg(windows)]
embed_icon();
}
#[cfg(windows)]
fn embed_icon() {
use std::path::Path;
use std::process::Command;
let png_path = Path::new("assets/dictate-icon.png");
let ico_path = Path::new("assets/dictate-icon.ico");
let magick = r"C:\Program Files\WindowsApps\ImageMagick.Q8_7.1.2.15_x64__b3hnabsze9y3j\magick.exe";
if png_path.exists() {
// PNG → ICO mit mehreren Größen (16, 32, 48, 64) für Tray
let status = Command::new(magick)
.args([
png_path.to_str().unwrap(),
"-define",
"icon:auto-resize=64,48,32,16",
ico_path.to_str().unwrap(),
])
.status();
if let Ok(s) = status {
if s.success() && ico_path.exists() {
winresource::WindowsResource::new()
.set_icon_with_id(ico_path.to_str().unwrap(), "TRAYICON")
.compile()
.expect("Icon einbetten fehlgeschlagen");
return;
}
}
}
// Fallback: Kein Icon eingebettet, Tray nutzt IDI_APPLICATION
}

79
HotKeet/src/companion.rs Normal file
View File

@@ -0,0 +1,79 @@
//! Companion TCP-Client, Frame-Protokoll.
//! Identisch zu CompanionClient.cs / CompanionProtocol.cs:
//! - Frame-Header: 5 Byte (1 Byte Typ, 4 Byte Länge Big-Endian)
//! - Typen: Audio=1, Command=2
//! - Audio: 16 kHz, Mono, 16-bit PCM
use std::net::{TcpStream, ToSocketAddrs};
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio::net::TcpStream as TokioTcpStream;
/// Prüft die TCP-Verbindung zum Companion (synchron, kurzer Timeout).
pub fn check_connection(host: &str, port: u16) -> Result<(), String> {
if host.trim().is_empty() {
return Err("Host leer".to_string());
}
let addrs: Vec<_> = (host.trim(), port)
.to_socket_addrs()
.map_err(|e| e.to_string())?
.collect();
let addr = addrs
.first()
.ok_or_else(|| "Host konnte nicht aufgelöst werden".to_string())?;
TcpStream::connect_timeout(addr, Duration::from_secs(2)).map_err(|e| e.to_string())?;
Ok(())
}
pub const TYPE_AUDIO: u8 = 1;
#[allow(dead_code)]
pub const TYPE_COMMAND: u8 = 2;
/// Liest einen Frame aus dem Stream.
/// Returns (type, payload) oder None bei EOF.
pub async fn read_frame(stream: &mut TokioTcpStream) -> std::io::Result<Option<(u8, Vec<u8>)>> {
let mut header = [0u8; 5];
let n = stream.read(&mut header).await?;
if n == 0 {
return Ok(None);
}
if n < 5 {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"Unvollständiger Companion-Frame-Header",
));
}
let frame_type = header[0];
let length = u32::from_be_bytes([header[1], header[2], header[3], header[4]]) as usize;
if length > 10 * 1024 * 1024 {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"Ungültige Companion-Frame-Länge",
));
}
let mut payload = vec![0u8; length];
let mut offset = 0;
while offset < length {
let r = stream.read(&mut payload[offset..]).await?;
if r == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"Verbindung während Companion-Frame abgebrochen",
));
}
offset += r;
}
Ok(Some((frame_type, payload)))
}
/// Parst einen Command-Frame (UTF-8 String)
#[allow(dead_code)]
pub fn parse_command(payload: &[u8]) -> Result<String, std::io::Error> {
let s = std::str::from_utf8(payload)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
Ok(s.trim().to_string())
}

185
HotKeet/src/config.rs Normal file
View File

@@ -0,0 +1,185 @@
//! Konfiguration für HotKeet.
//! Speicherort: Windows %LOCALAPPDATA%\hotkeet\, Linux/macOS ~/.config/hotkeet/
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Paste-Methode: Auto = Tastaturpuffer zuerst, Fallback Clipboard
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum PasteMethod {
#[default]
Auto,
Keyboard,
Clipboard,
}
impl PasteMethod {
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"keyboard" => PasteMethod::Keyboard,
"clipboard" => PasteMethod::Clipboard,
_ => PasteMethod::Auto,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DictateConfig {
#[serde(default = "default_hotkey")]
pub global_hotkey: String,
#[serde(default)]
pub use_companion_microphone: bool,
#[serde(default)]
pub companion_host: String,
#[serde(default = "default_companion_port")]
pub companion_port: u16,
#[serde(default)]
pub input_device_index: usize,
/// Gespeicherter Gerätename für Zuordnung (Index kann sich ändern)
#[serde(default)]
pub input_device_name: String,
#[serde(default)]
pub parakeet_cli_path: String,
#[serde(default)]
pub model_path: String,
#[serde(default = "default_true")]
pub start_minimized: bool,
#[serde(default = "default_true")]
pub minimize_to_tray: bool,
#[serde(default)]
pub paste_method: String,
/// Debug-Logging (paste-debug.log, Konsole) Standard: aus
#[serde(default)]
pub debug_logging: bool,
/// Signaltöne bei Start und Ende des Diktats (wie mediSchnack)
#[serde(default = "default_true")]
pub sound_on_start_end: bool,
}
fn default_hotkey() -> String {
"Ctrl+Shift+D".to_string()
}
fn default_companion_port() -> u16 {
52000
}
fn default_true() -> bool {
true
}
impl Default for DictateConfig {
fn default() -> Self {
Self {
global_hotkey: default_hotkey(),
use_companion_microphone: false,
companion_host: String::new(),
companion_port: default_companion_port(),
input_device_index: 0,
input_device_name: String::new(),
parakeet_cli_path: String::new(),
model_path: String::new(),
start_minimized: true,
minimize_to_tray: true,
paste_method: "Auto".to_string(),
debug_logging: false,
sound_on_start_end: true,
}
}
}
impl DictateConfig {
/// Pfad zur Konfigurationsdatei (plattformabhängig)
pub fn config_path() -> PathBuf {
#[cfg(windows)]
{
let base = std::env::var("LOCALAPPDATA")
.unwrap_or_else(|_| std::env::var("USERPROFILE").unwrap_or_else(|_| ".".to_string()));
PathBuf::from(base).join("hotkeet").join("settings.json")
}
#[cfg(not(windows))]
{
let base = std::env::var("XDG_CONFIG_HOME")
.unwrap_or_else(|_| {
std::env::var("HOME")
.map(|h| format!("{}/.config", h))
.unwrap_or_else(|_| ".".to_string())
});
PathBuf::from(base).join("hotkeet").join("settings.json")
}
}
/// Konfiguration laden
pub fn load() -> Self {
let path = Self::config_path();
if path.exists() {
if let Ok(data) = std::fs::read_to_string(&path) {
if let Ok(mut cfg) = serde_json::from_str::<DictateConfig>(&data) {
// Migration: parakeet_cli_path enthielt Modellpfad → in model_path
if !cfg.parakeet_cli_path.is_empty()
&& !cfg.parakeet_cli_path.to_lowercase().ends_with(".exe")
&& std::path::Path::new(&cfg.parakeet_cli_path).is_dir()
{
cfg.model_path = cfg.parakeet_cli_path.clone();
cfg.parakeet_cli_path = String::new();
}
return cfg;
}
}
}
DictateConfig::default()
}
/// Konfiguration speichern
pub fn save(&self) -> std::io::Result<()> {
let path = Self::config_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let data = serde_json::to_string_pretty(self).map_err(|e| {
std::io::Error::new(std::io::ErrorKind::InvalidData, e)
})?;
std::fs::write(&path, data)
}
pub fn paste_method_enum(&self) -> PasteMethod {
PasteMethod::from_str(&self.paste_method)
}
/// Prüft ob parakeet-cli und Modellpfad gültig sind.
/// Gültig = beide leer (Standard) ODER gesetzte Pfade existieren.
/// Ungültig = mindestens ein gesetzter Pfad existiert nicht → UI öffnen.
pub fn has_valid_parakeet_config(&self) -> bool {
if !self.parakeet_cli_path.is_empty() {
let p = std::path::Path::new(&self.parakeet_cli_path);
if !p.is_file() {
return false;
}
}
if !self.model_path.is_empty() {
let p = std::path::Path::new(&self.model_path);
if !p.is_dir() {
return false;
}
}
true
}
/// Beide Felder leer (Erststart/Reset) → UI öffnen, damit Nutzer konfigurieren kann.
pub fn needs_initial_config(&self) -> bool {
self.parakeet_cli_path.is_empty() && self.model_path.is_empty()
}
}

292
HotKeet/src/hotkey.rs Normal file
View File

@@ -0,0 +1,292 @@
//! Push-to-Talk Hotkey via rdev.
//! KeyDown = Aufnahme starten, KeyUp = Aufnahme stoppen.
use rdev::{Event, EventType, Key};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, RwLock};
/// Parsed Hotkey: Haupttaste + Modifier
#[derive(Debug, Clone)]
pub struct HotkeySpec {
pub main_key: Key,
pub ctrl: bool,
pub shift: bool,
pub alt: bool,
}
impl HotkeySpec {
/// Parse "Ctrl+Shift+D" etc.
pub fn parse(s: &str) -> Option<Self> {
let parts: Vec<&str> = s.split('+').map(|p| p.trim()).collect();
if parts.is_empty() {
return None;
}
let mut ctrl = false;
let mut shift = false;
let mut alt = false;
let mut main_key = None;
for p in parts {
let lower = p.to_lowercase();
match lower.as_str() {
"ctrl" | "control" => ctrl = true,
"shift" => shift = true,
"alt" => alt = true,
"altgr" => {
ctrl = true;
alt = true;
}
"d" => main_key = Some(Key::KeyD),
"s" => main_key = Some(Key::KeyS),
"r" => main_key = Some(Key::KeyR),
"space" => main_key = Some(Key::Space),
"f1" => main_key = Some(Key::F1),
"f2" => main_key = Some(Key::F2),
"f3" => main_key = Some(Key::F3),
"f4" => main_key = Some(Key::F4),
"f5" => main_key = Some(Key::F5),
"f6" => main_key = Some(Key::F6),
"f7" => main_key = Some(Key::F7),
"f8" => main_key = Some(Key::F8),
"f9" => main_key = Some(Key::F9),
"f10" => main_key = Some(Key::F10),
"f11" => main_key = Some(Key::F11),
"f12" => main_key = Some(Key::F12),
"." | "period" => main_key = Some(Key::Dot),
"," | "comma" => main_key = Some(Key::Comma),
"-" | "minus" => main_key = Some(Key::Minus),
"+" | "plus" => main_key = Some(Key::Equal),
";" | "semicolon" => main_key = Some(Key::SemiColon),
"'" | "quote" => main_key = Some(Key::Quote),
"/" | "slash" => main_key = Some(Key::Slash),
"\\" | "backslash" => main_key = Some(Key::BackSlash),
"`" | "backtick" | "grave" => main_key = Some(Key::BackQuote),
"[" | "openbracket" => main_key = Some(Key::LeftBracket),
"]" | "closebracket" => main_key = Some(Key::RightBracket),
"=" | "equals" | "equal" => main_key = Some(Key::Equal),
_ => {
if p.len() == 1 {
main_key = char_to_key(p.chars().next().unwrap());
}
}
}
}
main_key.map(|k| HotkeySpec {
main_key: k,
ctrl,
shift,
alt,
})
}
/// Prüft ob dieses Event ein KeyDown des Hotkeys ist (Modifier müssen passen)
/// AltGr (rechte Alt) sendet unter Windows oft Ctrl+Alt wir akzeptieren das als "Alt".
pub fn matches_key_down(&self, event: &Event, modifiers: &ModifierState) -> bool {
if let EventType::KeyPress(key) = event.event_type {
if key != self.main_key {
return false;
}
// AltGr sendet unter Windows Ctrl+Alt "Alt+X" soll auch bei AltGr+X greifen
let altgr = modifiers.ctrl && modifiers.alt;
let ctrl_ok = modifiers.ctrl == self.ctrl
|| (altgr && self.alt && !self.ctrl);
let shift_ok = modifiers.shift == self.shift;
let alt_ok = modifiers.alt == self.alt || (altgr && self.alt && !self.ctrl);
ctrl_ok && shift_ok && alt_ok
} else {
false
}
}
/// Prüft ob dieses Event ein KeyUp des Hotkeys ist
pub fn matches_key_up(&self, event: &Event) -> bool {
if let EventType::KeyRelease(key) = event.event_type {
key == self.main_key
} else {
false
}
}
}
/// Formatiert Hotkey-String aus Modifiern und Tastenname (z.B. von egui Key::name()).
/// Gibt None zurück wenn die Taste nicht als Haupttaste unterstützt wird (z.B. reine Modifier).
pub fn format_hotkey(ctrl: bool, shift: bool, alt: bool, key_name: &str) -> Option<String> {
let key_lower = key_name.to_lowercase();
if matches!(
key_lower.as_str(),
"control" | "ctrl" | "shift" | "alt" | "altgr" | "meta" | "command"
) {
return None;
}
let mut parts = Vec::new();
if ctrl && alt && !shift {
parts.push("AltGr");
} else {
if ctrl {
parts.push("Ctrl");
}
if shift {
parts.push("Shift");
}
if alt {
parts.push("Alt");
}
}
parts.push(key_name);
let s = parts.join("+");
if HotkeySpec::parse(&s).is_some() {
Some(s)
} else {
None
}
}
fn char_to_key(c: char) -> Option<Key> {
match c.to_ascii_lowercase() {
'.' => Some(Key::Dot),
',' => Some(Key::Comma),
'-' => Some(Key::Minus),
';' => Some(Key::SemiColon),
'\'' => Some(Key::Quote),
'/' => Some(Key::Slash),
'\\' => Some(Key::BackSlash),
'`' => Some(Key::BackQuote),
'[' => Some(Key::LeftBracket),
']' => Some(Key::RightBracket),
'=' => Some(Key::Equal),
'a' => Some(Key::KeyA),
'b' => Some(Key::KeyB),
'c' => Some(Key::KeyC),
'd' => Some(Key::KeyD),
'e' => Some(Key::KeyE),
'f' => Some(Key::KeyF),
'g' => Some(Key::KeyG),
'h' => Some(Key::KeyH),
'i' => Some(Key::KeyI),
'j' => Some(Key::KeyJ),
'k' => Some(Key::KeyK),
'l' => Some(Key::KeyL),
'm' => Some(Key::KeyM),
'n' => Some(Key::KeyN),
'o' => Some(Key::KeyO),
'p' => Some(Key::KeyP),
'q' => Some(Key::KeyQ),
'r' => Some(Key::KeyR),
's' => Some(Key::KeyS),
't' => Some(Key::KeyT),
'u' => Some(Key::KeyU),
'v' => Some(Key::KeyV),
'w' => Some(Key::KeyW),
'x' => Some(Key::KeyX),
'y' => Some(Key::KeyY),
'z' => Some(Key::KeyZ),
_ => None,
}
}
trait ToAsciiLowercase {
fn to_ascii_lowercase(self) -> char;
}
impl ToAsciiLowercase for char {
fn to_ascii_lowercase(self) -> char {
if self.is_ascii_alphabetic() {
(self as u8 | 32) as char
} else {
self
}
}
}
#[derive(Debug, Default)]
pub struct ModifierState {
pub ctrl: bool,
pub shift: bool,
pub alt: bool,
}
/// Globale Modifier-Zustände für den Hotkey-Callback
static MODIFIER_CTRL: AtomicBool = AtomicBool::new(false);
static MODIFIER_SHIFT: AtomicBool = AtomicBool::new(false);
static MODIFIER_ALT: AtomicBool = AtomicBool::new(false);
fn update_modifiers_from_atomic(event: &Event) {
match &event.event_type {
EventType::KeyPress(Key::ControlLeft | Key::ControlRight) => {
MODIFIER_CTRL.store(true, Ordering::SeqCst)
}
EventType::KeyRelease(Key::ControlLeft | Key::ControlRight) => {
MODIFIER_CTRL.store(false, Ordering::SeqCst)
}
EventType::KeyPress(Key::ShiftLeft | Key::ShiftRight) => {
MODIFIER_SHIFT.store(true, Ordering::SeqCst)
}
EventType::KeyRelease(Key::ShiftLeft | Key::ShiftRight) => {
MODIFIER_SHIFT.store(false, Ordering::SeqCst)
}
EventType::KeyPress(Key::Alt) => MODIFIER_ALT.store(true, Ordering::SeqCst),
EventType::KeyRelease(Key::Alt) => MODIFIER_ALT.store(false, Ordering::SeqCst),
EventType::KeyPress(Key::AltGr) => MODIFIER_ALT.store(true, Ordering::SeqCst),
EventType::KeyRelease(Key::AltGr) => MODIFIER_ALT.store(false, Ordering::SeqCst),
_ => {}
}
}
fn get_modifier_state() -> ModifierState {
ModifierState {
ctrl: MODIFIER_CTRL.load(Ordering::SeqCst),
shift: MODIFIER_SHIFT.load(Ordering::SeqCst),
alt: MODIFIER_ALT.load(Ordering::SeqCst),
}
}
/// Callback-Typ: (start, target_hwnd). Bei start=true: HWND des Zielfensters (für Fokus).
pub type HotkeyCallback = Box<dyn Fn(bool, Option<usize>) + Send + Sync>;
#[cfg(windows)]
fn get_foreground_window() -> Option<usize> {
use winapi::um::winuser::GetForegroundWindow;
let hwnd = unsafe { GetForegroundWindow() };
if hwnd.is_null() {
None
} else {
Some(hwnd as usize)
}
}
#[cfg(not(windows))]
fn get_foreground_window() -> Option<usize> {
None
}
/// Startet den rdev-Listener in einem separaten Thread.
/// Liest den Hotkey bei jedem Tastendruck aus der Config Änderungen greifen sofort.
pub fn spawn_listener(
config: Arc<RwLock<crate::config::DictateConfig>>,
callback: HotkeyCallback,
) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
let cb = callback;
if let Err(e) = rdev::listen(move |event| {
update_modifiers_from_atomic(&event);
let mods = get_modifier_state();
let hotkey_str = config
.read()
.map(|c| c.global_hotkey.clone())
.unwrap_or_else(|_| "Ctrl+Shift+D".to_string());
let spec = HotkeySpec::parse(&hotkey_str).unwrap_or_else(|| {
HotkeySpec::parse("Ctrl+Shift+D").expect("default hotkey")
});
if spec.matches_key_down(&event, &mods) {
let hwnd = get_foreground_window();
cb(true, hwnd);
} else if spec.matches_key_up(&event) {
cb(false, None);
}
}) {
eprintln!("rdev listen error: {:?}", e);
}
})
}

546
HotKeet/src/main.rs Normal file
View File

@@ -0,0 +1,546 @@
//! HotKeet: Push-to-Talk Diktier-App
//! Hotkey gedrückt = aufnehmen, losgelassen = stoppen, transkribieren, einfügen.
#![cfg_attr(windows, windows_subsystem = "windows")]
mod companion;
mod sound;
mod win_close;
use raw_window_handle::HasWindowHandle;
#[cfg(windows)]
fn show_error(msg: &str) {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use winapi::um::winuser::{MessageBoxW, MB_ICONERROR};
let msg_wide: Vec<u16> = OsStr::new(msg)
.encode_wide()
.chain(std::iter::once(0))
.collect();
let title: Vec<u16> = OsStr::new("HotKeet Fehler")
.encode_wide()
.chain(std::iter::once(0))
.collect();
unsafe {
MessageBoxW(
std::ptr::null_mut(),
msg_wide.as_ptr(),
title.as_ptr(),
MB_ICONERROR,
);
}
}
#[cfg(not(windows))]
fn show_error(msg: &str) {
eprintln!("Fehler: {}", msg);
}
#[cfg(windows)]
fn show_main_window() {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use winapi::um::winuser::{FindWindowW, ShowWindow, SW_RESTORE, SW_SHOW};
let title: Vec<u16> = OsStr::new("HotKeet")
.encode_wide()
.chain(std::iter::once(0))
.collect();
let hwnd = unsafe { FindWindowW(std::ptr::null_mut(), title.as_ptr()) };
if !hwnd.is_null() {
unsafe {
ShowWindow(hwnd, SW_RESTORE);
ShowWindow(hwnd, SW_SHOW);
}
}
}
mod config;
mod hotkey;
mod paste;
mod recording;
mod transcription;
mod tray;
mod ui;
use config::{DictateConfig, PasteMethod};
use paste::paste_text;
use recording::{record_companion, record_local, write_wav};
use transcription::transcribe;
use egui::{ViewportEvent, ViewportId};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, Sender};
use std::sync::Arc;
use std::time::{Duration, Instant};
/// Paste-Anfrage vom Aufnahme-Thread an den Haupt-Thread (Clipboard/Enigo brauchen UI-Thread).
struct PasteRequest {
text: String,
method: PasteMethod,
target_hwnd: Option<usize>,
debug_logging: bool,
}
#[derive(Clone, Copy, PartialEq)]
enum AppStatus {
Bereit,
Aufnahme,
Transkribieren,
Fertig,
Fehler,
}
/// Companion-Verbindungsstatus: None = nicht geprüft, Some(Ok) = verbunden, Some(Err) = Fehler
type CompanionStatus = Option<Result<(), String>>;
struct AppState {
settings: ui::SettingsApp,
recording_stop: Option<Arc<AtomicBool>>,
hotkey_rx: Receiver<(bool, Option<usize>)>,
paste_tx: Sender<PasteRequest>,
paste_rx: Receiver<PasteRequest>,
test_rx: Receiver<()>,
tray_rx: Receiver<tray::TrayMessage>,
config: Arc<std::sync::RwLock<DictateConfig>>,
status: Arc<std::sync::RwLock<AppStatus>>,
status_detail: Arc<std::sync::RwLock<String>>,
last_hotkey: Arc<std::sync::RwLock<Option<Instant>>>,
companion_status: Arc<std::sync::RwLock<CompanionStatus>>,
/// Nach dem ersten Frame verstecken (eframe setzt post_rendering sichtbar)
start_minimized_pending: bool,
frame_count: u32,
/// Schließkreuz → minimieren (in raw_input_hook gesetzt)
pending_minimize_from_close: bool,
}
impl eframe::App for AppState {
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) {
// Schließkreuz: Close-Event abfangen und minimieren statt beenden
let minimize = self.config.read().map(|c| c.minimize_to_tray).unwrap_or(true);
if minimize {
if let Some(viewport) = raw_input.viewports.get_mut(&ViewportId::ROOT) {
if viewport.events.iter().any(|e| *e == ViewportEvent::Close) {
viewport.events.retain(|e| *e != ViewportEvent::Close);
self.pending_minimize_from_close = true;
}
}
}
}
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
self.frame_count = self.frame_count.saturating_add(1);
// Nach 1. Frame: eframe post_rendering hat Fenster sichtbar gemacht wieder verstecken
if self.start_minimized_pending && self.frame_count >= 2 {
self.start_minimized_pending = false;
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
}
// Schließkreuz (aus raw_input_hook): Fenster verstecken statt beenden
if self.pending_minimize_from_close {
self.pending_minimize_from_close = false;
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
}
// Fallback falls raw_input_hook nicht greift (z.B. andere Viewports)
if ctx.input(|i| i.viewport().close_requested()) {
let minimize = self.config.read().map(|c| c.minimize_to_tray).unwrap_or(true);
if minimize {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
}
}
// Nur ein Hotkey-Event pro Frame verarbeiten, um KeyDown+KeyUp-Race zu vermeiden.
// KeyDown ignorieren wenn bereits Aufnahme läuft (Key-Repeat beim Gedrückthalten).
if let Ok((start, target_hwnd)) = self.hotkey_rx.try_recv() {
let _ = self.last_hotkey.write().map(|mut w| *w = Some(Instant::now()));
if start {
// Key-Repeat ignorieren: Nur starten wenn noch keine Aufnahme läuft
if self.recording_stop.is_none() {
let stop_flag = Arc::new(AtomicBool::new(false));
self.recording_stop = Some(stop_flag.clone());
let cfg = self.config.read().unwrap().clone();
let status = self.status.clone();
let status_detail = self.status_detail.clone();
let paste_tx = self.paste_tx.clone();
std::thread::spawn(move || {
run_recording(&cfg, stop_flag, status, status_detail, paste_tx, target_hwnd);
});
}
} else {
if let Some(ref stop) = self.recording_stop {
stop.store(true, Ordering::SeqCst);
}
self.recording_stop = None;
}
}
let status_str = self
.status
.read()
.map(|s| match *s {
AppStatus::Bereit => "Bereit",
AppStatus::Aufnahme => "Aufnahme",
AppStatus::Transkribieren => "Transkribieren",
AppStatus::Fertig => "Fertig",
AppStatus::Fehler => "Fehler",
})
.unwrap_or("?");
let detail = self
.status_detail
.read()
.map(|s| s.clone())
.unwrap_or_default();
let hotkey_info = self
.last_hotkey
.read()
.ok()
.and_then(|o| *o)
.map(|t| format!("vor {} s", t.elapsed().as_secs()))
.unwrap_or_else(|| "noch nie empfangen".to_string());
let companion_status = self
.companion_status
.read()
.map(|g| (*g).clone())
.unwrap_or(None);
self.settings
.ui(ctx, status_str, &detail, &hotkey_info, &companion_status);
while let Ok(()) = self.test_rx.try_recv() {
let _ = self.last_hotkey.write().map(|mut w| *w = Some(Instant::now()));
let stop_flag = Arc::new(AtomicBool::new(false));
self.recording_stop = Some(stop_flag.clone());
let cfg = self.config.read().unwrap().clone();
let status = self.status.clone();
let status_detail = self.status_detail.clone();
let stop_for_test = stop_flag.clone();
let paste_tx = self.paste_tx.clone();
std::thread::spawn(move || {
run_recording(&cfg, stop_flag, status, status_detail, paste_tx, None);
});
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(3));
stop_for_test.store(true, Ordering::SeqCst);
});
}
while let Ok(msg) = self.tray_rx.try_recv() {
match msg {
tray::TrayMessage::ShowSettings => {
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
}
tray::TrayMessage::Quit => {
// ViewportCommand::Close funktioniert bei verstecktem Fenster oft nicht
std::process::exit(0);
}
}
}
// Paste auf Haupt-Thread ausführen (Clipboard/Enigo brauchen UI-Thread unter Windows)
while let Ok(req) = self.paste_rx.try_recv() {
match paste_text(&req.text, req.method, req.target_hwnd, req.debug_logging) {
Ok(()) => {
let preview = if req.text.len() > 40 {
format!("{}", &req.text[..40])
} else {
req.text.clone()
};
set_status(
&self.status,
&self.status_detail,
AppStatus::Fertig,
&format!("Eingefügt: {}", preview),
);
}
Err(e) => {
eprintln!("Einfügen: {}", e);
set_status(
&self.status,
&self.status_detail,
AppStatus::Fehler,
&format!("Einfügen: {}", e),
);
}
}
let status_reset = self.status.clone();
let detail_reset = self.status_detail.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_secs(5));
let _ = status_reset.write().map(|mut w| *w = AppStatus::Bereit);
let _ = detail_reset.write().map(|mut w| *w = String::new());
});
}
// Regelmäßig neu zeichnen, damit Hotkey-Events verarbeitet werden
ctx.request_repaint_after(std::time::Duration::from_millis(50));
}
}
fn main() -> eframe::Result<()> {
std::panic::set_hook(Box::new(|panic_info| {
let msg = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
format!("{}\n\n{:?}", s, panic_info.location())
} else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
format!("{}\n\n{:?}", s, panic_info.location())
} else {
format!("{:?}", panic_info)
};
show_error(&msg);
}));
let config = DictateConfig::load();
let config_arc = Arc::new(std::sync::RwLock::new(config));
let (hotkey_tx, hotkey_rx): (Sender<(bool, Option<usize>)>, Receiver<(bool, Option<usize>)>) =
std::sync::mpsc::channel();
let (paste_tx, paste_rx): (Sender<PasteRequest>, Receiver<PasteRequest>) =
std::sync::mpsc::channel();
let (test_tx, test_rx): (Sender<()>, Receiver<()>) = std::sync::mpsc::channel();
let (tray_tx, tray_rx): (Sender<tray::TrayMessage>, Receiver<tray::TrayMessage>) =
std::sync::mpsc::channel();
let tray_rx = {
#[cfg(windows)]
{
std::thread::spawn(move || {
while let Ok(msg) = tray_rx.recv() {
match msg {
tray::TrayMessage::Quit => std::process::exit(0),
tray::TrayMessage::ShowSettings => show_main_window(),
}
}
});
let (_dummy_tx, dummy_rx) = std::sync::mpsc::channel();
dummy_rx
}
#[cfg(not(windows))]
{
tray_rx
}
};
let hotkey_cb = Box::new(move |start: bool, hwnd: Option<usize>| {
let _ = hotkey_tx.send((start, hwnd));
});
let _hotkey_thread = hotkey::spawn_listener(config_arc.clone(), hotkey_cb);
let config_ui = config_arc.read().unwrap().clone();
let companion_status = Arc::new(std::sync::RwLock::new(None));
// Hintergrund-Thread: Companion-Verbindung periodisch prüfen
{
let config_arc = config_arc.clone();
let companion_status = companion_status.clone();
std::thread::spawn(move || loop {
std::thread::sleep(Duration::from_secs(3));
let cfg = match config_arc.read() {
Ok(g) => g.clone(),
Err(_) => continue,
};
let status = if cfg.use_companion_microphone {
Some(companion::check_connection(&cfg.companion_host, cfg.companion_port))
} else {
None
};
if let Ok(mut w) = companion_status.write() {
*w = status;
}
});
}
let _tray = tray::create_tray(tray_tx);
// Bei ungültigen Parakeet-Pfaden oder Erststart (beide leer): UI öffnen
let start_minimized = if config_ui.needs_initial_config() || !config_ui.has_valid_parakeet_config()
{
false
} else {
config_ui.start_minimized
};
let minimize_to_tray = config_ui.minimize_to_tray;
let state = AppState {
settings: ui::SettingsApp::new(config_ui)
.with_config_sync(config_arc.clone())
.with_test_sender(test_tx),
recording_stop: None,
hotkey_rx,
paste_tx,
paste_rx,
test_rx,
tray_rx,
config: config_arc,
status: Arc::new(std::sync::RwLock::new(AppStatus::Bereit)),
status_detail: Arc::new(std::sync::RwLock::new(String::new())),
last_hotkey: Arc::new(std::sync::RwLock::new(None)),
companion_status,
start_minimized_pending: start_minimized,
frame_count: 0,
pending_minimize_from_close: false,
};
let mut viewport = egui::ViewportBuilder::default()
.with_inner_size([400.0, 400.0])
.with_min_inner_size([300.0, 300.0]);
if start_minimized {
viewport = viewport.with_visible(false);
}
if let Err(e) = eframe::run_native(
"HotKeet",
eframe::NativeOptions {
viewport,
..Default::default()
},
Box::new(move |cc| {
let raw = cc.window_handle().map(|h| h.as_raw().clone());
win_close::subclass_for_minimize_to_tray(raw, minimize_to_tray);
Ok(Box::new(state))
}),
) {
let err_str = e.to_string();
let hint = if err_str.to_lowercase().contains("opengl")
|| err_str.to_lowercase().contains("wgpu")
|| err_str.to_lowercase().contains("adapter")
{
"\n\nWindows Server ohne GPU:\n\
- opengl32.dll von Mesa3D (fdossena.com/mesa) neben die EXE legen\n\
- oder App auf Arbeitsplatz-PC mit GPU ausführen"
} else {
""
};
show_error(&format!("Start fehlgeschlagen: {}{}", e, hint));
std::process::exit(1);
}
Ok(())
}
fn set_status(
status: &Arc<std::sync::RwLock<AppStatus>>,
detail: &Arc<std::sync::RwLock<String>>,
s: AppStatus,
d: &str,
) {
let _ = status.write().map(|mut w| *w = s);
let _ = detail.write().map(|mut w| *w = d.to_string());
}
fn run_recording(
config: &DictateConfig,
stop_flag: Arc<AtomicBool>,
status: Arc<std::sync::RwLock<AppStatus>>,
status_detail: Arc<std::sync::RwLock<String>>,
paste_tx: Sender<PasteRequest>,
target_hwnd: Option<usize>,
) {
if config.sound_on_start_end {
sound::play_start(config.debug_logging);
}
set_status(&status, &status_detail, AppStatus::Aufnahme, "Aufnahme läuft…");
let use_companion = config.use_companion_microphone && !config.companion_host.is_empty();
if config.debug_logging {
eprintln!(
"[recording] Quelle: {}, target_hwnd: {:?}",
if use_companion { "Companion" } else { "Lokal" },
target_hwnd
);
}
let samples = if use_companion {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(record_companion(
&config.companion_host,
config.companion_port,
stop_flag,
))
} else {
record_local(config, stop_flag)
};
let samples = match samples {
Ok(s) => s,
Err(e) => {
eprintln!("Aufnahme fehlgeschlagen: {}", e);
set_status(&status, &status_detail, AppStatus::Fehler, &format!("Aufnahme: {}", e));
return;
}
};
let n_samples = samples.len();
let duration_ms = (n_samples as f64 / 16000.0) * 1000.0;
if config.debug_logging {
eprintln!(
"[recording] Aufnahme fertig: {} Samples, ~{:.0} ms",
n_samples, duration_ms
);
}
if samples.is_empty() {
if config.debug_logging {
eprintln!("[recording] Keine Samples Aufnahme zu kurz oder Companion sendet nichts");
}
set_status(&status, &status_detail, AppStatus::Bereit, "");
return;
}
set_status(&status, &status_detail, AppStatus::Transkribieren, "Transkribieren…");
if config.sound_on_start_end {
sound::play_end(config.debug_logging);
}
let temp_dir = std::env::temp_dir();
let wav_path = temp_dir.join("hotkeet-temp.wav");
if let Err(e) = write_wav(&wav_path, &samples) {
eprintln!("WAV schreiben: {}", e);
set_status(&status, &status_detail, AppStatus::Fehler, &format!("WAV: {}", e));
return;
}
let text = match transcribe(
&config.parakeet_cli_path,
&config.model_path,
&wav_path,
) {
Ok(t) => t,
Err(e) => {
eprintln!("Transkription: {}", e);
let _ = std::fs::remove_file(&wav_path);
set_status(&status, &status_detail, AppStatus::Fehler, &format!("Transkription: {}", e));
return;
}
};
let _ = std::fs::remove_file(&wav_path);
if config.debug_logging {
eprintln!(
"[recording] Transkription: {} Zeichen, Text: {:?}",
text.len(),
if text.len() > 50 {
format!("{}", &text[..50])
} else {
text.clone()
}
);
if text.is_empty() && n_samples > 0 {
eprintln!(
"[recording] Parakeet lieferte leeren Text trotz {} Samples evtl. Aufnahme zu kurz (<1s) oder nur Stille",
n_samples
);
}
}
let req = PasteRequest {
text,
method: config.paste_method_enum(),
target_hwnd,
debug_logging: config.debug_logging,
};
if paste_tx.send(req).is_err() {
eprintln!("Paste-Kanal geschlossen");
set_status(&status, &status_detail, AppStatus::Fehler, "Paste-Kanal fehlgeschlagen");
}
}

162
HotKeet/src/paste.rs Normal file
View File

@@ -0,0 +1,162 @@
//! Texteinfügung: Primär enigo.text (Tastaturpuffer), Fallback Clipboard + Ctrl+V.
//! Windows: Key::Other(0x56) für VK_V wie bei Handy (layout-unabhängig).
use crate::config::PasteMethod;
use enigo::{Direction, Enigo, Key, Keyboard, Settings};
use std::io::Write;
fn paste_log(msg: &str, enabled: bool) {
if !enabled {
return;
}
eprintln!("[paste] {}", msg);
let log_dir = crate::config::DictateConfig::config_path()
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."));
let path = log_dir.join("paste-debug.log");
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path) {
let _ = writeln!(f, "{} {}", chrono::Local::now().format("%H:%M:%S%.3f"), msg);
}
}
#[cfg(windows)]
fn set_foreground_window(hwnd: usize) -> Result<(), String> {
use std::mem::transmute;
use winapi::shared::windef::HWND;
use winapi::um::processthreadsapi::GetCurrentThreadId;
use winapi::um::winuser::{
AttachThreadInput, GetForegroundWindow, GetWindowThreadProcessId, SetForegroundWindow,
};
let h: HWND = unsafe { transmute(hwnd) };
let foreground = unsafe { GetForegroundWindow() };
if foreground == h {
return Ok(()); // Bereits im Vordergrund
}
let our_tid = unsafe { GetCurrentThreadId() };
let foreground_tid = unsafe { GetWindowThreadProcessId(foreground, std::ptr::null_mut()) };
let attached = unsafe { AttachThreadInput(our_tid, foreground_tid, 1) != 0 };
if attached {
unsafe {
SetForegroundWindow(h);
AttachThreadInput(our_tid, foreground_tid, 0);
}
} else {
let _ = unsafe { SetForegroundWindow(h) };
}
Ok(())
}
#[cfg(not(windows))]
fn set_foreground_window(_hwnd: usize) -> Result<(), String> {
Ok(())
}
/// Fügt Text ein. Primär Tastaturpuffer, Fallback Clipboard.
/// target_hwnd: Bei Hotkey-Aufnahme gespeichertes Zielfenster für Fokus-Wiederherstellung.
/// debug_logging: Logging in Konsole und paste-debug.log.
pub fn paste_text(
text: &str,
method: PasteMethod,
target_hwnd: Option<usize>,
debug_logging: bool,
) -> Result<(), String> {
paste_log(
&format!("paste_text start, len={}, method={:?}, hwnd={:?}", text.len(), method, target_hwnd),
debug_logging,
);
if text.is_empty() {
paste_log("skip: leerer Text", debug_logging);
return Ok(());
}
// Fokus wiederherstellen und warten, damit das Zielfenster bereit ist
if let Some(hwnd) = target_hwnd {
paste_log(&format!("SetForegroundWindow hwnd={}", hwnd), debug_logging);
set_foreground_window(hwnd)?;
std::thread::sleep(std::time::Duration::from_millis(500));
} else {
paste_log("kein target_hwnd (z.B. Test-Button)", debug_logging);
}
let result = match method {
PasteMethod::Keyboard => {
paste_log("Methode: Keyboard (enigo.text)", debug_logging);
paste_via_keyboard(text, debug_logging)
}
PasteMethod::Clipboard => {
paste_log("Methode: Clipboard", debug_logging);
paste_via_clipboard(text, debug_logging)
}
PasteMethod::Auto => {
paste_log("Methode: Auto (Keyboard zuerst)", debug_logging);
paste_via_keyboard(text, debug_logging).or_else(|e| {
paste_log(&format!("Keyboard fehlgeschlagen: {}, Fallback Clipboard", e), debug_logging);
paste_via_clipboard(text, debug_logging)
})
}
};
if result.is_ok() {
paste_log("paste_text OK", debug_logging);
} else {
paste_log(&format!("paste_text Fehler: {:?}", result), debug_logging);
}
result
}
fn paste_via_keyboard(text: &str, debug: bool) -> Result<(), String> {
paste_log("paste_via_keyboard: Enigo init", debug);
let mut enigo = Enigo::new(&Settings::default()).map_err(|e| {
paste_log(&format!("Enigo init Fehler: {:?}", e), debug);
format!("Enigo init: {:?}", e)
})?;
paste_log(&format!("paste_via_keyboard: enigo.text {} Zeichen", text.len()), debug);
enigo.text(text).map_err(|e| {
paste_log(&format!("enigo.text Fehler: {:?}", e), debug);
format!("enigo.text: {:?}", e)
})?;
paste_log("paste_via_keyboard OK", debug);
Ok(())
}
/// Ctrl+V wie bei Handy: Windows nutzt Key::Other(0x56) (VK_V) für layout-unabhängiges Paste.
fn paste_via_clipboard(text: &str, debug: bool) -> Result<(), String> {
paste_log("paste_via_clipboard: Clipboard init", debug);
let mut clipboard = arboard::Clipboard::new().map_err(|e| {
paste_log(&format!("Clipboard init Fehler: {}", e), debug);
format!("Clipboard: {}", e)
})?;
paste_log(&format!("paste_via_clipboard: set_text {} Zeichen", text.len()), debug);
clipboard.set_text(text).map_err(|e| {
paste_log(&format!("Clipboard set_text Fehler: {}", e), debug);
format!("Clipboard set: {}", e)
})?;
paste_log("Clipboard set_text OK", debug);
std::thread::sleep(std::time::Duration::from_millis(100));
paste_log("paste_via_clipboard: Enigo init für Ctrl+V", debug);
let mut enigo = Enigo::new(&Settings::default()).map_err(|e| {
paste_log(&format!("Enigo init Fehler: {:?}", e), debug);
format!("Enigo init: {:?}", e)
})?;
#[cfg(windows)]
let v_key = Key::Other(0x56); // VK_V layout-unabhängig wie bei Handy
#[cfg(not(windows))]
let v_key = Key::Unicode('v');
enigo.key(Key::Control, Direction::Press).map_err(|e| format!("Ctrl: {:?}", e))?;
enigo.key(v_key, Direction::Click).map_err(|e| format!("V: {:?}", e))?;
std::thread::sleep(std::time::Duration::from_millis(100)); // wie Handy
enigo.key(Key::Control, Direction::Release).map_err(|e| format!("Ctrl: {:?}", e))?;
paste_log("paste_via_clipboard: Ctrl+V gesendet, OK", debug);
Ok(())
}

242
HotKeet/src/recording.rs Normal file
View File

@@ -0,0 +1,242 @@
//! Aufnahme: cpal (lokal) oder Companion (TCP).
//! Ausgabe: WAV 16 kHz, Mono, 16-bit PCM (für Parakeet).
use crate::companion::{read_frame, TYPE_AUDIO};
use crate::config::DictateConfig;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use cpal::Sample;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;
use tokio::net::TcpStream;
/// Eintrag für die Eingabequellen-Auswahl: (source_index, Anzeigename)
/// source_index 0 = Companion-App, 1.. = Mikrofon (input_device_index = source_index - 1)
pub fn list_input_sources() -> Vec<(usize, String)> {
let mut sources = vec![(0, "Companion-App".to_string())];
let devices = match cpal::default_host().input_devices() {
Ok(d) => d,
Err(_) => return sources,
};
for (idx, device) in devices.enumerate() {
let name = device.name().unwrap_or_else(|_| format!("Mikrofon {}", idx));
sources.push((idx + 1, format!("Mikrofon: {}", name)));
}
sources
}
/// Ermittelt den cpal-Device-Index aus der Config (Name hat Vorrang vor Index).
pub fn resolve_input_device_index(config: &DictateConfig) -> usize {
if !config.input_device_name.is_empty() {
let devices = match cpal::default_host().input_devices() {
Ok(d) => d,
Err(_) => return config.input_device_index,
};
for (idx, device) in devices.enumerate() {
let name = device.name().unwrap_or_else(|_| format!("Mikrofon {}", idx));
let display = format!("Mikrofon: {}", name);
if display == config.input_device_name {
return idx;
}
}
}
config.input_device_index
}
const TARGET_SAMPLE_RATE: u32 = 16000;
const TARGET_CHANNELS: u16 = 1;
/// Sammelt Audio-Samples während der Aufnahme
struct SampleCollector {
samples: Arc<std::sync::Mutex<Vec<i16>>>,
stop: AtomicBool,
}
impl SampleCollector {
fn new() -> Self {
Self {
samples: Arc::new(std::sync::Mutex::new(Vec::new())),
stop: AtomicBool::new(false),
}
}
fn add_i16(&self, s: i16) {
if !self.stop.load(Ordering::SeqCst) {
if let Ok(mut v) = self.samples.lock() {
v.push(s);
}
}
}
fn stop(&self) {
self.stop.store(true, Ordering::SeqCst);
}
fn take(&self) -> Vec<i16> {
if let Ok(mut v) = self.samples.lock() {
std::mem::take(&mut *v)
} else {
Vec::new()
}
}
}
/// Lokale Aufnahme mit cpal. Läuft bis stop_flag gesetzt wird.
pub fn record_local(
config: &DictateConfig,
stop_flag: Arc<AtomicBool>,
) -> Result<Vec<i16>, String> {
let host = cpal::default_host();
let device_index = resolve_input_device_index(config);
let device = host
.input_devices()
.map_err(|e| format!("Kein Audiogerät: {}", e))?
.nth(device_index)
.or_else(|| host.default_input_device())
.ok_or("Kein Eingabegerät gefunden")?;
let supported = device
.default_input_config()
.map_err(|e| format!("Kein Input-Config: {}", e))?;
let sample_rate = supported.sample_rate();
let collector = Arc::new(SampleCollector::new());
let collector_clone = collector.clone();
let err_fn = move |err| eprintln!("cpal stream error: {}", err);
let channels = supported.channels() as usize;
let stream = match supported.sample_format() {
cpal::SampleFormat::I16 => {
let c = collector_clone.clone();
device
.build_input_stream(
&supported.into(),
move |data: &[i16], _: &_| {
for (i, &s) in data.iter().enumerate() {
if channels == 1 || i % channels == 0 {
c.add_i16(s);
}
}
},
err_fn,
None,
)
.map_err(|e| format!("Stream build: {}", e))?
}
cpal::SampleFormat::F32 => {
let c = collector_clone.clone();
device
.build_input_stream(
&supported.into(),
move |data: &[f32], _: &_| {
for (i, &s) in data.iter().enumerate() {
if channels == 1 || i % channels == 0 {
c.add_i16(s.to_sample::<i16>());
}
}
},
err_fn,
None,
)
.map_err(|e| format!("Stream build: {}", e))?
}
_ => return Err("Nur I16/F32 unterstützt".to_string()),
};
stream.play().map_err(|e| format!("Stream play: {}", e))?;
let start = std::time::Instant::now();
let min_duration = Duration::from_millis(1200); // Mind. 1,2 s Parakeet braucht genug Audio
while !stop_flag.load(Ordering::SeqCst) || start.elapsed() < min_duration {
std::thread::sleep(Duration::from_millis(50));
}
collector.stop();
drop(stream);
let mut samples = collector.take();
let in_rate = sample_rate.0;
if in_rate != TARGET_SAMPLE_RATE {
samples = resample_i16(&samples, in_rate, TARGET_SAMPLE_RATE);
}
Ok(samples)
}
/// Einfache Resampling (linear)
fn resample_i16(samples: &[i16], from_rate: u32, to_rate: u32) -> Vec<i16> {
if from_rate == to_rate {
return samples.to_vec();
}
let ratio = from_rate as f64 / to_rate as f64;
let out_len = (samples.len() as f64 / ratio) as usize;
let mut out = Vec::with_capacity(out_len);
for i in 0..out_len {
let src_idx = i as f64 * ratio;
let idx0 = src_idx.floor() as usize;
let idx1 = (idx0 + 1).min(samples.len().saturating_sub(1));
let frac = src_idx - idx0 as f64;
let s0 = samples.get(idx0).copied().unwrap_or(0) as f64;
let s1 = samples.get(idx1).copied().unwrap_or(0) as f64;
let s = s0 * (1.0 - frac) + s1 * frac;
out.push(s.clamp(-32768.0, 32767.0) as i16);
}
out
}
/// Companion-Aufnahme: verbindet sich, sammelt Audio-Frames bis stop_flag.
pub async fn record_companion(
host: &str,
port: u16,
stop_flag: Arc<AtomicBool>,
) -> Result<Vec<i16>, String> {
let addr = format!("{}:{}", host, port);
let mut stream = TcpStream::connect(&addr)
.await
.map_err(|e| format!("Companion verbinden: {}", e))?;
let mut all_audio: Vec<i16> = Vec::new();
let start = std::time::Instant::now();
let min_duration = Duration::from_millis(1200); // Mind. 1,2 s Parakeet braucht genug Audio
while !stop_flag.load(Ordering::SeqCst) || start.elapsed() < min_duration {
tokio::select! {
result = read_frame(&mut stream) => {
match result.map_err(|e: std::io::Error| e.to_string())? {
Some((t, payload)) => {
if t == TYPE_AUDIO {
let samples: Vec<i16> = payload
.chunks_exact(2)
.map(|c| i16::from_le_bytes([c[0], c[1]]))
.collect();
all_audio.extend(samples);
}
}
None => break,
}
}
_ = tokio::time::sleep(Duration::from_millis(50)) => {}
}
}
Ok(all_audio)
}
/// Schreibt i16-Mono-Samples als WAV
pub fn write_wav(path: &std::path::Path, samples: &[i16]) -> Result<(), String> {
let spec = hound::WavSpec {
channels: TARGET_CHANNELS,
sample_rate: TARGET_SAMPLE_RATE,
bits_per_sample: 16,
sample_format: hound::SampleFormat::Int,
};
let mut writer = hound::WavWriter::create(path, spec)
.map_err(|e| format!("WAV erstellen: {}", e))?;
for &s in samples {
writer.write_sample(s).map_err(|e| format!("WAV schreiben: {}", e))?;
}
writer.finalize().map_err(|e| format!("WAV finalisieren: {}", e))?;
Ok(())
}

102
HotKeet/src/sound.rs Normal file
View File

@@ -0,0 +1,102 @@
//! Signaltöne bei Start und Ende des Diktats (wie mediSchnack).
//! Nutzt PlaySound (System-Sounds) mit MessageBeep als Fallback.
use std::io::Write;
/// Kurzer Ton beim Start der Aufnahme
pub fn play_start(debug_logging: bool) {
play_beep(880, debug_logging);
}
/// Kurzer Ton beim Ende der Aufnahme
pub fn play_end(debug_logging: bool) {
play_beep(660, debug_logging);
}
#[cfg(windows)]
fn sound_log(debug: bool, msg: &str) {
if !debug {
return;
}
eprintln!("[sound] {}", msg);
let log_dir = crate::config::DictateConfig::config_path()
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| std::path::PathBuf::from("."));
let path = log_dir.join("sound-debug.log");
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
{
let _ = writeln!(f, "{} {}", chrono::Local::now().format("%H:%M:%S%.3f"), msg);
}
}
#[cfg(windows)]
fn play_beep(freq_hz: u32, debug_logging: bool) {
std::thread::spawn(move || {
sound_log(debug_logging, &format!("play_beep {} Hz", freq_hz));
// 1. PlaySound System-Sounds (SystemAsterisk / SystemExclamation)
let ok = try_play_sound(freq_hz);
sound_log(debug_logging, &format!("PlaySound: {}", if ok { "OK" } else { "fehlgeschlagen" }));
if ok {
return;
}
// 2. Fallback: MessageBeep
sound_log(debug_logging, "MessageBeep (Fallback)");
try_message_beep(freq_hz);
});
}
#[cfg(windows)]
fn try_message_beep(freq_hz: u32) {
use winapi::um::winuser::{MessageBeep, MB_ICONEXCLAMATION, MB_ICONINFORMATION};
unsafe {
MessageBeep(if freq_hz >= 800 {
MB_ICONINFORMATION
} else {
MB_ICONEXCLAMATION
});
}
}
#[cfg(windows)]
fn try_play_sound(freq_hz: u32) -> bool {
use std::ffi::OsStr;
use std::os::windows::ffi::OsStrExt;
use windows_sys::Win32::Media::Audio::*;
let name: Vec<u16> = OsStr::new(if freq_hz >= 800 {
"SystemAsterisk"
} else {
"SystemExclamation"
})
.encode_wide()
.chain(std::iter::once(0))
.collect();
let result = unsafe {
PlaySoundW(
name.as_ptr(),
0,
SND_ALIAS | SND_ASYNC, // ohne SND_NODEFAULT = Standard-Beep bei Fehler
)
};
result != 0
}
#[cfg(not(windows))]
fn play_beep(_freq_hz: u32, _debug_logging: bool) {
// Terminal-Bell (funktioniert in den meisten Linux/macOS-Terminals)
std::thread::spawn(|| {
eprint!("\x07");
let _ = std::io::stderr().lock().flush();
});
}

View File

@@ -0,0 +1,67 @@
//! Transkription via parakeet-cli Subprozess.
//! Aufruf: parakeet-cli "<model_dir>" "<audio.wav>"
//! Ausgabe: JSON {"text": "..."} auf stdout
use std::path::Path;
use std::process::Command;
#[cfg(windows)]
use std::os::windows::process::CommandExt;
#[cfg(windows)]
const DEFAULT_MODEL: &str = r"c:\voice2text\models\parakeet-tdt-0.6b-v3-int8";
#[cfg(not(windows))]
const DEFAULT_MODEL: &str = "/usr/local/share/voice2text/models/parakeet-tdt-0.6b-v3-int8";
/// CREATE_NO_WINDOW: Subprozess ohne Konsolenfenster starten
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
/// Transkribiert eine WAV-Datei mit parakeet-cli.
/// Gibt den Text zurück oder einen Fehler-String.
pub fn transcribe(
parakeet_cli_path: &str,
model_dir: &str,
wav_path: &Path,
) -> Result<String, String> {
let cli = if parakeet_cli_path.is_empty() {
"parakeet-cli"
} else {
parakeet_cli_path
};
let model = if model_dir.is_empty() {
DEFAULT_MODEL
} else {
model_dir
};
let mut cmd = Command::new(cli);
cmd.arg(model).arg(wav_path);
#[cfg(windows)]
cmd.creation_flags(CREATE_NO_WINDOW);
let output = cmd
.output()
.map_err(|e| format!("parakeet-cli starten: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("parakeet-cli Fehler: {}", stderr));
}
let stdout = String::from_utf8(output.stdout)
.map_err(|_| "parakeet-cli Ausgabe ist kein UTF-8")?;
let json: serde_json::Value = serde_json::from_str(stdout.trim())
.map_err(|e| format!("parakeet-cli JSON: {}", e))?;
let text = json
.get("text")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
Ok(text)
}

46
HotKeet/src/tray.rs Normal file
View File

@@ -0,0 +1,46 @@
//! System-Tray unter Windows
use std::sync::mpsc::Sender;
use tray_item::{IconSource, TrayItem};
pub enum TrayMessage {
ShowSettings,
Quit,
}
#[cfg(windows)]
fn default_icon() -> IconSource {
use windows_sys::Win32::UI::WindowsAndMessaging::{LoadIconW, IDI_APPLICATION};
let hicon = unsafe { LoadIconW(0, IDI_APPLICATION) };
if hicon == 0 {
IconSource::Resource("")
} else {
IconSource::RawIcon(hicon)
}
}
#[cfg(not(windows))]
fn default_icon() -> IconSource {
IconSource::Resource("")
}
pub fn create_tray(tx: Sender<TrayMessage>) -> Option<tray_item::TrayItem> {
// Eingebettetes Icon (TRAYICON) zuerst, sonst Fallback
let icon = IconSource::Resource("TRAYICON");
let mut tray = TrayItem::new("HotKeet", icon)
.or_else(|_| TrayItem::new("HotKeet", default_icon()))
.ok()?;
let tx_show = tx.clone();
tray.add_menu_item("Einstellungen", move || {
let _ = tx_show.send(TrayMessage::ShowSettings);
})
.ok()?;
tray.add_menu_item("Beenden", move || {
let _ = tx.send(TrayMessage::Quit);
})
.ok()?;
Some(tray)
}

269
HotKeet/src/ui.rs Normal file
View File

@@ -0,0 +1,269 @@
//! egui Settings-Fenster
use crate::config::DictateConfig;
use crate::hotkey;
use crate::recording::list_input_sources;
use eframe::egui;
use std::sync::mpsc::Sender;
use std::sync::{Arc, RwLock};
pub struct SettingsApp {
pub config: DictateConfig,
pub status: String,
pub config_arc: Option<Arc<RwLock<DictateConfig>>>,
pub test_tx: Option<Sender<()>>,
/// Hotkey-Feld: true = warte auf Tastendruck
pub hotkey_capturing: bool,
}
impl SettingsApp {
pub fn new(config: DictateConfig) -> Self {
Self {
config,
status: String::new(),
config_arc: None,
test_tx: None,
hotkey_capturing: false,
}
}
pub fn with_config_sync(mut self, config_arc: Arc<RwLock<DictateConfig>>) -> Self {
self.config_arc = Some(config_arc);
self
}
pub fn with_test_sender(mut self, tx: Sender<()>) -> Self {
self.test_tx = Some(tx);
self
}
/// Ermittelt den anzuzeigenden Quell-Index (0=Companion, 1+=Mikrofon).
fn selected_source_index(&self, sources: &[(usize, String)]) -> usize {
if self.config.use_companion_microphone {
return 0;
}
if !self.config.input_device_name.is_empty() {
if let Some((idx, _)) = sources.iter().find(|(_, name)| *name == self.config.input_device_name) {
return *idx;
}
}
self.config.input_device_index.saturating_add(1)
}
fn set_source_from_index(&mut self, idx: usize, sources: &[(usize, String)]) {
if idx == 0 {
self.config.use_companion_microphone = true;
self.config.input_device_name.clear();
} else {
self.config.use_companion_microphone = false;
self.config.input_device_index = idx - 1;
if let Some((_, name)) = sources.iter().find(|(i, _)| *i == idx) {
self.config.input_device_name = name.clone();
}
}
}
pub fn ui(
&mut self,
ctx: &egui::Context,
status_str: &str,
status_detail: &str,
hotkey_info: &str,
companion_status: &Option<Result<(), String>>,
) {
egui::CentralPanel::default().show(ctx, |ui| {
ui.heading("HotKeet Einstellungen");
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.strong("Status:");
ui.label(status_str);
if !status_detail.is_empty() {
ui.label("");
ui.label(status_detail);
}
});
ui.label(format!("Letzter Hotkey: {}", hotkey_info));
if let Some(ref tx) = self.test_tx {
if ui.button("Test: Aufnahme starten (3 s)").clicked() {
let _ = tx.send(());
}
}
ui.add_space(8.0);
ui.horizontal(|ui| {
ui.label("Hotkey:");
let label = if self.hotkey_capturing {
"Taste drücken… (Esc = Abbrechen)"
} else {
&self.config.global_hotkey
};
let resp = ui.add(
egui::Button::new(label)
.min_size(egui::vec2(180.0, 0.0)),
);
if resp.clicked() {
self.hotkey_capturing = true;
}
if self.hotkey_capturing {
if ui.ctx().input(|i| i.key_pressed(egui::Key::Escape)) {
self.hotkey_capturing = false;
} else {
let mods = ui.ctx().input(|i| i.modifiers.clone());
for key in egui::Key::ALL {
if ui.ctx().input(|i| i.key_pressed(*key)) {
if let Some(s) =
hotkey::format_hotkey(mods.ctrl, mods.shift, mods.alt, key.name())
{
self.config.global_hotkey = s;
self.status = "Änderungen speichern nicht vergessen.".to_string();
self.hotkey_capturing = false;
}
// Nur bei gültiger Haupttaste abbrechen, sonst weiter (Modifier überspringen)
if !self.hotkey_capturing {
break;
}
}
}
}
}
});
ui.add_space(4.0);
ui.label("Eingabequelle:");
let sources = list_input_sources();
let mut selected = self.selected_source_index(&sources);
let selected = &mut selected;
let selected_text = sources
.iter()
.find(|(i, _)| *i == self.selected_source_index(&sources))
.map(|(_, n)| n.as_str())
.unwrap_or("?");
egui::ComboBox::from_id_salt("input_source")
.selected_text(selected_text)
.show_ui(ui, |ui| {
for (idx, name) in &sources {
ui.selectable_value(selected, *idx, name);
}
});
self.set_source_from_index(*selected, &sources);
if self.config.use_companion_microphone {
ui.horizontal(|ui| {
ui.label("Companion Host:");
ui.text_edit_singleline(&mut self.config.companion_host);
ui.label("Port:");
ui.add(
egui::DragValue::new(&mut self.config.companion_port)
.range(1..=65535)
.speed(1),
);
});
match companion_status {
None => ui.label("Prüfe Verbindung…"),
Some(Ok(())) => ui.label("Verbunden"),
Some(Err(e)) => ui.colored_label(
egui::Color32::RED,
format!("Nicht verbunden: {}", e),
),
};
}
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label("parakeet-cli Pfad:");
let display = if self.config.parakeet_cli_path.is_empty() {
"(leer = im PATH)".to_string()
} else {
self.config.parakeet_cli_path.clone()
};
ui.label(egui::RichText::new(&display).color(egui::Color32::GRAY));
if ui.button("Durchsuchen…").clicked() {
let mut dialog = rfd::FileDialog::new().set_title("parakeet-cli auswählen");
#[cfg(windows)]
{
dialog = dialog.add_filter("Executable", &["exe"]);
}
dialog = dialog.add_filter("Alle Dateien", &["*"]);
if let Some(p) = dialog.pick_file() {
self.config.parakeet_cli_path = p.display().to_string();
self.status = "Änderungen speichern nicht vergessen.".to_string();
}
}
if !self.config.parakeet_cli_path.is_empty() && ui.small_button("").clicked() {
self.config.parakeet_cli_path.clear();
self.status = "Änderungen speichern nicht vergessen.".to_string();
}
});
ui.horizontal(|ui| {
ui.label("Modellpfad:");
let display = if self.config.model_path.is_empty() {
"(leer = Standardpfad)".to_string()
} else {
self.config.model_path.clone()
};
ui.label(egui::RichText::new(&display).color(egui::Color32::GRAY));
if ui.button("Durchsuchen…").clicked() {
if let Some(p) = rfd::FileDialog::new()
.set_title("Modellordner auswählen")
.pick_folder()
{
self.config.model_path = p.display().to_string();
self.status = "Änderungen speichern nicht vergessen.".to_string();
}
}
if !self.config.model_path.is_empty() && ui.small_button("").clicked() {
self.config.model_path.clear();
self.status = "Änderungen speichern nicht vergessen.".to_string();
}
});
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label("Einfügemethode:");
let mut method = self.config.paste_method.clone();
egui::ComboBox::from_id_salt("paste_method")
.selected_text(&method)
.show_ui(ui, |ui| {
ui.selectable_value(&mut method, "Auto".to_string(), "Auto (Tastaturpuffer, Fallback Clipboard)");
ui.selectable_value(&mut method, "Keyboard".to_string(), "Nur Tastaturpuffer");
ui.selectable_value(&mut method, "Clipboard".to_string(), "Nur Zwischenablage");
});
self.config.paste_method = method;
});
ui.add_space(4.0);
ui.checkbox(&mut self.config.start_minimized, "Beim Start minimieren");
ui.checkbox(&mut self.config.minimize_to_tray, "In Tray minimieren");
ui.checkbox(&mut self.config.sound_on_start_end, "Signaltöne bei Start und Ende des Diktats");
ui.checkbox(&mut self.config.debug_logging, "Debug-Logging (paste-debug.log, Konsole)");
ui.add_space(16.0);
if ui.button("Speichern").clicked() {
match self.config.save() {
Ok(()) => {
if let Some(ref arc) = self.config_arc {
let _ = arc.write().map(|mut w| *w = self.config.clone());
}
self.status = "Gespeichert.".to_string();
}
Err(e) => self.status = format!("Fehler: {}", e),
}
}
if !self.status.is_empty() {
ui.add_space(8.0);
ui.label(&self.status);
}
});
}
}

74
HotKeet/src/win_close.rs Normal file
View File

@@ -0,0 +1,74 @@
//! Windows: WM_CLOSE abfangen und Fenster verstecken statt beenden (Minimieren ins Tray).
#[cfg(windows)]
pub fn subclass_for_minimize_to_tray(
raw_handle: Result<raw_window_handle::RawWindowHandle, raw_window_handle::HandleError>,
minimize_to_tray: bool,
) {
if !minimize_to_tray {
return;
}
let Ok(handle) = raw_handle else {
return;
};
use winapi::shared::windef::HWND;
let hwnd: HWND = match handle {
raw_window_handle::RawWindowHandle::Win32(win) => win.hwnd.get() as HWND,
_ => return,
};
if hwnd.is_null() {
return;
}
use winapi::um::winuser::{GetWindowLongPtrW, SetWindowLongPtrW, GWLP_WNDPROC};
type WndProc = winapi::um::winuser::WNDPROC;
unsafe {
let old_proc = GetWindowLongPtrW(hwnd, GWLP_WNDPROC);
if old_proc == 0 {
return;
}
let new_proc: WndProc = Some(subclass_wnd_proc);
let new_proc_addr: isize = std::mem::transmute(new_proc);
SetWindowLongPtrW(hwnd, GWLP_WNDPROC, new_proc_addr);
ORIGINAL_PROC.store(old_proc as usize, std::sync::atomic::Ordering::SeqCst);
}
}
#[cfg(windows)]
static ORIGINAL_PROC: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
#[cfg(windows)]
unsafe extern "system" fn subclass_wnd_proc(
hwnd: winapi::shared::windef::HWND,
msg: winapi::shared::minwindef::UINT,
wparam: winapi::shared::minwindef::WPARAM,
lparam: winapi::shared::minwindef::LPARAM,
) -> winapi::shared::minwindef::LRESULT {
use winapi::um::winuser::{CallWindowProcW, SW_HIDE, WM_CLOSE};
if msg == WM_CLOSE {
let _ = winapi::um::winuser::ShowWindow(hwnd, SW_HIDE);
return 0;
}
let old_proc = ORIGINAL_PROC.load(std::sync::atomic::Ordering::SeqCst);
if old_proc == 0 {
return 0;
}
let old_proc: winapi::um::winuser::WNDPROC = std::mem::transmute(old_proc);
CallWindowProcW(old_proc, hwnd, msg, wparam, lparam)
}
#[cfg(not(windows))]
pub fn subclass_for_minimize_to_tray(
_raw_handle: Result<
raw_window_handle::RawWindowHandle,
raw_window_handle::HandleError,
>,
_minimize_to_tray: bool,
) {
}

View File

@@ -0,0 +1,6 @@
# Linker-Pfad für msvcrt.lib (Visual Studio 2022)
[target.x86_64-pc-windows-msvc]
rustflags = [
"-C",
"link-arg=/LIBPATH:C:\\Program Files\\Microsoft Visual Studio\\2022\\Community\\SDK\\ScopeCppSDK\\vc15\\VC\\lib",
]

1280
parakeet-cli/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

9
parakeet-cli/Cargo.toml Normal file
View File

@@ -0,0 +1,9 @@
[package]
name = "parakeet-cli"
version = "0.1.0"
edition = "2021"
description = "CLI for Parakeet v3 speech-to-text via transcribe-rs"
[dependencies]
transcribe-rs = { version = "0.2.3", features = ["parakeet"] }
serde_json = "1.0"

18
parakeet-cli/README.md Normal file
View File

@@ -0,0 +1,18 @@
# parakeet-cli
CLI für Parakeet v3 Speech-to-Text (transcribe-rs). Wird von mediSchnack als natives Offline-Modell genutzt.
## Build
```bash
cd mediSchnack/parakeet-cli
cargo build --release
```
**Hinweis:** Bei Rust < 1.88 muss `ort` auf eine kompatible Version gepinnt werden:
```bash
cargo update -p ort -p ort-sys --precise 2.0.0-rc.10
```
Die `parakeet-cli.exe` wird beim mediSchnack-Build automatisch nach `bin/` kopiert, sofern sie unter `parakeet-cli/target/release/` existiert.

9
parakeet-cli/build.cmd Normal file
View File

@@ -0,0 +1,9 @@
@echo off
REM Build-Skript: Setzt LIB-Pfad für msvcrt.lib (Visual Studio 2022)
set "LIB_PATH=C:\Program Files\Microsoft Visual Studio\2022\Community\SDK\ScopeCppSDK\vc15\VC\lib"
if defined LIB (
set "LIB=%LIB_PATH%;%LIB%"
) else (
set "LIB=%LIB_PATH%"
)
cargo build %*

8
parakeet-cli/build.ps1 Normal file
View File

@@ -0,0 +1,8 @@
# Build-Skript: Setzt LIB-Pfad für msvcrt.lib (Visual Studio 2022)
$libPath = "C:\Program Files\Microsoft Visual Studio\2022\Community\SDK\ScopeCppSDK\vc15\VC\lib"
if ($env:LIB) {
$env:LIB = "$libPath;$env:LIB"
} else {
$env:LIB = $libPath
}
cargo build @args

43
parakeet-cli/src/main.rs Normal file
View File

@@ -0,0 +1,43 @@
//! Parakeet CLI - Transcribes WAV audio using Parakeet v3 (transcribe-rs).
//! Usage: parakeet-cli <model_dir> <audio.wav>
//! Output: JSON {"text": "..."} on stdout, errors on stderr.
use std::path::PathBuf;
use transcribe_rs::{engines::parakeet::{ParakeetEngine, ParakeetModelParams}, TranscriptionEngine};
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() < 3 {
eprintln!("Usage: parakeet-cli <model_dir> <audio.wav>");
std::process::exit(1);
}
let model_path = PathBuf::from(&args[1]);
let audio_path = PathBuf::from(&args[2]);
if !model_path.is_dir() {
eprintln!("Model directory does not exist: {:?}", model_path);
std::process::exit(1);
}
if !audio_path.is_file() {
eprintln!("Audio file does not exist: {:?}", audio_path);
std::process::exit(1);
}
let mut engine = ParakeetEngine::new();
if let Err(e) = engine.load_model_with_params(&model_path, ParakeetModelParams::int8()) {
eprintln!("Failed to load model: {}", e);
std::process::exit(1);
}
let result = match engine.transcribe_file(&audio_path, None) {
Ok(r) => r,
Err(e) => {
eprintln!("Transcription failed: {}", e);
std::process::exit(1);
}
};
let json = serde_json::json!({ "text": result.text });
println!("{}", json);
}