Release 1.1.0: Add Import Audio Files feature
- New Import tab with drag-and-drop support for audio files - Support for 8 formats: MP3, MP4, WAV, M4A, FLAC, OGG, AAC, WMA - File metadata display (duration, size, format) - Editable meeting titles - Progress tracking with visual indicators - Smart template selection - Auto-navigation to Transcription view - Updated README with BlackHole requirement and Teams config - Added get_audio_metadata Rust command - Version bump to 1.1.0
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
use tauri::{AppHandle, Manager, State, Emitter};
|
||||
use tauri::{
|
||||
AppHandle, Manager, State, Emitter,
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{TrayIconBuilder, TrayIconEvent},
|
||||
WindowEvent
|
||||
};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::process::Command;
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
@@ -65,7 +70,7 @@ fn get_input_devices() -> Result<Vec<AudioDevice>, String> {
|
||||
|
||||
|
||||
#[tauri::command]
|
||||
fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String, save_path: Option<String>, custom_filename: Option<String>) -> Result<(), String> {
|
||||
fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String, save_path: Option<String>, custom_filename: Option<String>, wait_for_speech: Option<bool>) -> Result<(), String> {
|
||||
emit_log(&app, "INFO", &format!("Starting recording on device: {}", device_id));
|
||||
let host = cpal::default_host();
|
||||
|
||||
@@ -77,16 +82,17 @@ fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String
|
||||
.or_else(|| host.default_input_device())
|
||||
.ok_or("No input device found")?;
|
||||
|
||||
let config = device.default_input_config().map_err(|e| e.to_string())?;
|
||||
|
||||
// VAD requires 16Hz or 8kHz, typically. Silero likes 16k.
|
||||
// We might need to resample or just check if the device supports it.
|
||||
// For MVP VAD, we will try to stick to standard rates.
|
||||
// Actually, simple energy VAD is easier to start with if Silero is too heavy or requires ONNX runtime.
|
||||
// Let's check the crate docs or usage first.
|
||||
// Wait, the user wants to IGNORE music. Energy VAD will fail on music.
|
||||
// voice_activity_detector crate usually uses Silero or similar.
|
||||
|
||||
// Select the configuration with the MAXIMUM number of channels
|
||||
// This is crucial for "Hearbit Audio" (Aggregate) which lists 3 channels but might default to 2.
|
||||
// We want the raw 3 channels to separate Mic (Ch0) from System (Ch1+2).
|
||||
let supported_configs = device.supported_input_configs().map_err(|e| e.to_string())?;
|
||||
let config = supported_configs
|
||||
.max_by_key(|c| c.channels())
|
||||
.map(|c| c.with_max_sample_rate())
|
||||
.ok_or("No supported input configurations found")?;
|
||||
|
||||
emit_log(&app, "INFO", &format!("Selected Audio Config: {} Channels, {} Hz", config.channels(), config.sample_rate()));
|
||||
|
||||
let spec = hound::WavSpec {
|
||||
channels: config.channels(),
|
||||
sample_rate: config.sample_rate(),
|
||||
@@ -122,7 +128,12 @@ fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String
|
||||
|
||||
// Initialize AudioProcessor (VAD)
|
||||
// We pass the writer to it.
|
||||
let processor = AudioProcessor::new(config.sample_rate(), writer.clone(), app.clone())
|
||||
let should_wait = wait_for_speech.unwrap_or(false);
|
||||
if should_wait {
|
||||
emit_log(&app, "INFO", "Recording started in WAITING mode (buffer-only until speech).");
|
||||
}
|
||||
|
||||
let processor = AudioProcessor::new(config.sample_rate(), config.channels(), writer.clone(), app.clone(), should_wait)
|
||||
.map_err(|e| format!("Failed to create AudioProcessor: {}", e))?;
|
||||
|
||||
// Wrap processor in Arc<Mutex> so we can share/move it into callback
|
||||
@@ -560,6 +571,62 @@ async fn summarize_text(app: AppHandle, text: String, api_key: String, product_i
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct AudioMetadata {
|
||||
duration: f64,
|
||||
size: u64,
|
||||
format: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_audio_metadata(app: AppHandle, file_path: String) -> Result<AudioMetadata, String> {
|
||||
emit_log(&app, "INFO", &format!("Getting metadata for: {}", file_path));
|
||||
|
||||
// Get file size
|
||||
let metadata = std::fs::metadata(&file_path).map_err(|e| e.to_string())?;
|
||||
let size = metadata.len();
|
||||
|
||||
// Extract format from extension
|
||||
let path = std::path::Path::new(&file_path);
|
||||
let format = path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
// Get duration using ffprobe (requires ffmpeg to be installed)
|
||||
let duration = match Command::new("ffprobe")
|
||||
.args([
|
||||
"-v", "error",
|
||||
"-show_entries", "format=duration",
|
||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||
&file_path
|
||||
])
|
||||
.output()
|
||||
{
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let duration_str = String::from_utf8_lossy(&output.stdout);
|
||||
duration_str.trim().parse::<f64>().unwrap_or(0.0)
|
||||
} else {
|
||||
emit_log(&app, "WARN", "ffprobe failed, duration = 0");
|
||||
0.0
|
||||
}
|
||||
},
|
||||
Err(_) => {
|
||||
emit_log(&app, "WARN", "ffprobe not found, duration = 0");
|
||||
0.0
|
||||
}
|
||||
};
|
||||
|
||||
emit_log(&app, "SUCCESS", &format!("Metadata: {}s, {} bytes", duration, size));
|
||||
|
||||
Ok(AudioMetadata {
|
||||
duration,
|
||||
size,
|
||||
format,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_audio_midi_setup() -> Result<(), String> {
|
||||
Command::new("open")
|
||||
@@ -640,6 +707,49 @@ async fn read_log_file(app: AppHandle) -> Result<String, String> {
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.setup(|app| {
|
||||
// Setup Tray Icon
|
||||
let quit_i = MenuItem::with_id(app, "quit", "Quit Hearbit AI", true, None::<&str>).unwrap();
|
||||
let show_i = MenuItem::with_id(app, "show", "Show Window", true, None::<&str>).unwrap();
|
||||
let menu = Menu::with_items(app, &[&show_i, &quit_i]).unwrap();
|
||||
|
||||
let _tray = TrayIconBuilder::new()
|
||||
.icon(app.default_window_icon().unwrap().clone())
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(true)
|
||||
.on_menu_event(|app, event| {
|
||||
match event.id.as_ref() {
|
||||
"quit" => app.exit(0),
|
||||
"show" => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click { .. } = event {
|
||||
let app = tray.app_handle();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
if let WindowEvent::CloseRequested { api, .. } = event {
|
||||
// Prevent window from closing, just hide it
|
||||
window.hide().unwrap();
|
||||
api.prevent_close();
|
||||
}
|
||||
})
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_log::Builder::default()
|
||||
.targets([
|
||||
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
|
||||
@@ -670,6 +780,7 @@ pub fn run() {
|
||||
auth::get_calendar_events,
|
||||
save_text_file,
|
||||
read_log_file,
|
||||
get_audio_metadata,
|
||||
email::send_smtp_email
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
|
||||
Reference in New Issue
Block a user