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:
36
README.md
36
README.md
@@ -8,6 +8,10 @@
|
|||||||
|
|
||||||
* **🎙️ Dual-Channel Recording**: seamlessly capture your voice and meeting audio from apps like Microsoft Teams, Zoom, or Google Meet.
|
* **🎙️ 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.
|
* **📁 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**:
|
* **📅 Microsoft 365 Integration**:
|
||||||
* **Upcoming Meetings**: View your daily schedule and join with **one click**.
|
* **Upcoming Meetings**: View your daily schedule and join with **one click**.
|
||||||
* **Meeting Details**: View full agenda and **invited attendee status** (Accepted/Declined).
|
* **Meeting Details**: View full agenda and **invited attendee status** (Accepted/Declined).
|
||||||
@@ -23,9 +27,16 @@
|
|||||||
|
|
||||||
## 🚀 Getting Started
|
## 🚀 Getting Started
|
||||||
|
|
||||||
### 1. Prerequisites
|
### Required
|
||||||
* **macOS** (Apple Silicon or Intel).
|
|
||||||
* **BlackHole 2ch Driver** (Mandatory): Download from [existential.audio](https://existential.audio/blackhole/) or run `brew install blackhole-2ch`.
|
* **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/).
|
* **Infomaniak AI Account**: You need an API Key and Product ID from the [Infomaniak Developer Portal](https://manager.infomaniak.com/).
|
||||||
|
|
||||||
### 2. Installation
|
### 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.
|
3. Enter your password.
|
||||||
4. Open the app again.
|
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
|
## 👨💻 Development
|
||||||
|
|||||||
1
src-tauri/Cargo.lock
generated
1
src-tauri/Cargo.lock
generated
@@ -1741,6 +1741,7 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
|||||||
name = "hearbit-ai"
|
name = "hearbit-ai"
|
||||||
version = "0.1.2"
|
version = "0.1.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
"cpal",
|
"cpal",
|
||||||
"hound",
|
"hound",
|
||||||
|
|||||||
@@ -37,3 +37,4 @@ url = "2.5"
|
|||||||
lettre = { version = "0.11", features = ["tokio1", "tokio1-native-tls", "builder"] }
|
lettre = { version = "0.11", features = ["tokio1", "tokio1-native-tls", "builder"] }
|
||||||
tauri-plugin-log = "2.0.0"
|
tauri-plugin-log = "2.0.0"
|
||||||
tauri-plugin-shell = "2.3.4"
|
tauri-plugin-shell = "2.3.4"
|
||||||
|
base64 = "0.22"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use std::process::Command;
|
|||||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::time::sleep;
|
use tokio::time::sleep;
|
||||||
|
use base64::Engine;
|
||||||
|
|
||||||
mod audio_processor;
|
mod audio_processor;
|
||||||
use audio_processor::AudioProcessor;
|
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]
|
#[tauri::command]
|
||||||
fn open_audio_midi_setup() -> Result<(), String> {
|
fn open_audio_midi_setup() -> Result<(), String> {
|
||||||
Command::new("open")
|
Command::new("open")
|
||||||
@@ -781,6 +894,9 @@ pub fn run() {
|
|||||||
save_text_file,
|
save_text_file,
|
||||||
read_log_file,
|
read_log_file,
|
||||||
get_audio_metadata,
|
get_audio_metadata,
|
||||||
|
convert_to_mp3,
|
||||||
|
chunk_audio,
|
||||||
|
read_image_as_base64,
|
||||||
email::send_smtp_email
|
email::send_smtp_email
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
|
|||||||
@@ -420,6 +420,9 @@ Thanks!`
|
|||||||
onLoad={handleLoadHistory}
|
onLoad={handleLoadHistory}
|
||||||
onDelete={handleDeleteHistory}
|
onDelete={handleDeleteHistory}
|
||||||
onRename={handleRenameHistory}
|
onRename={handleRenameHistory}
|
||||||
|
smtpConfig={smtpConfig}
|
||||||
|
emailTemplates={emailTemplates}
|
||||||
|
addToast={addToast}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { useState } from 'react';
|
||||||
|
import EmailPreviewModal from './EmailPreviewModal';
|
||||||
|
import { SmtpConfig } from './Settings';
|
||||||
|
import { EmailTemplate } from '../App';
|
||||||
|
import { ToastType } from './ui/Toast';
|
||||||
|
|
||||||
interface HistoryItem {
|
interface HistoryItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -15,11 +19,15 @@ interface HistoryViewProps {
|
|||||||
onLoad: (item: HistoryItem) => void;
|
onLoad: (item: HistoryItem) => void;
|
||||||
onDelete: (id: string) => void;
|
onDelete: (id: string) => void;
|
||||||
onRename: (id: string, newSubject: 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 [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editValue, setEditValue] = useState("");
|
const [editValue, setEditValue] = useState("");
|
||||||
|
const [emailModalItem, setEmailModalItem] = useState<HistoryItem | null>(null);
|
||||||
|
|
||||||
const startEditing = (item: HistoryItem) => {
|
const startEditing = (item: HistoryItem) => {
|
||||||
setEditingId(item.id);
|
setEditingId(item.id);
|
||||||
@@ -104,18 +112,38 @@ export default function HistoryView({ history, onLoad, onDelete, onRename }: His
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={(e) => { e.stopPropagation(); onDelete(item.id); }}
|
<button
|
||||||
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"
|
onClick={(e) => { e.stopPropagation(); setEmailModalItem(item); }}
|
||||||
title="Delete"
|
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"
|
||||||
<Trash2 size={18} />
|
>
|
||||||
</button>
|
<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"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { Upload, FileAudio, X, Check, Loader2 } from 'lucide-react';
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { open } from '@tauri-apps/plugin-dialog';
|
import { open } from '@tauri-apps/plugin-dialog';
|
||||||
@@ -44,7 +44,6 @@ const Import: React.FC<ImportProps> = ({
|
|||||||
setTranscription,
|
setTranscription,
|
||||||
setSummary
|
setSummary
|
||||||
}) => {
|
}) => {
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
|
||||||
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
const [selectedFile, setSelectedFile] = useState<string | null>(null);
|
||||||
const [metadata, setMetadata] = useState<AudioMetadata | null>(null);
|
const [metadata, setMetadata] = useState<AudioMetadata | null>(null);
|
||||||
const [meetingTitle, setMeetingTitle] = useState('');
|
const [meetingTitle, setMeetingTitle] = useState('');
|
||||||
@@ -94,35 +93,17 @@ const Import: React.FC<ImportProps> = ({
|
|||||||
setMeetingTitle(extractFilename(filePath));
|
setMeetingTitle(extractFilename(filePath));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get metadata (we'll need to implement this in Rust backend)
|
|
||||||
const meta = await invoke<AudioMetadata>('get_audio_metadata', { filePath });
|
const meta = await invoke<AudioMetadata>('get_audio_metadata', { filePath });
|
||||||
setMetadata(meta);
|
setMetadata(meta);
|
||||||
setStage('idle');
|
setStage('idle');
|
||||||
addToast('File loaded successfully', 'success', 2000);
|
addToast('File loaded successfully', 'success', 2000);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Metadata error:', e);
|
console.error('Metadata error:', e);
|
||||||
// Even if metadata fails, allow processing
|
|
||||||
setMetadata(null);
|
setMetadata(null);
|
||||||
setStage('idle');
|
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 () => {
|
const handleManualSelect = async () => {
|
||||||
try {
|
try {
|
||||||
const selected = await open({
|
const selected = await open({
|
||||||
@@ -150,12 +131,62 @@ const Import: React.FC<ImportProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setStage('transcribing');
|
// Check file extension
|
||||||
const transText = await invoke<string>('transcribe_audio', {
|
const isWav = selectedFile.toLowerCase().endsWith('.wav');
|
||||||
filePath: selectedFile,
|
let processFile = selectedFile;
|
||||||
apiKey,
|
|
||||||
productId
|
// 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');
|
||||||
|
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);
|
setTranscription(transText);
|
||||||
|
|
||||||
if (!transText || transText.trim().length === 0) {
|
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">
|
<div className="flex flex-col w-full h-full bg-background relative">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="w-full flex justify-center items-center p-4 shrink-0">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<div className="flex-1 overflow-y-auto px-6 pb-6 flex flex-col items-center">
|
<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>
|
<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">
|
<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>
|
</p>
|
||||||
|
|
||||||
{/* Drag & Drop Zone */}
|
{/* File Selection Zone */}
|
||||||
<div
|
<div
|
||||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
className={`w-full max-w-md border-2 border-dashed rounded-lg p-8 mb-6 transition-all ${selectedFile
|
||||||
onDragLeave={() => setIsDragging(false)}
|
? 'border-green-500 bg-green-500/5'
|
||||||
onDrop={handleDrop}
|
: 'border-border bg-secondary/30'
|
||||||
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
|
|
||||||
? 'border-green-500 bg-green-500/5'
|
|
||||||
: 'border-border bg-secondary/30 hover:border-primary/50'
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col items-center justify-center gap-4">
|
<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" />
|
<Upload size={48} className="text-muted-foreground" />
|
||||||
<div className="text-center">
|
<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">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
or click below to browse
|
Click below to browse your files
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleManualSelect}
|
onClick={handleManualSelect}
|
||||||
disabled={isProcessing}
|
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>
|
</button>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Supported: MP3, MP4, WAV, M4A, FLAC, OGG, AAC, WMA
|
Supported: MP3, MP4, WAV, M4A, FLAC, OGG, AAC, WMA
|
||||||
|
|||||||
@@ -255,16 +255,20 @@ const Recorder: React.FC<RecorderProps> = ({
|
|||||||
const diff = (now - lastSpeechTimeRef.current) / 1000;
|
const diff = (now - lastSpeechTimeRef.current) / 1000;
|
||||||
setSilenceDuration(diff);
|
setSilenceDuration(diff);
|
||||||
|
|
||||||
// Auto-stop after 30 seconds of silence
|
// Different timeouts based on mode:
|
||||||
if (diff > 30 && !isStoppingRef.current) {
|
// Voice Memo: 20 seconds of silence
|
||||||
console.log("Auto-stopping due to silence");
|
// Meeting: Disabled (no auto-stop to avoid cutting off long meetings)
|
||||||
addToast("Auto-stopping (Silence detected)", "info", 3000);
|
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();
|
stopRecording();
|
||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [isRecording, isPaused, isWaiting, addToast]); // Dependencies for interval lifecycle
|
}, [isRecording, isPaused, isWaiting, recordingMode, addToast]); // Added recordingMode dependency
|
||||||
|
|
||||||
// Handle Auto Start Prop
|
// Handle Auto Start Prop
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -332,18 +336,65 @@ const Recorder: React.FC<RecorderProps> = ({
|
|||||||
setIsRecording(false);
|
setIsRecording(false);
|
||||||
setIsPaused(false);
|
setIsPaused(false);
|
||||||
setIsWaiting(false); // Reset waiting state
|
setIsWaiting(false); // Reset waiting state
|
||||||
setStatus('Processing...');
|
setStatus('Saving recording...');
|
||||||
const filePath = await invoke<string>('stop_recording');
|
const filePath = await invoke<string>('stop_recording');
|
||||||
|
|
||||||
// Wait a moment for file flush (safety)
|
// Wait a moment for file flush (safety)
|
||||||
await new Promise(r => setTimeout(r, 500));
|
await new Promise(r => setTimeout(r, 500));
|
||||||
|
|
||||||
setStatus('Transcribing (Infomaniak Whisper)...');
|
// Confirm recording saved
|
||||||
const transText = await invoke<string>('transcribe_audio', {
|
addToast(`Recording saved locally: ${filePath.split('/').pop()}`, 'success', 3000);
|
||||||
filePath,
|
setStatus('Converting to MP3...');
|
||||||
apiKey,
|
|
||||||
productId
|
// 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);
|
setTranscription(transText);
|
||||||
|
|
||||||
// Check if transcription is empty or just whitespace
|
// 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">
|
<div className="flex flex-col w-full h-full bg-background relative">
|
||||||
{/* Fixed Header - Reduced padding */}
|
{/* Fixed Header - Reduced padding */}
|
||||||
<div className="w-full flex justify-center items-center p-4 shrink-0">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Content - Reduced spacing */}
|
{/* Scrollable Content - Reduced spacing */}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { save, open } from '@tauri-apps/plugin-dialog';
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { encryptData, decryptData } from '../utils/backup';
|
import { encryptData, decryptData } from '../utils/backup';
|
||||||
import EmailTemplateEditor from './EmailTemplateEditor';
|
import EmailTemplateEditor from './EmailTemplateEditor';
|
||||||
|
import logo from '../assets/logo.png';
|
||||||
|
|
||||||
import { PromptTemplate, EmailTemplate } from '../App';
|
import { PromptTemplate, EmailTemplate } from '../App';
|
||||||
|
|
||||||
@@ -382,6 +383,61 @@ const Settings: React.FC<SettingsProps> = ({ apiKey, productId, prompts, savePat
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="space-y-4">
|
||||||
<h3 className="text-foreground font-semibold border-b border-border pb-2">System Intergration</h3>
|
<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">
|
<div className="flex items-center justify-between p-4 bg-secondary/20 rounded border border-border/50">
|
||||||
|
|||||||
Reference in New Issue
Block a user