feat: release 1.0 - rename to Hearbit AI, fix timestamps, update UI
100
src-tauri/Cargo.lock
generated
@@ -488,8 +488,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
@@ -819,6 +821,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
@@ -1515,6 +1519,23 @@ version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "hearbit-ai"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"cpal",
|
||||
"hound",
|
||||
"reqwest 0.13.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
@@ -1834,21 +1855,6 @@ dependencies = [
|
||||
"cfb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "infomaniak-recorder"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"cpal",
|
||||
"hound",
|
||||
"reqwest 0.13.1",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-opener",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.11.0"
|
||||
@@ -3309,6 +3315,30 @@ dependencies = [
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -4167,6 +4197,46 @@ dependencies = [
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.4.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed390cc669f937afeb8b28032ce837bac8ea023d975a2e207375ec05afaf1804"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
"glob",
|
||||
"percent-encoding",
|
||||
"schemars 0.8.22",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.18",
|
||||
"toml 0.9.11+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.3"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "infomaniak-recorder"
|
||||
name = "hearbit-ai"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
@@ -20,10 +20,11 @@ tauri-build = { version = "2", features = [] }
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_json = "1.0"
|
||||
chrono = "0.4"
|
||||
cpal = "0.17.1"
|
||||
hound = "3.5.1"
|
||||
reqwest = { version = "0.13.1", features = ["json", "multipart"] }
|
||||
tokio = { version = "1.40.0", features = ["full"] }
|
||||
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
"opener:default",
|
||||
"dialog:default"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 746 B After Width: | Height: | Size: 2.7 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 152 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 5.7 KiB |
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 280 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 128 KiB |
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 382 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 7.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
src-tauri/resources/BlackHole2ch.v0.6.1.pkg
Normal file
@@ -1,4 +1,4 @@
|
||||
use tauri::State;
|
||||
use tauri::{AppHandle, Manager, State, Emitter};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::process::Command;
|
||||
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
|
||||
@@ -17,6 +17,22 @@ struct AudioDevice {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, Clone)]
|
||||
struct LogEvent {
|
||||
level: String,
|
||||
message: String,
|
||||
timestamp: String,
|
||||
}
|
||||
|
||||
fn emit_log(app: &AppHandle, level: &str, message: &str) {
|
||||
let log = LogEvent {
|
||||
level: level.to_string(),
|
||||
message: message.to_string(),
|
||||
timestamp: chrono::Local::now().format("%H:%M:%S").to_string(),
|
||||
};
|
||||
let _ = app.emit("log-event", log);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn greet(name: &str) -> String {
|
||||
format!("Hello, {}! You've been greeted from Rust!", name)
|
||||
@@ -41,22 +57,11 @@ fn get_input_devices() -> Result<Vec<AudioDevice>, String> {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn install_driver() -> Result<String, String> {
|
||||
let output = Command::new("brew")
|
||||
.args(["install", "blackhole-2ch"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute command: {}", e))?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
} else {
|
||||
Err(String::from_utf8_lossy(&output.stderr).to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_recording(state: State<'_, AppState>, device_id: String) -> Result<(), String> {
|
||||
fn start_recording(app: AppHandle, state: State<'_, AppState>, device_id: String, save_path: Option<String>) -> Result<(), String> {
|
||||
emit_log(&app, "INFO", &format!("Starting recording on device: {}", device_id));
|
||||
let host = cpal::default_host();
|
||||
|
||||
// Find device by name (using name as ID)
|
||||
@@ -75,16 +80,31 @@ fn start_recording(state: State<'_, AppState>, device_id: String) -> Result<(),
|
||||
sample_format: hound::SampleFormat::Int,
|
||||
};
|
||||
|
||||
// Create a temporary file
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let file_path = temp_dir.join(format!("recording_{}.wav", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()));
|
||||
// Determine file path: User provided or Temp
|
||||
let file_path = if let Some(path) = save_path {
|
||||
if path.trim().is_empty() {
|
||||
std::env::temp_dir().join(format!("recording_{}.wav", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()))
|
||||
} else {
|
||||
// Check if directory exists, if not try to create it or error out?
|
||||
// For now, assume user gives a valid directory. We'll append filename.
|
||||
std::path::PathBuf::from(path).join(format!("recording_{}.wav", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()))
|
||||
}
|
||||
} else {
|
||||
std::env::temp_dir().join(format!("recording_{}.wav", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs()))
|
||||
};
|
||||
|
||||
let file_path_str = file_path.to_string_lossy().to_string();
|
||||
emit_log(&app, "INFO", &format!("Saving recording to: {}", file_path_str));
|
||||
|
||||
let writer = hound::WavWriter::create(&file_path, spec).map_err(|e| e.to_string())?;
|
||||
let writer = Arc::new(Mutex::new(writer));
|
||||
let writer_clone = writer.clone();
|
||||
|
||||
let err_fn = |err| eprintln!("an error occurred on stream: {}", err);
|
||||
let app_handle = app.clone();
|
||||
let err_fn = move |err| {
|
||||
eprintln!("an error occurred on stream: {}", err);
|
||||
emit_log(&app_handle, "ERROR", &format!("Stream error: {}", err));
|
||||
};
|
||||
|
||||
let stream = match config.sample_format() {
|
||||
cpal::SampleFormat::F32 => device.build_input_stream(
|
||||
@@ -128,13 +148,15 @@ fn start_recording(state: State<'_, AppState>, device_id: String) -> Result<(),
|
||||
|
||||
// Store state
|
||||
*state.recording_stream.lock().unwrap() = Some(stream);
|
||||
*state.recording_file_path.lock().unwrap() = Some(file_path_str);
|
||||
*state.recording_file_path.lock().unwrap() = Some(file_path_str.clone());
|
||||
|
||||
emit_log(&app, "SUCCESS", &format!("Recording started. File: {}", file_path_str));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn stop_recording(state: State<'_, AppState>) -> Result<String, String> {
|
||||
fn stop_recording(app: AppHandle, state: State<'_, AppState>) -> Result<String, String> {
|
||||
emit_log(&app, "INFO", "Stopping recording...");
|
||||
// Drop stream to stop recording
|
||||
{
|
||||
let mut stream_guard = state.recording_stream.lock().unwrap();
|
||||
@@ -146,7 +168,35 @@ fn stop_recording(state: State<'_, AppState>) -> Result<String, String> {
|
||||
|
||||
// Return file path
|
||||
let mut path_guard = state.recording_file_path.lock().unwrap();
|
||||
path_guard.take().ok_or("No recording path found".to_string())
|
||||
let path = path_guard.take().ok_or("No recording path found".to_string())?;
|
||||
emit_log(&app, "SUCCESS", &format!("Recording stopped. Saved to: {}", path));
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn pause_recording(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
|
||||
emit_log(&app, "INFO", "Pausing recording...");
|
||||
let stream_guard = state.recording_stream.lock().unwrap();
|
||||
if let Some(stream) = stream_guard.as_ref() {
|
||||
stream.pause().map_err(|e| e.to_string())?;
|
||||
emit_log(&app, "SUCCESS", "Recording paused.");
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Not recording".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn resume_recording(app: AppHandle, state: State<'_, AppState>) -> Result<(), String> {
|
||||
emit_log(&app, "INFO", "Resuming recording...");
|
||||
let stream_guard = state.recording_stream.lock().unwrap();
|
||||
if let Some(stream) = stream_guard.as_ref() {
|
||||
stream.play().map_err(|e| e.to_string())?;
|
||||
emit_log(&app, "SUCCESS", "Recording resumed.");
|
||||
Ok(())
|
||||
} else {
|
||||
Err("Not recording".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
@@ -157,6 +207,7 @@ struct ModelListResponse {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ModelData {
|
||||
id: String,
|
||||
#[allow(dead_code)]
|
||||
owned_by: Option<String>,
|
||||
}
|
||||
|
||||
@@ -177,6 +228,7 @@ struct Choice {
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Message {
|
||||
#[allow(dead_code)]
|
||||
content: String,
|
||||
}
|
||||
|
||||
@@ -187,20 +239,27 @@ struct ModelInfo {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_available_models(api_key: String, product_id: String) -> Result<Vec<ModelInfo>, String> {
|
||||
async fn get_available_models(app: AppHandle, api_key: String, product_id: String) -> Result<Vec<ModelInfo>, String> {
|
||||
emit_log(&app, "INFO", "Fetching available models from Infomaniak...");
|
||||
let client = reqwest::Client::new();
|
||||
// Use the v2/openai compliant endpoint as per docs
|
||||
let url = format!("https://api.infomaniak.com/2/ai/{}/openai/v1/models", product_id);
|
||||
|
||||
emit_log(&app, "DEBUG", &format!("GET {}", url));
|
||||
|
||||
let res = client.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
.map_err(|e| {
|
||||
let msg = format!("Network error fetching models: {}", e);
|
||||
emit_log(&app, "ERROR", &msg);
|
||||
msg
|
||||
})?;
|
||||
|
||||
if res.status().is_success() {
|
||||
let raw_body = res.text().await.map_err(|e| e.to_string())?;
|
||||
println!("Models Raw Response: {}", raw_body);
|
||||
// println!("Models Raw Response: {}", raw_body);
|
||||
let list: ModelListResponse = serde_json::from_str(&raw_body)
|
||||
.map_err(|e| format!("Failed to parse models: {}. Body: {}", e, raw_body))?;
|
||||
|
||||
@@ -209,20 +268,34 @@ async fn get_available_models(api_key: String, product_id: String) -> Result<Vec
|
||||
.map(|m| ModelInfo {
|
||||
id: m.id.clone(),
|
||||
name: m.id, // Use ID as name for now, or fetch more details if available
|
||||
}).collect();
|
||||
}).collect::<Vec<ModelInfo>>();
|
||||
|
||||
emit_log(&app, "SUCCESS", &format!("Loaded {} models.", models.len()));
|
||||
Ok(models)
|
||||
} else {
|
||||
// Fallback to v1 if v2 fails or try another common path?
|
||||
// For now just error out
|
||||
let err = res.text().await.unwrap_or_default();
|
||||
emit_log(&app, "ERROR", &format!("Failed to fetch models: {}", err));
|
||||
Err(format!("Failed to fetch models: {}", err))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct WhisperVerboseResponse {
|
||||
text: Option<String>,
|
||||
segments: Option<Vec<Segment>>,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct Segment {
|
||||
start: f64,
|
||||
end: f64,
|
||||
text: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn transcribe_audio(file_path: String, api_key: String, product_id: String) -> Result<String, String> {
|
||||
async fn transcribe_audio(app: AppHandle, file_path: String, api_key: String, product_id: String) -> Result<String, String> {
|
||||
emit_log(&app, "INFO", "Starting transcription with timestamps...");
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
// Prepare file part
|
||||
@@ -235,44 +308,88 @@ async fn transcribe_audio(file_path: String, api_key: String, product_id: String
|
||||
|
||||
let form = reqwest::multipart::Form::new()
|
||||
.part("file", file_part)
|
||||
.text("model", "whisper");
|
||||
.text("model", "whisper")
|
||||
.text("response_format", "verbose_json")
|
||||
.text("timestamp_granularities[]", "segment"); // Crucial for accurate segments
|
||||
|
||||
let url = format!("https://api.infomaniak.com/1/ai/{}/openai/audio/transcriptions", product_id);
|
||||
|
||||
emit_log(&app, "DEBUG", &format!("POST {}", url));
|
||||
|
||||
let res = client.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.multipart(form)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
.map_err(|e| {
|
||||
let msg = format!("Network error during transcription: {}", e);
|
||||
emit_log(&app, "ERROR", &msg);
|
||||
msg
|
||||
})?;
|
||||
|
||||
if res.status().is_success() {
|
||||
let raw_body = res.text().await.map_err(|e| e.to_string())?;
|
||||
println!("Transcription Raw Response: {}", raw_body);
|
||||
|
||||
// Attempt to parse text or batch_id
|
||||
// Attempt to parse text or batch_id
|
||||
let response: WhisperResponse = serde_json::from_str(&raw_body)
|
||||
// Check if we got a batch ID
|
||||
#[derive(serde::Deserialize)]
|
||||
struct BatchResponse {
|
||||
batch_id: Option<String>,
|
||||
}
|
||||
|
||||
// Try parsing as batch response first (Infomaniak specific behavior)
|
||||
if let Ok(batch_res) = serde_json::from_str::<BatchResponse>(&raw_body) {
|
||||
if let Some(batch_id) = batch_res.batch_id {
|
||||
emit_log(&app, "INFO", &format!("Transcription queued. Batch ID: {}", batch_id));
|
||||
return poll_transcription(&app, &client, &api_key, &product_id, &batch_id).await;
|
||||
}
|
||||
}
|
||||
|
||||
// If not batch, try parsing verbose response directly
|
||||
// Log the raw body so we can see why it fails
|
||||
emit_log(&app, "DEBUG", &format!("Direct Response (first 500 chars): {:.500}", raw_body));
|
||||
|
||||
let response: WhisperVerboseResponse = serde_json::from_str(&raw_body)
|
||||
.map_err(|e| format!("Failed to decode JSON: {}. Body: {}", e, raw_body))?;
|
||||
|
||||
match (response.text, response.batch_id) {
|
||||
(Some(text), _) => Ok(text),
|
||||
(_, Some(batch_id)) => {
|
||||
// Need to poll
|
||||
poll_transcription(&client, &api_key, &product_id, &batch_id).await
|
||||
},
|
||||
_ => Err(format!("Response contained neither text nor batch_id. Body: {}", raw_body))
|
||||
if let Some(segments) = response.segments {
|
||||
emit_log(&app, "INFO", &format!("Found {} segments (Direct).", segments.len()));
|
||||
for (i, seg) in segments.iter().take(3).enumerate() {
|
||||
emit_log(&app, "DEBUG", &format!("Seg {}: start={}", i, seg.start));
|
||||
}
|
||||
|
||||
// Format timestamps: [MM:SS] Text
|
||||
let mut formatted_transcript = String::new();
|
||||
for segment in segments {
|
||||
let start_mins = (segment.start / 60.0).floor() as u64;
|
||||
let start_secs = (segment.start % 60.0).floor() as u64;
|
||||
formatted_transcript.push_str(&format!("[{:02}:{:02}] {}\n", start_mins, start_secs, segment.text.trim()));
|
||||
}
|
||||
|
||||
// Fallback to raw text if segments empty
|
||||
if formatted_transcript.trim().is_empty() {
|
||||
if let Some(text) = response.text {
|
||||
emit_log(&app, "SUCCESS", "Segments missing, using raw text.");
|
||||
return Ok(text);
|
||||
}
|
||||
} else {
|
||||
emit_log(&app, "SUCCESS", "Transcription received with timestamps.");
|
||||
return Ok(formatted_transcript);
|
||||
}
|
||||
} else if let Some(text) = response.text {
|
||||
emit_log(&app, "SUCCESS", "Segments missing, using raw text.");
|
||||
return Ok(text);
|
||||
}
|
||||
|
||||
emit_log(&app, "ERROR", "Response contained no recognized content.");
|
||||
Err(format!("Response contained no recognized content. Body: {}", raw_body))
|
||||
} else {
|
||||
let error_text = res.text().await.unwrap_or_default();
|
||||
emit_log(&app, "ERROR", &format!("Transcription failed: {}", error_text));
|
||||
Err(format!("Transcription failed: {}", error_text))
|
||||
}
|
||||
}
|
||||
|
||||
async fn poll_transcription(client: &reqwest::Client, api_key: &str, product_id: &str, batch_id: &str) -> Result<String, String> {
|
||||
// Polling URL: /1/ai/{product_id}/results/{batch_id} (or similar, verifying via trial)
|
||||
// If that fails, we can try /openai/audio/transcriptions/{batch_id} but documentation suggests results endpoint.
|
||||
// Let's assume the standard Infomaniak pattern for batches.
|
||||
async fn poll_transcription(app: &AppHandle, client: &reqwest::Client, api_key: &str, product_id: &str, batch_id: &str) -> Result<String, String> {
|
||||
let status_url = format!("https://api.infomaniak.com/1/ai/{}/results/{}", product_id, batch_id);
|
||||
|
||||
let mut attempts = 0;
|
||||
@@ -280,6 +397,7 @@ async fn poll_transcription(client: &reqwest::Client, api_key: &str, product_id:
|
||||
attempts += 1;
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
|
||||
emit_log(app, "DEBUG", &format!("Polling status... Attempt {}", attempts));
|
||||
let res = client.get(&status_url)
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.send()
|
||||
@@ -301,31 +419,63 @@ async fn poll_transcription(client: &reqwest::Client, api_key: &str, product_id:
|
||||
|
||||
if dl_res.status().is_success() {
|
||||
let content = dl_res.text().await.map_err(|e| e.to_string())?;
|
||||
|
||||
// Try to parse the content as JSON to see if it's { "text": "..." }
|
||||
if let Ok(json_val) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
if let Some(text_content) = json_val.get("text").and_then(|t| t.as_str()) {
|
||||
return Ok(text_content.to_string());
|
||||
}
|
||||
emit_log(app, "DEBUG", &format!("Poll Raw Content (first 500 chars): {:.500}", content));
|
||||
|
||||
// Try to parse as Verbose JSON to get timestamps
|
||||
if let Ok(response) = serde_json::from_str::<WhisperVerboseResponse>(&content) {
|
||||
if let Some(segments) = response.segments {
|
||||
emit_log(app, "INFO", &format!("Found {} segments.", segments.len()));
|
||||
// Log first 3 segments start times
|
||||
for (i, seg) in segments.iter().take(3).enumerate() {
|
||||
emit_log(app, "DEBUG", &format!("Seg {}: start={}", i, seg.start));
|
||||
}
|
||||
|
||||
let mut formatted_transcript = String::new();
|
||||
for segment in segments {
|
||||
let start_mins = (segment.start / 60.0).floor() as u64;
|
||||
let start_secs = (segment.start % 60.0).floor() as u64;
|
||||
formatted_transcript.push_str(&format!("[{:02}:{:02}] {}\n", start_mins, start_secs, segment.text.trim()));
|
||||
}
|
||||
if !formatted_transcript.trim().is_empty() {
|
||||
emit_log(app, "SUCCESS", "Transcription completed (async) with timestamps.");
|
||||
return Ok(formatted_transcript);
|
||||
} else {
|
||||
emit_log(app, "WARN", "Segments found but empty content.");
|
||||
}
|
||||
} else {
|
||||
emit_log(app, "WARN", "Verbose parsed but no segments found.");
|
||||
}
|
||||
|
||||
if let Some(text) = response.text {
|
||||
emit_log(app, "SUCCESS", "Transcription completed (async) - raw text (segments missing).");
|
||||
return Ok(text);
|
||||
}
|
||||
} else {
|
||||
emit_log(app, "WARN", "Failed to parse poll content as WhisperVerboseResponse");
|
||||
}
|
||||
|
||||
|
||||
emit_log(app, "SUCCESS", "Transcription completed - returning raw content.");
|
||||
// If not JSON or no text field, return raw content
|
||||
return Ok(content);
|
||||
} else {
|
||||
emit_log(app, "ERROR", "Failed to download transcription results.");
|
||||
return Err(format!("Download failed: {}", dl_res.status()));
|
||||
}
|
||||
} else if status == "failed" || status == "error" {
|
||||
emit_log(app, "ERROR", &format!("Batch processing failed: {:?}", json));
|
||||
return Err(format!("Batch processing failed: {:?}", json));
|
||||
}
|
||||
// If 'processing' or 'pending', continue loop
|
||||
}
|
||||
}
|
||||
}
|
||||
emit_log(app, "ERROR", "Transcription timed out after 80s.");
|
||||
Err("Transcription timed out".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn summarize_text(text: String, api_key: String, product_id: String, prompt: String, model: String) -> Result<String, String> {
|
||||
async fn summarize_text(app: AppHandle, text: String, api_key: String, product_id: String, prompt: String, model: String) -> Result<String, String> {
|
||||
emit_log(&app, "INFO", "Starting summarization...");
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("https://api.infomaniak.com/2/ai/{}/openai/v1/chat/completions", product_id);
|
||||
|
||||
@@ -341,36 +491,58 @@ async fn summarize_text(text: String, api_key: String, product_id: String, promp
|
||||
"messages": messages
|
||||
});
|
||||
|
||||
emit_log(&app, "DEBUG", &format!("POST {}", url));
|
||||
|
||||
let res = client.post(&url)
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
.map_err(|e| {
|
||||
let msg = format!("Network error during summarization: {}", e);
|
||||
emit_log(&app, "ERROR", &msg);
|
||||
msg
|
||||
})?;
|
||||
|
||||
if res.status().is_success() {
|
||||
let raw_body = res.text().await.map_err(|e| e.to_string())?;
|
||||
println!("Summarization Raw Response: {}", raw_body);
|
||||
// println!("Summarization Raw Response: {}", raw_body);
|
||||
|
||||
let response_body: ChatCompletionResponse = serde_json::from_str(&raw_body)
|
||||
.map_err(|e| format!("Failed to decode JSON: {}. Body: {}", e, raw_body))?;
|
||||
|
||||
if let Some(choice) = response_body.choices.first() {
|
||||
emit_log(&app, "SUCCESS", "Summarization received.");
|
||||
Ok(choice.message.content.clone())
|
||||
} else {
|
||||
emit_log(&app, "WARN", "No summary generated in response.");
|
||||
Err("No summary generated".to_string())
|
||||
}
|
||||
} else {
|
||||
let error_text = res.text().await.unwrap_or_default();
|
||||
emit_log(&app, "ERROR", &format!("Summarization failed: {}", error_text));
|
||||
Err(format!("Summarization failed: {}", error_text))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_audio_midi_setup() -> Result<(), String> {
|
||||
Command::new("open")
|
||||
.arg("-a")
|
||||
.arg("Audio MIDI Setup")
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.manage(AppState {
|
||||
recording_stream: Mutex::new(None),
|
||||
recording_file_path: Mutex::new(None),
|
||||
@@ -378,12 +550,14 @@ pub fn run() {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
get_input_devices,
|
||||
install_driver,
|
||||
start_recording,
|
||||
stop_recording,
|
||||
pause_recording,
|
||||
resume_recording,
|
||||
transcribe_audio,
|
||||
summarize_text,
|
||||
get_available_models
|
||||
get_available_models,
|
||||
open_audio_midi_setup
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Hearbit AI",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.hearbit-ai.app",
|
||||
"identifier": "com.hearbit-ai.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
@@ -13,8 +13,8 @@
|
||||
"windows": [
|
||||
{
|
||||
"title": "Hearbit AI",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
"width": 1000,
|
||||
"height": 800
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
@@ -30,6 +30,9 @@
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"resources": [
|
||||
"resources/BlackHole2ch.v0.6.1.pkg"
|
||||
]
|
||||
}
|
||||
}
|
||||