diff --git a/Cargo.lock b/Cargo.lock index c156012..9f84e61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -984,6 +984,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "clipboard-win" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15efe7a882b08f34e38556b14f2fb3daa98769d06c7f0c1b076dfd0d983bc892" +dependencies = [ + "error-code", +] + [[package]] name = "clru" version = "0.6.1" @@ -1774,6 +1783,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "euclid" version = "0.22.9" @@ -4113,6 +4128,7 @@ dependencies = [ "chrono", "clap", "clipboard", + "clipboard-win 5.4.0", "colored", "cpal", "csv", diff --git a/Cargo.toml b/Cargo.toml index 7fb5897..be8b74b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ sysinfo = "0.32.1" csv = "1.3.1" humantime = "2.1.0" clipboard = "0.5.0" +clipboard-win = "5.4.0" reqwest = { version = "0.12.1", features = ["json"] } serde = { version = "1.0", features = ["derive"] } once_cell = "1.19.0" diff --git a/src/convert.rs b/src/convert.rs new file mode 100644 index 0000000..5445fc5 --- /dev/null +++ b/src/convert.rs @@ -0,0 +1,101 @@ +use anyhow::{bail, Context, Result}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use uuid::Uuid; +#[cfg(target_os = "windows")] +use clipboard_win::{formats::FileList, Clipboard, Getter, Setter}; + +/// Convert `input` to `output_ext` using ffmpeg and return the path +/// to the converted file. +/// +/// The resulting file is created in the system temp directory with a +/// random name so the original file is never overwritten. +pub fn convert_with_ffmpeg(input: &Path, output_ext: &str) -> Result { + let out_path = std::env::temp_dir().join(format!("converted-{}.{output_ext}", Uuid::new_v4())); + + let status = Command::new("ffmpeg") + .args([ + "-y", + "-i", + input + .to_str() + .context("Failed to convert input path to string")?, + out_path + .to_str() + .context("Failed to convert output path to string")?, + ]) + .status() + .context("Failed to execute ffmpeg")?; + + if !status.success() { + bail!("ffmpeg failed to convert file"); + } + + Ok(out_path) +} + +/// Convert the file currently stored in the clipboard to `output_ext` +/// using ffmpeg and put the resulting file back on the clipboard. +/// +/// On non-Windows platforms this returns an error. +#[cfg(target_os = "windows")] +pub fn convert_clipboard_file(output_ext: &str) -> Result { + let _clip = Clipboard::new_attempts(10) + .map_err(|e| anyhow::anyhow!("Failed to open clipboard: {e:?}"))?; + + let mut files = Vec::::new(); + FileList + .read_clipboard(&mut files) + .map_err(|e| anyhow::anyhow!("Failed to read clipboard files: {e:?}"))?; + + let input = files + .get(0) + .cloned() + .context("Clipboard does not contain a file")?; + + let out = convert_with_ffmpeg(&input, output_ext)?; + + let out_str = out.to_string_lossy().to_string(); + FileList + .write_clipboard(&[out_str.as_str()]) + .map_err(|e| anyhow::anyhow!("Failed to set clipboard files: {e:?}"))?; + + Ok(out) +} + +#[cfg(not(target_os = "windows"))] +pub fn convert_clipboard_file(_output_ext: &str) -> Result { + bail!("convert_clipboard_file is only supported on Windows") +} + +#[cfg(test)] +mod tests { + use super::*; + use std::env; + use std::fs; + + #[test] + fn convert_with_ffmpeg_fails_if_ffmpeg_returns_error() { + let dir = tempfile::tempdir().unwrap(); + let ffmpeg_path = dir.path().join("ffmpeg"); + fs::write(&ffmpeg_path, "#!/bin/sh\nexit 1\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(&ffmpeg_path).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&ffmpeg_path, perms).unwrap(); + } + + let old_path = env::var("PATH").unwrap_or_default(); + env::set_var("PATH", format!("{}:{}", dir.path().display(), old_path)); + + let input = dir.path().join("in.wav"); + fs::write(&input, b"dummy").unwrap(); + + let res = convert_with_ffmpeg(&input, "mp3"); + assert!(res.is_err()); + + env::set_var("PATH", old_path); + } +} diff --git a/src/main.rs b/src/main.rs index 62d950b..76019c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,7 @@ use timers::*; use tracing_appender::non_blocking::WorkerGuard; use tracing_subscriber::filter::FilterFn; use tracing_subscriber::Registry; +mod convert; mod default_device_sink; mod timers; mod transcribe; @@ -567,6 +568,26 @@ fn call_fn( } } + "convert_clipboard_file" => { + let args = match serde_json::from_str::(fn_args) { + Ok(json) => json, + Err(e) => return Some(format!("Failed to parse arguments: {}", e)), + }; + + let output_ext = match args["output_ext"].as_str() { + Some(ext) => ext, + None => return Some("Missing 'output_ext' argument.".to_string()), + }; + + match convert::convert_clipboard_file(output_ext) { + Ok(out) => Some(format!( + "Converted file copied to clipboard as {}", + out.display() + )), + Err(e) => Some(format!("Failed to convert clipboard file: {}", e)), + } + } + "set_speech_speed" => { let args: serde_json::Value = serde_json::from_str(fn_args).unwrap(); if let Some(speed) = args["speed"].as_f64() { @@ -1530,6 +1551,16 @@ async fn main() -> Result<(), Box> { })) .build().unwrap(), + ChatCompletionFunctionsArgs::default() + .name("convert_clipboard_file") + .description("Converts the file currently stored in the clipboard to another format using ffmpeg and copies the new file back to the clipboard.") + .parameters(json!({ + "type": "object", + "properties": {"output_ext": {"type": "string"}}, + "required": ["output_ext"], + })) + .build().unwrap(), + ChatCompletionFunctionsArgs::default() .name("set_speech_speed") .description("Sets how fast the AI voice speaks. Speed must be between 0.5 and 100.0.")