Skip to content

Commit a15c881

Browse files
Add transcription hook
Add support for transcription hook - an executable script in app's data directory. If `transcription_hook` file exists, Handy runs it passing transcription text via stdin and uses script stdout as a transcription result. This approach is a flexible extension point for advanced users (which nowadays means with access to coding LLM) akin to git hooks. Here are some possible scenarios: * simple transcription modifications * a pipeline involving LLM processing, language detection and translation * custom paste method (as Handy does nothing if transcription is empty) * conditional processing based on the active application waiting for the input See related: * #168 * #162 * #916 * #911 * #834 * #847 * #833 * #662 * #601 * #335 * #739 * #638 * #455 * #157
1 parent a6b5c32 commit a15c881

File tree

2 files changed

+105
-2
lines changed

2 files changed

+105
-2
lines changed

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,40 @@ handy --start-hidden --no-tray
101101
> /Applications/Handy.app/Contents/MacOS/Handy --toggle-transcription
102102
> ```
103103
104+
105+
### Transcription hook
106+
107+
It is possble to modify transcription text by a script before Handy pastes it.
108+
To do that create an executable script `transcription_hook` in the app's data folder.
109+
Handy will pass transcription text to it via standard input and paste its standard output.
110+
111+
Example script to transform first letter to lowercase and remove trailing period:
112+
```bash
113+
# macOS/Linux
114+
cat > ~/Library/Application\ Support/com.pais.handy/transcription_hook << 'EOF'
115+
#!/usr/bin/env python3
116+
import sys
117+
text = sys.stdin.read().strip()
118+
text = text[:-1] if text.endswith('.') else text
119+
text = text[0].lower() + text[1:] if text else text
120+
sys.stdout.write(text)
121+
EOF
122+
123+
chmod +x ~/Library/Application\ Support/com.pais.handy/transcription_hook
124+
```
125+
126+
Example using Apple Script to prefix transcription with `#` if active window is Terminal.app:
127+
```bash
128+
#!/bin/bash
129+
active_app=$(osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true')
130+
131+
if [ "$active_app" = "Terminal" ]; then
132+
echo -n "# $(cat)"
133+
else
134+
cat
135+
fi
136+
```
137+
104138
## Known Issues & Current Limitations
105139
106140
This project is actively being developed and has some [known issues](https://github.com/cjpais/Handy/issues). We believe in transparency about the current state:

src-tauri/src/managers/transcription.rs

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,14 @@ use crate::settings::{get_settings, ModelUnloadTimeout};
44
use anyhow::Result;
55
use log::{debug, error, info, warn};
66
use serde::Serialize;
7+
use std::io::Write;
78
use std::panic::{catch_unwind, AssertUnwindSafe};
9+
use std::process::{Command, Stdio};
810
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
911
use std::sync::{Arc, Condvar, Mutex, MutexGuard};
1012
use std::thread;
1113
use std::time::{Duration, SystemTime};
12-
use tauri::{AppHandle, Emitter};
14+
use tauri::{AppHandle, Emitter, Manager};
1315
use transcribe_rs::{
1416
engines::{
1517
gigaam::GigaAMEngine,
@@ -628,7 +630,7 @@ impl TranscriptionManager {
628630
translation_note
629631
);
630632

631-
let final_result = filtered_result;
633+
let final_result = run_transcription_hook(&self.app_handle, filtered_result);
632634

633635
if final_result.is_empty() {
634636
info!("Transcription result is empty");
@@ -659,3 +661,70 @@ impl Drop for TranscriptionManager {
659661
}
660662
}
661663
}
664+
665+
/// Runs a transcription hook script if it exists.
666+
///
667+
/// This function looks for an executable script named `transcription_hook`
668+
/// in the app's data directory, runs it passing text via stdin and
669+
/// return script stdout as a result.
670+
fn run_transcription_hook(app: &AppHandle, text: String) -> String {
671+
let app_data_dir = match app.path().app_data_dir() {
672+
Ok(dir) => dir,
673+
Err(e) => {
674+
error!("Failed to get app data directory: {}", e);
675+
return text;
676+
}
677+
};
678+
679+
let transcription_hook = app_data_dir.join("transcription_hook");
680+
681+
if !transcription_hook.exists() {
682+
debug!("Transcription hook is not configured");
683+
return text;
684+
}
685+
686+
let mut child = match Command::new(&transcription_hook)
687+
.stdin(Stdio::piped())
688+
.stdout(Stdio::piped())
689+
.stderr(Stdio::piped())
690+
.spawn()
691+
{
692+
Ok(child) => child,
693+
Err(e) => {
694+
error!("Failed to spawn transcription hook: {}", e);
695+
return text;
696+
}
697+
};
698+
699+
if let Some(mut stdin) = child.stdin.take() {
700+
if let Err(e) = stdin.write_all(text.as_bytes()) {
701+
error!("Failed to write to transcription hook stdin: {}", e);
702+
return text;
703+
}
704+
// stdin is dropped here closing the pipe
705+
}
706+
707+
let output = match child.wait_with_output() {
708+
Ok(output) => output,
709+
Err(e) => {
710+
error!("Failed to wait for transcription hook: {}", e);
711+
return text;
712+
}
713+
};
714+
715+
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
716+
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
717+
718+
if !output.status.success() {
719+
error!(
720+
"Transcription hook exited with code {:?}, stderr: {:?}",
721+
output.status.code(),
722+
stderr
723+
);
724+
return text;
725+
}
726+
727+
debug!("Transcription hook completed, stderr: {:?}", stderr);
728+
729+
stdout
730+
}

0 commit comments

Comments
 (0)