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

View File

@@ -8,6 +8,10 @@
* **🎙️ Dual-Channel Recording**: seamlessly capture your voice and meeting audio from apps like Microsoft Teams, Zoom, or Google Meet.
* **📁 Import Audio Files**: Upload existing recordings (MP3, MP4, WAV, M4A, FLAC, OGG, AAC, WMA) for transcription and summarization.
* **⏱️ Long Meeting Support**: Record meetings up to 2+ hours with automatic MP3 conversion and chunking.
* **🎵 Smart Auto-Stop**:
* **Voice Memo Mode**: Automatically stops after 20 seconds of silence
* **Meeting Mode**: No auto-stop to capture full discussions
* **📅 Microsoft 365 Integration**:
* **Upcoming Meetings**: View your daily schedule and join with **one click**.
* **Meeting Details**: View full agenda and **invited attendee status** (Accepted/Declined).
@@ -23,9 +27,16 @@
## 🚀 Getting Started
### 1. Prerequisites
* **macOS** (Apple Silicon or Intel).
* **BlackHole 2ch Driver** (Mandatory): Download from [existential.audio](https://existential.audio/blackhole/) or run `brew install blackhole-2ch`.
### Required
* **macOS** (tested on macOS Monterey and later)
* **BlackHole 2ch Driver** ([Download here](https://existential.audio/blackhole/))
* **MANDATORY** for system audio capture (MS Teams, Zoom, etc.)
* Without this, you can only record microphone input
* **ffmpeg** for audio processing
```bash
brew install ffmpeg
```
* **Infomaniak AI Account**: You need an API Key and Product ID from the [Infomaniak Developer Portal](https://manager.infomaniak.com/).
### 2. Installation
@@ -93,6 +104,25 @@ This is a standard macOS warning for apps not signed with an Apple Developer Cer
3. Enter your password.
4. Open the app again.
### Long Meetings (> 1 hour)
**Automatic Handling**: The app automatically handles long recordings:
- **MP3 Conversion**: All recordings are converted to MP3 (64kbps) for 10x compression
- **Chunking**: Files ≥18 MB are automatically split into 10-minute segments
- **Processing**: Each segment is transcribed separately and merged with timestamps
**Example**: A 2-hour meeting:
1. Records as WAV (~120 MB)
2. Converts to MP3 (~12 MB)
3. Stays under limit → No chunking needed!
**Very long meetings** (e.g., all-day workshops):
- Automatically chunks into segments
- Shows progress: "Processing chunk 1/15..."
- Merges all transcriptions seamlessly
### No Audio / Can't Hear Meeting Participants
---
## 👨‍💻 Development

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!())

View File

@@ -420,6 +420,9 @@ Thanks!`
onLoad={handleLoadHistory}
onDelete={handleDeleteHistory}
onRename={handleRenameHistory}
smtpConfig={smtpConfig}
emailTemplates={emailTemplates}
addToast={addToast}
/>
)}

View File

@@ -1,5 +1,9 @@
import { FileText, Trash2, Calendar, Pencil, Check, X } from 'lucide-react';
import { FileText, Trash2, Calendar, Pencil, Check, X, Mail } from 'lucide-react';
import { useState } from 'react';
import EmailPreviewModal from './EmailPreviewModal';
import { SmtpConfig } from './Settings';
import { EmailTemplate } from '../App';
import { ToastType } from './ui/Toast';
interface HistoryItem {
id: string;
@@ -15,11 +19,15 @@ interface HistoryViewProps {
onLoad: (item: HistoryItem) => void;
onDelete: (id: string) => void;
onRename: (id: string, newSubject: string) => void;
smtpConfig: SmtpConfig;
emailTemplates: EmailTemplate[];
addToast: (message: string, type: ToastType, duration?: number) => void;
}
export default function HistoryView({ history, onLoad, onDelete, onRename }: HistoryViewProps) {
export default function HistoryView({ history, onLoad, onDelete, onRename, smtpConfig, emailTemplates, addToast }: HistoryViewProps) {
const [editingId, setEditingId] = useState<string | null>(null);
const [editValue, setEditValue] = useState("");
const [emailModalItem, setEmailModalItem] = useState<HistoryItem | null>(null);
const startEditing = (item: HistoryItem) => {
setEditingId(item.id);
@@ -104,18 +112,38 @@ export default function HistoryView({ history, onLoad, onDelete, onRename }: His
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={(e) => { e.stopPropagation(); setEmailModalItem(item); }}
className="text-muted-foreground hover:text-primary p-2 rounded-lg hover:bg-primary/10 transition-colors opacity-0 group-hover:opacity-100"
title="Send Email"
>
<Mail size={18} />
</button>
<button
onClick={(e) => { e.stopPropagation(); onDelete(item.id); }}
className="text-muted-foreground hover:text-destructive p-2 rounded-lg hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100 ml-2"
className="text-muted-foreground hover:text-destructive p-2 rounded-lg hover:bg-destructive/10 transition-colors opacity-0 group-hover:opacity-100"
title="Delete"
>
<Trash2 size={18} />
</button>
</div>
</div>
</div>
))}
</div>
)}
<EmailPreviewModal
isOpen={emailModalItem !== null}
onClose={() => setEmailModalItem(null)}
initialRecipients={[]}
initialSubject={emailModalItem?.subject || "Meeting Summary"}
initialBody={emailModalItem?.summary || ""}
emailTemplates={emailTemplates}
smtpConfig={smtpConfig ? { ...smtpConfig, port: Number(smtpConfig.port) } : null}
addToast={addToast}
/>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback } from 'react';
import React, { useState } from 'react';
import { Upload, FileAudio, X, Check, Loader2 } from 'lucide-react';
import { invoke } from "@tauri-apps/api/core";
import { open } from '@tauri-apps/plugin-dialog';
@@ -44,7 +44,6 @@ const Import: React.FC<ImportProps> = ({
setTranscription,
setSummary
}) => {
const [isDragging, setIsDragging] = useState(false);
const [selectedFile, setSelectedFile] = useState<string | null>(null);
const [metadata, setMetadata] = useState<AudioMetadata | null>(null);
const [meetingTitle, setMeetingTitle] = useState('');
@@ -94,35 +93,17 @@ const Import: React.FC<ImportProps> = ({
setMeetingTitle(extractFilename(filePath));
try {
// Get metadata (we'll need to implement this in Rust backend)
const meta = await invoke<AudioMetadata>('get_audio_metadata', { filePath });
setMetadata(meta);
setStage('idle');
addToast('File loaded successfully', 'success', 2000);
} catch (e) {
console.error('Metadata error:', e);
// Even if metadata fails, allow processing
setMetadata(null);
setStage('idle');
}
};
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
// @ts-ignore - Tauri provides path on File object
const filePath = files[0].path;
if (filePath) {
handleFileSelect(filePath);
} else {
addToast('Failed to get file path', 'error');
}
}
}, []);
const handleManualSelect = async () => {
try {
const selected = await open({
@@ -150,12 +131,62 @@ const Import: React.FC<ImportProps> = ({
}
try {
// Check file extension
const isWav = selectedFile.toLowerCase().endsWith('.wav');
let processFile = selectedFile;
// Convert WAV to MP3 if needed
if (isWav) {
setStage('validating');
addToast('Converting WAV to MP3...', 'info', 2000);
processFile = await invoke<string>('convert_to_mp3', { wavPath: selectedFile });
}
// Get file size to check if chunking needed
const metadata = await invoke<AudioMetadata>('get_audio_metadata', { filePath: processFile });
const sizeMB = metadata.size / (1024 * 1024);
let transText = '';
// Check if chunking needed for large files
if (sizeMB >= 18) {
// CHUNKING PATH for large files
setStage('validating');
addToast(`Large file (${sizeMB.toFixed(1)}MB). Splitting into chunks...`, 'info', 4000);
const chunks = await invoke<string[]>('chunk_audio', {
filePath: processFile,
chunkMinutes: 10
});
addToast(`Processing ${chunks.length} chunks...`, 'info', 4000);
let allTranscriptions: string[] = [];
for (let i = 0; i < chunks.length; i++) {
setStage('transcribing');
const transText = await invoke<string>('transcribe_audio', {
filePath: selectedFile,
addToast(`Transcribing chunk ${i + 1}/${chunks.length}...`, 'info', 2000);
const chunkText = await invoke<string>('transcribe_audio', {
filePath: chunks[i],
apiKey,
productId
});
allTranscriptions.push(chunkText);
}
// Merge transcriptions
transText = allTranscriptions.join('\n\n--- Next Segment ---\n\n');
addToast('All chunks transcribed successfully!', 'success', 3000);
} else {
// NORMAL PATH for small files
setStage('transcribing');
transText = await invoke<string>('transcribe_audio', {
filePath: processFile,
apiKey,
productId
});
}
setTranscription(transText);
if (!transText || transText.trim().length === 0) {
@@ -246,26 +277,21 @@ const Import: React.FC<ImportProps> = ({
<div className="flex flex-col w-full h-full bg-background relative">
{/* Header */}
<div className="w-full flex justify-center items-center p-4 shrink-0">
<img src={logo} alt="Logo" className="h-10 object-contain" />
<img src={localStorage.getItem('customLogo') || logo} alt="Logo" className="h-10 object-contain" />
</div>
{/* Main Content */}
<div className="flex-1 overflow-y-auto px-6 pb-6 flex flex-col items-center">
<h1 className="text-xl font-bold mb-2 text-foreground">Import Audio File</h1>
<p className="text-muted-foreground mb-6 text-center text-sm">
Upload a recording for transcription and summarization
Select an audio file for transcription and summarization
</p>
{/* Drag & Drop Zone */}
{/* File Selection Zone */}
<div
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
onDragLeave={() => setIsDragging(false)}
onDrop={handleDrop}
className={`w-full max-w-md border-2 border-dashed rounded-lg p-8 mb-6 transition-all ${isDragging
? 'border-primary bg-primary/5 scale-105'
: selectedFile
className={`w-full max-w-md border-2 border-dashed rounded-lg p-8 mb-6 transition-all ${selectedFile
? 'border-green-500 bg-green-500/5'
: 'border-border bg-secondary/30 hover:border-primary/50'
: 'border-border bg-secondary/30'
}`}
>
<div className="flex flex-col items-center justify-center gap-4">
@@ -291,17 +317,17 @@ const Import: React.FC<ImportProps> = ({
<>
<Upload size={48} className="text-muted-foreground" />
<div className="text-center">
<p className="font-semibold text-foreground">Drag & Drop audio file</p>
<p className="font-semibold text-foreground">Select Audio File</p>
<p className="text-xs text-muted-foreground mt-1">
or click below to browse
Click below to browse your files
</p>
</div>
<button
onClick={handleManualSelect}
disabled={isProcessing}
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 text-sm font-semibold transition-all"
className="px-6 py-3 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 disabled:opacity-50 text-base font-semibold transition-all shadow-md hover:shadow-lg"
>
Select File
Browse Files
</button>
<p className="text-xs text-muted-foreground">
Supported: MP3, MP4, WAV, M4A, FLAC, OGG, AAC, WMA

View File

@@ -255,16 +255,20 @@ const Recorder: React.FC<RecorderProps> = ({
const diff = (now - lastSpeechTimeRef.current) / 1000;
setSilenceDuration(diff);
// Auto-stop after 30 seconds of silence
if (diff > 30 && !isStoppingRef.current) {
console.log("Auto-stopping due to silence");
addToast("Auto-stopping (Silence detected)", "info", 3000);
// Different timeouts based on mode:
// Voice Memo: 20 seconds of silence
// Meeting: Disabled (no auto-stop to avoid cutting off long meetings)
const timeoutSeconds = recordingMode === 'voice' ? 20 : 9999; // 9999 = effectively disabled
if (diff > timeoutSeconds && !isStoppingRef.current) {
console.log(`Auto-stopping (${recordingMode} mode) due to ${timeoutSeconds}s silence`);
addToast(`Auto-stopping (${Math.floor(diff)}s silence detected)`, "info", 3000);
stopRecording();
}
}, 1000);
return () => clearInterval(interval);
}, [isRecording, isPaused, isWaiting, addToast]); // Dependencies for interval lifecycle
}, [isRecording, isPaused, isWaiting, recordingMode, addToast]); // Added recordingMode dependency
// Handle Auto Start Prop
useEffect(() => {
@@ -332,18 +336,65 @@ const Recorder: React.FC<RecorderProps> = ({
setIsRecording(false);
setIsPaused(false);
setIsWaiting(false); // Reset waiting state
setStatus('Processing...');
setStatus('Saving recording...');
const filePath = await invoke<string>('stop_recording');
// Wait a moment for file flush (safety)
await new Promise(r => setTimeout(r, 500));
setStatus('Transcribing (Infomaniak Whisper)...');
const transText = await invoke<string>('transcribe_audio', {
filePath,
// Confirm recording saved
addToast(`Recording saved locally: ${filePath.split('/').pop()}`, 'success', 3000);
setStatus('Converting to MP3...');
// Small delay to show the "saved" message
await new Promise(r => setTimeout(r, 500));
// Convert WAV to MP3 for smaller size
const mp3Path = await invoke<string>('convert_to_mp3', { wavPath: filePath });
// Get file size to check if chunking needed
interface AudioMetadata { duration: number; size: number; format: string; }
const metadata = await invoke<AudioMetadata>('get_audio_metadata', { filePath: mp3Path });
const sizeMB = metadata.size / (1024 * 1024);
let transText = '';
// Check if chunking needed (only for Meeting mode and large files)
if (recordingMode === 'meeting' && sizeMB >= 18) {
// CHUNKING PATH for large meetings
setStatus(`Large file (${sizeMB.toFixed(1)}MB). Splitting into chunks...`);
const chunks = await invoke<string[]>('chunk_audio', {
filePath: mp3Path,
chunkMinutes: 10
});
addToast(`Processing ${chunks.length} chunks...`, 'info', 4000);
let allTranscriptions: string[] = [];
for (let i = 0; i < chunks.length; i++) {
setStatus(`Transcribing chunk ${i + 1}/${chunks.length}...`);
const chunkText = await invoke<string>('transcribe_audio', {
filePath: chunks[i],
apiKey,
productId
});
allTranscriptions.push(chunkText);
}
// Merge transcriptions
transText = allTranscriptions.join('\n\n--- Next Segment ---\n\n');
addToast('All chunks transcribed successfully!', 'success', 3000);
} else {
// NORMAL PATH for small files
setStatus('Transcribing (Infomaniak Whisper)...');
transText = await invoke<string>('transcribe_audio', {
filePath: mp3Path,
apiKey,
productId
});
}
setTranscription(transText);
// Check if transcription is empty or just whitespace
@@ -422,7 +473,7 @@ const Recorder: React.FC<RecorderProps> = ({
<div className="flex flex-col w-full h-full bg-background relative">
{/* Fixed Header - Reduced padding */}
<div className="w-full flex justify-center items-center p-4 shrink-0">
<img src={logo} alt="Logo" className="h-10 object-contain" />
<img src={localStorage.getItem('customLogo') || logo} alt="Logo" className="h-10 object-contain" />
</div>
{/* Scrollable Content - Reduced spacing */}

View File

@@ -5,6 +5,7 @@ import { save, open } from '@tauri-apps/plugin-dialog';
import { invoke } from '@tauri-apps/api/core';
import { encryptData, decryptData } from '../utils/backup';
import EmailTemplateEditor from './EmailTemplateEditor';
import logo from '../assets/logo.png';
import { PromptTemplate, EmailTemplate } from '../App';
@@ -382,6 +383,61 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
</div>
</div>
<div className="space-y-4">
<h3 className="text-foreground font-semibold border-b border-border pb-2">📸 Branding</h3>
<div className="p-4 bg-secondary/20 rounded border border-border/50">
<div className="mb-3">
<div className="font-medium text-sm mb-2">Custom Logo</div>
<div className="text-xs text-muted-foreground mb-3">Upload your company logo to replace the default Livtec branding throughout the app.</div>
</div>
{/* Logo Preview */}
<div className="flex items-center gap-4 mb-3">
<div className="w-20 h-20 bg-background border border-border rounded flex items-center justify-center overflow-hidden">
<img
src={localStorage.getItem('customLogo') || logo}
alt="Logo Preview"
className="max-w-full max-h-full object-contain"
/>
</div>
<div className="flex-1">
<button
onClick={async () => {
try {
const selected = await open({
filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'svg'] }]
});
if (selected && typeof selected === 'string') {
const dataUrl = await invoke<string>('read_image_as_base64', { filePath: selected });
localStorage.setItem('customLogo', dataUrl);
setStatusIdx('Logo uploaded! Save settings to apply.');
// Force re-render
window.dispatchEvent(new Event('storage'));
}
} catch (e) {
setStatusIdx(`Logo upload failed: ${e}`);
}
}}
className="bg-secondary hover:bg-secondary/80 text-xs px-3 py-2 rounded border border-border transition-all flex items-center gap-2"
>
<Upload size={14} /> Upload Logo
</button>
<button
onClick={() => {
localStorage.removeItem('customLogo');
setStatusIdx('Logo reset to default. Save to apply.');
window.dispatchEvent(new Event('storage'));
}}
className="mt-2 bg-secondary hover:bg-secondary/80 text-xs px-3 py-2 rounded border border-border transition-all text-muted-foreground"
>
Reset to Default
</button>
</div>
</div>
<p className="text-[10px] text-muted-foreground">Supported: PNG, JPG, SVG. Recommended: Square format, transparent background.</p>
</div>
</div>
<div className="space-y-4">
<h3 className="text-foreground font-semibold border-b border-border pb-2">System Intergration</h3>
<div className="flex items-center justify-between p-4 bg-secondary/20 rounded border border-border/50">