feat: v1.1.0 - Long meeting support, email in history, custom logo

- Add MP3 conversion and chunking for files >18MB
- Support meetings up to 2+ hours
- Add email button directly in history view
- Implement custom logo upload in Settings for white-labeling
- Add read_image_as_base64 Rust command
- Update README with new features and ffmpeg requirement
This commit is contained in:
michael.borak
2026-01-21 10:14:16 +01:00
parent a06e473e85
commit 79db6adf45
9 changed files with 378 additions and 66 deletions

1
src-tauri/Cargo.lock generated
View File

@@ -1741,6 +1741,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
name = "hearbit-ai"
version = "0.1.2"
dependencies = [
"base64 0.22.1",
"chrono",
"cpal",
"hound",

View File

@@ -37,3 +37,4 @@ url = "2.5"
lettre = { version = "0.11", features = ["tokio1", "tokio1-native-tls", "builder"] }
tauri-plugin-log = "2.0.0"
tauri-plugin-shell = "2.3.4"
base64 = "0.22"

View File

@@ -9,6 +9,7 @@ use std::process::Command;
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use std::time::Duration;
use tokio::time::sleep;
use base64::Engine;
mod audio_processor;
use audio_processor::AudioProcessor;
@@ -627,6 +628,118 @@ fn get_audio_metadata(app: AppHandle, file_path: String) -> Result<AudioMetadata
})
}
#[tauri::command]
fn convert_to_mp3(app: AppHandle, wav_path: String) -> Result<String, String> {
emit_log(&app, "INFO", &format!("Converting to MP3: {}", wav_path));
let mp3_path = wav_path.replace(".wav", ".mp3");
let output = Command::new("ffmpeg")
.args([
"-i", &wav_path,
"-codec:a", "libmp3lame",
"-b:a", "64k",
"-y", // overwrite
&mp3_path
])
.output()
.map_err(|e| format!("Failed to execute ffmpeg: {}", e))?;
if output.status.success() {
emit_log(&app, "SUCCESS", &format!("MP3 created: {}", mp3_path));
Ok(mp3_path)
} else {
let error = String::from_utf8_lossy(&output.stderr);
emit_log(&app, "ERROR", &format!("MP3 conversion failed: {}", error));
Err(format!("MP3 conversion failed: {}", error))
}
}
#[tauri::command]
fn chunk_audio(app: AppHandle, file_path: String, chunk_minutes: u32) -> Result<Vec<String>, String> {
emit_log(&app, "INFO", &format!("Chunking audio: {} ({}min chunks)", file_path, chunk_minutes));
let chunk_seconds = chunk_minutes * 60;
// Get total duration using ffprobe
let duration_output = Command::new("ffprobe")
.args([
"-v", "error",
"-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1",
&file_path
])
.output()
.map_err(|e| format!("Failed to get duration: {}", e))?;
let duration_str = String::from_utf8_lossy(&duration_output.stdout);
let duration: f64 = duration_str.trim().parse()
.map_err(|_| "Failed to parse duration".to_string())?;
let num_chunks = (duration / chunk_seconds as f64).ceil() as usize;
emit_log(&app, "INFO", &format!("Total duration: {}s, creating {} chunks", duration, num_chunks));
let mut chunk_paths = Vec::new();
let base_path = file_path.replace(".mp3", "");
for i in 0..num_chunks {
let start_time = i as u32 * chunk_seconds;
let chunk_path = format!("{}_chunk_{}.mp3", base_path, i);
let output = Command::new("ffmpeg")
.args([
"-i", &file_path,
"-ss", &start_time.to_string(),
"-t", &chunk_seconds.to_string(),
"-c", "copy",
"-y",
&chunk_path
])
.output()
.map_err(|e| format!("Failed to create chunk {}: {}", i, e))?;
if !output.status.success() {
let error = String::from_utf8_lossy(&output.stderr);
return Err(format!("Chunk {} failed: {}", i, error));
}
chunk_paths.push(chunk_path);
}
emit_log(&app, "SUCCESS", &format!("Created {} chunks", chunk_paths.len()));
Ok(chunk_paths)
}
#[tauri::command]
fn read_image_as_base64(app: AppHandle, file_path: String) -> Result<String, String> {
emit_log(&app, "INFO", &format!("Reading image as base64: {}", file_path));
let bytes = std::fs::read(&file_path)
.map_err(|e| format!("Failed to read file: {}", e))?;
// Detect image type from extension
let extension = std::path::Path::new(&file_path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("png")
.to_lowercase();
let mime_type = match extension.as_str() {
"jpg" | "jpeg" => "image/jpeg",
"png" => "image/png",
"svg" => "image/svg+xml",
"gif" => "image/gif",
_ => "image/png"
};
// Use base64 encoding
let base64_str = base64::prelude::BASE64_STANDARD.encode(&bytes);
let data_url = format!("data:{};base64,{}", mime_type, base64_str);
emit_log(&app, "SUCCESS", &format!("Image converted to base64 ({} bytes)", base64_str.len()));
Ok(data_url)
}
#[tauri::command]
fn open_audio_midi_setup() -> Result<(), String> {
Command::new("open")
@@ -781,6 +894,9 @@ pub fn run() {
save_text_file,
read_log_file,
get_audio_metadata,
convert_to_mp3,
chunk_audio,
read_image_as_base64,
email::send_smtp_email
])
.run(tauri::generate_context!())