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