working? ability to package/run on macos

This commit is contained in:
CJ Pais 2025-05-05 12:13:27 -07:00
parent 8cd40c391d
commit 6b545d3acd
18 changed files with 1142 additions and 146 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -16,6 +16,8 @@
"@tauri-apps/plugin-clipboard-manager": "~2", "@tauri-apps/plugin-clipboard-manager": "~2",
"@tauri-apps/plugin-global-shortcut": "~2", "@tauri-apps/plugin-global-shortcut": "~2",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-stronghold": "~2",
"@tauri-apps/plugin-upload": "~2",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"tailwindcss": "^4.0.2", "tailwindcss": "^4.0.2",

967
src-tauri/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -35,6 +35,8 @@ env_logger = "0.11.6"
log = "0.4.25" log = "0.4.25"
tokio = "1.43.0" tokio = "1.43.0"
vad-rs = "0.1.5" vad-rs = "0.1.5"
tauri-plugin-upload = "2"
tauri-plugin-stronghold = "2"
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
whisper-rs = { version = "0.13.2", features = ["metal"] } whisper-rs = { version = "0.13.2", features = ["metal"] }

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.device.microphone</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>

8
src-tauri/Info.plist Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSMicrophoneUsageDescription</key>
<string>Request microphone access to transcribe audio locally</string>
</dict>
</plist>

View file

@ -1,15 +1,6 @@
{ {
"identifier": "desktop-capability", "identifier": "desktop-capability",
"platforms": [ "platforms": ["macOS", "windows", "linux"],
"macOS", "windows": ["main"],
"windows", "permissions": ["autostart:default", "global-shortcut:default"]
"linux"
],
"windows": [
"main"
],
"permissions": [
"autostart:default",
"global-shortcut:default"
]
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 974 B

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View file

@ -8,6 +8,7 @@ use rdev::{simulate, EventType, Key, SimulateError};
use rig::{completion::Prompt, providers::anthropic}; use rig::{completion::Prompt, providers::anthropic};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::{thread, time}; use std::{thread, time};
use tauri::Manager;
use tauri_plugin_autostart::MacosLauncher; use tauri_plugin_autostart::MacosLauncher;
use tauri_plugin_clipboard_manager::ClipboardExt; use tauri_plugin_clipboard_manager::ClipboardExt;
@ -19,7 +20,7 @@ fn try_send_event(event: &EventType) {
fn send(event: EventType) { fn send(event: EventType) {
try_send_event(&event); try_send_event(&event);
thread::sleep(time::Duration::from_millis(40)); thread::sleep(time::Duration::from_millis(60));
} }
fn send_paste() { fn send_paste() {
@ -137,105 +138,100 @@ fn register_bindings(manager: &mut KeyBindingManager) {
); );
// Register LLM Call after Transcription // Register LLM Call after Transcription
manager.register( // manager.register(
"shift-alt".to_string(), // "shift-alt".to_string(),
vec![Key::ShiftLeft, Key::Alt], // vec![Key::ShiftLeft, Key::Alt],
|ctx| { // |ctx| {
info!("Shift+Alt pressed!"); // info!("Shift+Alt pressed!");
ctx.recording_manager.try_start_recording("shift-alt"); // ctx.recording_manager.try_start_recording("shift-alt");
None // None
}, // },
|ctx| { // |ctx| {
info!("release being called from shift-alt"); // info!("release being called from shift-alt");
let ctx = ctx.clone(); // let ctx = ctx.clone();
Some(tauri::async_runtime::spawn(async move { // Some(tauri::async_runtime::spawn(async move {
if let Some(samples) = ctx.recording_manager.stop_recording("shift-alt") { // if let Some(samples) = ctx.recording_manager.stop_recording("shift-alt") {
if let Ok(transcription) = ctx.transcription_manager.transcribe(samples) { // if let Ok(transcription) = ctx.transcription_manager.transcribe(samples) {
println!("Transcription: {}", transcription); // println!("Transcription: {}", transcription);
let instruct = ctx // let instruct = ctx
.anthropic // .anthropic
.agent(anthropic::CLAUDE_3_5_SONNET) // .agent(anthropic::CLAUDE_3_5_SONNET)
.preamble(INSTRUCT_SYS) // .preamble(INSTRUCT_SYS)
.temperature(0.5) // .temperature(0.5)
.build(); // .build();
let highlighted_text = get_highlighted_text(ctx.app_handle.clone()); // let highlighted_text = get_highlighted_text(ctx.app_handle.clone());
println!("Highlighted Text: {}", highlighted_text); // println!("Highlighted Text: {}", highlighted_text);
let prompt = format!("{}\n\ncontext:{}\n", transcription, highlighted_text); // let prompt = format!("{}\n\ncontext:{}\n", transcription, highlighted_text);
match instruct.prompt(prompt).await { // match instruct.prompt(prompt).await {
Ok(response) => { // Ok(response) => {
println!("Sonnet response: {}", response); // println!("Sonnet response: {}", response);
paste(response, ctx.app_handle.clone()); // paste(response, ctx.app_handle.clone());
} // }
Err(err) => println!("Sonnet error: {}", err), // Err(err) => println!("Sonnet error: {}", err),
} // }
} // }
} // }
})) // }))
}, // },
); // );
manager.register( // manager.register(
"ctrl-alt-meta".to_string(), // "ctrl-alt-meta".to_string(),
vec![Key::ControlLeft, Key::Alt, Key::MetaLeft], // vec![Key::ControlLeft, Key::Alt, Key::MetaLeft],
|ctx| { // |ctx| {
info!("Ctrl+Alt+Meta pressed!"); // info!("Ctrl+Alt+Meta pressed!");
ctx.recording_manager.try_start_recording("ctrl-alt-meta"); // ctx.recording_manager.try_start_recording("ctrl-alt-meta");
None // None
}, // },
|ctx| { // |ctx| {
info!("release being called from ctrl-alt-meta"); // info!("release being called from ctrl-alt-meta");
let ctx = ctx.clone(); // let ctx = ctx.clone();
Some(tauri::async_runtime::spawn(async move { // Some(tauri::async_runtime::spawn(async move {
if let Some(samples) = ctx.recording_manager.stop_recording("ctrl-alt-meta") { // if let Some(samples) = ctx.recording_manager.stop_recording("ctrl-alt-meta") {
let samples: Vec<f32> = samples; // explicit type annotation // let samples: Vec<f32> = samples; // explicit type annotation
match ctx.transcription_manager.transcribe(samples) { // match ctx.transcription_manager.transcribe(samples) {
Ok(transcription) => { // Ok(transcription) => {
println!("Transcription: {}", transcription); // println!("Transcription: {}", transcription);
let code = ctx // let code = ctx
.anthropic // .anthropic
.agent(anthropic::CLAUDE_3_5_SONNET) // .agent(anthropic::CLAUDE_3_5_SONNET)
.preamble(CODE_SYS) // .preamble(CODE_SYS)
.temperature(0.5) // .temperature(0.5)
.build(); // .build();
let highlighted_text = get_highlighted_text(ctx.app_handle.clone()); // let highlighted_text = get_highlighted_text(ctx.app_handle.clone());
let prompt = // let prompt =
format!("{}\n\ncontext:{}\n", transcription, highlighted_text); // format!("{}\n\ncontext:{}\n", transcription, highlighted_text);
match code.prompt(prompt).await { // match code.prompt(prompt).await {
Ok(response) => { // Ok(response) => {
println!("Sonnet response: {}", response); // println!("Sonnet response: {}", response);
paste(response, ctx.app_handle.clone()); // paste(response, ctx.app_handle.clone());
} // }
Err(err) => println!("Sonnet error: {}", err), // Err(err) => println!("Sonnet error: {}", err),
} // }
} // }
Err(err) => println!("Transcription error: {}", err), // Err(err) => println!("Transcription error: {}", err),
} // }
} else { // } else {
println!("No samples recorded"); // println!("No samples recorded");
} // }
})) // }))
}, // },
); // );
} }
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
env_logger::init(); env_logger::init();
let recording_manager =
Arc::new(AudioRecordingManager::new().expect("Failed to initialize recording manager"));
let transcription_manager =
Arc::new(TranscriptionManager::new().expect("Failed to initialize transcription manager"));
// let transcription_manager = Arc::new(TranscriptionManager::new());
let claude_client = Arc::new(anthropic::Client::from_env());
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_stronghold::Builder::new(|pass| todo!()).build())
.plugin(tauri_plugin_upload::init())
.plugin(tauri_plugin_global_shortcut::Builder::new().build()) .plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_clipboard_manager::init())
.plugin(tauri_plugin_autostart::init( .plugin(tauri_plugin_autostart::init(
@ -247,10 +243,33 @@ pub fn run() {
.setup(move |app| { .setup(move |app| {
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
let vad_path = app.path().resolve(
"resources/silero_vad_v4.onnx",
tauri::path::BaseDirectory::Resource,
)?;
let whisper_path = app.path().resolve(
"resources/ggml-small.bin",
tauri::path::BaseDirectory::Resource,
)?;
let recording_manager = Arc::new(
AudioRecordingManager::new(&vad_path)
.expect("Failed to initialize recording manager"),
);
let transcription_manager = Arc::new(
TranscriptionManager::new(
whisper_path
.to_str()
.expect("Path contains invalid UTF-8 Chars"),
)
.expect("Failed to initialize transcription manager"),
);
let manager = Arc::new(Mutex::new(KeyBindingManager::new( let manager = Arc::new(Mutex::new(KeyBindingManager::new(
recording_manager.clone(), recording_manager.clone(),
transcription_manager.clone(), transcription_manager.clone(),
claude_client.clone(), // claude_client.clone(),
app_handle.clone(), app_handle.clone(),
))); )));

View file

@ -1,5 +1,6 @@
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use rubato::{FftFixedIn, Resampler}; use rubato::{FftFixedIn, Resampler};
use std::path::PathBuf;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::vec::Vec; use std::vec::Vec;
use vad_rs::{Vad, VadStatus}; use vad_rs::{Vad, VadStatus};
@ -16,7 +17,7 @@ pub struct AudioRecordingManager {
} }
impl AudioRecordingManager { impl AudioRecordingManager {
pub fn new() -> Result<Self, anyhow::Error> { pub fn new(vad_path: &PathBuf) -> Result<Self, anyhow::Error> {
let host = cpal::default_host(); let host = cpal::default_host();
let device = host let device = host
.default_input_device() .default_input_device()
@ -28,9 +29,7 @@ impl AudioRecordingManager {
// Configure the resampler - keeping 1024 as input size for FFT efficiency // Configure the resampler - keeping 1024 as input size for FFT efficiency
let resampler = FftFixedIn::new(sample_rate as usize, 16000, 1024, 2, 1)?; let resampler = FftFixedIn::new(sample_rate as usize, 16000, 1024, 2, 1)?;
let vad = Arc::new(Mutex::new( let vad = Arc::new(Mutex::new(Vad::new(vad_path, 16000).unwrap()));
Vad::new("resources/silero_vad_v4.onnx", 16000).unwrap(),
));
let vad_clone = Arc::clone(&vad); let vad_clone = Arc::clone(&vad);
let state = Arc::new(Mutex::new(RecordingState::Idle)); let state = Arc::new(Mutex::new(RecordingState::Idle));
@ -82,15 +81,13 @@ impl AudioRecordingManager {
if let Ok(mut vad) = vad_clone.lock() { if let Ok(mut vad) = vad_clone.lock() {
// println!("VAD lock acquired"); // println!("VAD lock acquired");
match vad.compute(&chunk) { match vad.compute(&chunk) {
Ok(mut result) => match result.status() { Ok(mut result) => {
VadStatus::Speech => { if result.prob > 0.15 {
let mut buffer = let mut buffer =
buffer_clone.lock().unwrap(); buffer_clone.lock().unwrap();
buffer.extend_from_slice(&chunk); buffer.extend_from_slice(&chunk);
} }
VadStatus::Silence => {} }
_ => {}
},
Err(error) => { Err(error) => {
eprintln!("Error computing VAD: {:?}", error) eprintln!("Error computing VAD: {:?}", error)
} }

View file

@ -11,7 +11,7 @@ type KeySet = HashSet<Key>;
pub struct BindingContext { pub struct BindingContext {
pub recording_manager: Arc<AudioRecordingManager>, pub recording_manager: Arc<AudioRecordingManager>,
pub transcription_manager: Arc<TranscriptionManager>, pub transcription_manager: Arc<TranscriptionManager>,
pub anthropic: Arc<anthropic::Client>, // pub anthropic: Arc<anthropic::Client>,
pub app_handle: tauri::AppHandle, pub app_handle: tauri::AppHandle,
} }
@ -52,7 +52,7 @@ impl KeyBindingManager {
pub fn new( pub fn new(
recording_manager: Arc<AudioRecordingManager>, recording_manager: Arc<AudioRecordingManager>,
transcription_manager: Arc<TranscriptionManager>, transcription_manager: Arc<TranscriptionManager>,
anthropic: Arc<anthropic::Client>, // anthropic: Arc<anthropic::Client>,
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
) -> Self { ) -> Self {
Self { Self {
@ -62,7 +62,7 @@ impl KeyBindingManager {
context: BindingContext { context: BindingContext {
recording_manager, recording_manager,
transcription_manager, transcription_manager,
anthropic, // anthropic,
app_handle, app_handle,
}, },
} }

View file

@ -11,14 +11,12 @@ pub struct TranscriptionManager {
} }
impl TranscriptionManager { impl TranscriptionManager {
pub fn new() -> Result<Self> { pub fn new(whisper_path: &str) -> Result<Self> {
install_whisper_log_trampoline(); install_whisper_log_trampoline();
// Load the model // Load the model
let context = WhisperContext::new_with_params( let context =
"resources/ggml-small.bin", WhisperContext::new_with_params(whisper_path, WhisperContextParameters::default())
WhisperContextParameters::default(), .map_err(|e| anyhow::anyhow!("Failed to load whisper model: {}", e))?;
)
.map_err(|e| anyhow::anyhow!("Failed to load whisper model: {}", e))?;
// Create state // Create state
let state = context.create_state().expect("failed to create state"); let state = context.create_state().expect("failed to create state");

View file

@ -1,6 +1,6 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "handy-app", "productName": "Handy",
"version": "0.1.0", "version": "0.1.0",
"identifier": "com.handy-app.app", "identifier": "com.handy-app.app",
"build": { "build": {
@ -12,7 +12,7 @@
"app": { "app": {
"windows": [ "windows": [
{ {
"title": "handy-app", "title": "Handy",
"width": 800, "width": 800,
"height": 600 "height": 600
} }
@ -24,12 +24,20 @@
"bundle": { "bundle": {
"active": true, "active": true,
"targets": "all", "targets": "all",
"resources": ["resources/*"],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",
"icons/128x128@2x.png", "icons/128x128@2x.png",
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico"
] ],
"macOS": {
"files": {},
"hardenedRuntime": true,
"minimumSystemVersion": "10.13",
"signingIdentity": "-",
"entitlements": "Entitlements.plist"
}
} }
} }

View file

@ -1,4 +1,4 @@
import React from 'react'; import React from "react";
interface Shortcut { interface Shortcut {
id: string; id: string;
@ -11,23 +11,23 @@ export const KeyboardShortcuts: React.FC = () => {
// These would normally come from the backend configuration // These would normally come from the backend configuration
const shortcuts: Shortcut[] = [ const shortcuts: Shortcut[] = [
{ {
id: 'transcribe', id: "transcribe",
name: 'Transcribe', name: "Transcribe",
description: 'Convert speech to text', description: "Convert speech to text",
keys: ['⌃', '⌘'], keys: ["⌃", "⌘"],
},
{
id: 'instruct',
name: 'AI Chat',
description: 'Start a conversation with AI',
keys: ['⇧', '⌥'],
},
{
id: 'code',
name: 'Generate Code',
description: 'Generate code from voice input',
keys: ['⌃', '⌥', '⌘'],
}, },
// {
// id: 'instruct',
// name: 'AI Chat',
// description: 'Start a conversation with AI',
// keys: ['⇧', '⌥'],
// },
// {
// id: 'code',
// name: 'Generate Code',
// description: 'Generate code from voice input',
// keys: ['⌃', '⌥', '⌘'],
// },
]; ];
return ( return (
@ -38,7 +38,9 @@ export const KeyboardShortcuts: React.FC = () => {
className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:bg-gray-50" className="flex items-center justify-between p-4 rounded-lg border border-gray-200 hover:bg-gray-50"
> >
<div> <div>
<h3 className="text-sm font-medium text-gray-900">{shortcut.name}</h3> <h3 className="text-sm font-medium text-gray-900">
{shortcut.name}
</h3>
<p className="text-sm text-gray-500">{shortcut.description}</p> <p className="text-sm text-gray-500">{shortcut.description}</p>
</div> </div>
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">