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:
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal 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
|
||||
6
HotKeet/.cargo/config.toml
Normal file
6
HotKeet/.cargo/config.toml
Normal 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
4702
HotKeet/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
HotKeet/Cargo.toml
Normal file
36
HotKeet/Cargo.toml
Normal 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
75
HotKeet/PLATFORM.md
Normal 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.
|
||||
BIN
HotKeet/assets/dictate-icon.ico
Normal file
BIN
HotKeet/assets/dictate-icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
BIN
HotKeet/assets/dictate-icon.png
Normal file
BIN
HotKeet/assets/dictate-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
21
HotKeet/build.cmd
Normal file
21
HotKeet/build.cmd
Normal 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
9
HotKeet/build.ps1
Normal 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
41
HotKeet/build.rs
Normal 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
79
HotKeet/src/companion.rs
Normal 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
185
HotKeet/src/config.rs
Normal 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
292
HotKeet/src/hotkey.rs
Normal 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
546
HotKeet/src/main.rs
Normal 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
162
HotKeet/src/paste.rs
Normal 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
242
HotKeet/src/recording.rs
Normal 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
102
HotKeet/src/sound.rs
Normal 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();
|
||||
});
|
||||
}
|
||||
67
HotKeet/src/transcription.rs
Normal file
67
HotKeet/src/transcription.rs
Normal 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
46
HotKeet/src/tray.rs
Normal 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
269
HotKeet/src/ui.rs
Normal 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
74
HotKeet/src/win_close.rs
Normal 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,
|
||||
) {
|
||||
}
|
||||
6
parakeet-cli/.cargo/config.toml
Normal file
6
parakeet-cli/.cargo/config.toml
Normal 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
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
9
parakeet-cli/Cargo.toml
Normal 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
18
parakeet-cli/README.md
Normal 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
9
parakeet-cli/build.cmd
Normal 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
8
parakeet-cli/build.ps1
Normal 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
43
parakeet-cli/src/main.rs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user