Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,8 @@ type_delay_ms = 0
# [output.post_process]
# command = "ollama run llama3.2:1b 'Clean up this dictation. Fix grammar, remove filler words. Output only the cleaned text:'"
# timeout_ms = 30000 # 30 second timeout (generous for LLM)
# trim = true # Strip leading/trailing whitespace from output (default: true)
# fallback_on_empty = true # Use original text if command returns empty (default: true)

[output.notification]
# Show notification when recording starts (hotkey pressed)
Expand Down Expand Up @@ -1468,6 +1470,18 @@ pub struct PostProcessConfig {
/// Timeout in milliseconds (default: 30000 = 30 seconds)
#[serde(default = "default_post_process_timeout")]
pub timeout_ms: u64,

/// Whether to trim leading/trailing whitespace from command output (default: true)
/// Set to false when the command intentionally produces significant whitespace,
/// e.g. a trailing space after sentence-ending punctuation for dictation flow.
#[serde(default = "default_true")]
pub trim: bool,

/// Whether to fall back to original text when command output is empty (default: true)
/// Set to false when the command intentionally produces empty output,
/// e.g. filtering out unwanted transcriptions like [BLANK_AUDIO].
#[serde(default = "default_true")]
pub fallback_on_empty: bool,
}

/// Named profile for context-specific settings
Expand Down
2 changes: 2 additions & 0 deletions src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1284,6 +1284,8 @@ impl Daemon {
let profile_config = crate::config::PostProcessConfig {
command: cmd.clone(),
timeout_ms,
trim: true,
fallback_on_empty: true,
};
let profile_processor = PostProcessor::new(&profile_config);
tracing::info!(
Expand Down
69 changes: 67 additions & 2 deletions src/output/post_process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ use tokio::time::timeout;
pub struct PostProcessor {
command: String,
timeout: Duration,
trim: bool,
fallback_on_empty: bool,
}

impl PostProcessor {
Expand All @@ -33,6 +35,8 @@ impl PostProcessor {
Self {
command: config.command.clone(),
timeout: Duration::from_millis(config.timeout_ms),
trim: config.trim,
fallback_on_empty: config.fallback_on_empty,
}
}

Expand All @@ -43,11 +47,14 @@ impl PostProcessor {
pub async fn process(&self, text: &str) -> String {
match self.execute_command(text).await {
Ok(processed) => {
if processed.is_empty() {
if processed.is_empty() && self.fallback_on_empty {
tracing::warn!(
"Post-process command returned empty output, using original text"
);
text.to_string()
} else if processed.is_empty() {
tracing::debug!("Post-process command returned empty output");
String::new()
} else {
tracing::debug!(
"Post-processed ({} -> {} chars)",
Expand Down Expand Up @@ -103,7 +110,12 @@ impl PostProcessor {
let processed = String::from_utf8(output.stdout)
.map_err(|e| PostProcessError::InvalidUtf8(e.to_string()))?;

Ok(processed.trim().to_string())
if self.trim {
Ok(processed.trim().to_string())
} else {
// Only strip trailing newlines (artifact of shell output), preserve other whitespace
Ok(processed.trim_end_matches('\n').to_string())
}
}
}

Expand Down Expand Up @@ -153,6 +165,8 @@ mod tests {
PostProcessConfig {
command: command.to_string(),
timeout_ms,
trim: true,
fallback_on_empty: true,
}
}

Expand Down Expand Up @@ -246,4 +260,55 @@ mod tests {
let result = processor.process("test input").await;
assert_eq!(result, "prefix:\ntest input");
}

#[tokio::test]
async fn test_no_trim_preserves_trailing_space() {
// When trim = false, trailing spaces from the command should be preserved
let config = PostProcessConfig {
command: "printf '%s ' \"$( cat )\"".to_string(),
timeout_ms: 5000,
trim: false,
fallback_on_empty: true,
};
let processor = PostProcessor::new(&config);
let result = processor.process("hello world.").await;
assert_eq!(result, "hello world. ");
}

#[tokio::test]
async fn test_no_trim_still_strips_trailing_newlines() {
// Even with trim = false, trailing newlines (shell artifacts) are stripped
let config = PostProcessConfig {
command: "echo 'hello'".to_string(),
timeout_ms: 5000,
trim: false,
fallback_on_empty: true,
};
let processor = PostProcessor::new(&config);
let result = processor.process("ignored").await;
assert_eq!(result, "hello");
}

#[tokio::test]
async fn test_no_fallback_on_empty_returns_empty() {
// When fallback_on_empty = false, empty output is returned as-is
let config = PostProcessConfig {
command: "echo -n ''".to_string(),
timeout_ms: 5000,
trim: true,
fallback_on_empty: false,
};
let processor = PostProcessor::new(&config);
let result = processor.process("original text").await;
assert_eq!(result, "");
}

#[tokio::test]
async fn test_fallback_on_empty_default_returns_original() {
// Default behavior: empty output falls back to original text
let config = make_config("echo -n ''", 5000);
let processor = PostProcessor::new(&config);
let result = processor.process("original text").await;
assert_eq!(result, "original text");
}
}