Merge pull request #59 from cjpais/recording-pill
Add Recording Overlay
This commit is contained in:
commit
6ce848dc66
31 changed files with 1904 additions and 19 deletions
BIN
bun.lockb
BIN
bun.lockb
Binary file not shown.
1273
package-lock.json
generated
Normal file
1273
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -14,6 +14,7 @@
|
||||||
"@tauri-apps/api": "^2.6.0",
|
"@tauri-apps/api": "^2.6.0",
|
||||||
"@tauri-apps/plugin-autostart": "~2",
|
"@tauri-apps/plugin-autostart": "~2",
|
||||||
"@tauri-apps/plugin-clipboard-manager": "~2",
|
"@tauri-apps/plugin-clipboard-manager": "~2",
|
||||||
|
"@tauri-apps/plugin-fs": "~2",
|
||||||
"@tauri-apps/plugin-global-shortcut": "~2",
|
"@tauri-apps/plugin-global-shortcut": "~2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
"@tauri-apps/plugin-os": "~2",
|
"@tauri-apps/plugin-os": "~2",
|
||||||
|
|
|
||||||
BIN
public/icon/recording.png
Normal file
BIN
public/icon/recording.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
public/icon/transcribing.png
Normal file
BIN
public/icon/transcribing.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
29
src-tauri/Cargo.lock
generated
29
src-tauri/Cargo.lock
generated
|
|
@ -2018,12 +2018,14 @@ dependencies = [
|
||||||
"reqwest 0.11.27",
|
"reqwest 0.11.27",
|
||||||
"rodio",
|
"rodio",
|
||||||
"rubato",
|
"rubato",
|
||||||
|
"rustfft",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tauri",
|
"tauri",
|
||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-autostart",
|
"tauri-plugin-autostart",
|
||||||
"tauri-plugin-clipboard-manager",
|
"tauri-plugin-clipboard-manager",
|
||||||
|
"tauri-plugin-fs",
|
||||||
"tauri-plugin-global-shortcut",
|
"tauri-plugin-global-shortcut",
|
||||||
"tauri-plugin-macos-permissions",
|
"tauri-plugin-macos-permissions",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
|
@ -4403,9 +4405,9 @@ dependencies = [
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustfft"
|
name = "rustfft"
|
||||||
version = "6.2.0"
|
version = "6.4.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "43806561bc506d0c5d160643ad742e3161049ac01027b5e6d7524091fd401d86"
|
checksum = "c6f140db74548f7c9d7cce60912c9ac414e74df5e718dc947d514b051b42f3f4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"num-complex",
|
"num-complex",
|
||||||
"num-integer",
|
"num-integer",
|
||||||
|
|
@ -4413,7 +4415,6 @@ dependencies = [
|
||||||
"primal-check",
|
"primal-check",
|
||||||
"strength_reduce",
|
"strength_reduce",
|
||||||
"transpose",
|
"transpose",
|
||||||
"version_check",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -5450,6 +5451,28 @@ dependencies = [
|
||||||
"thiserror 2.0.11",
|
"thiserror 2.0.11",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tauri-plugin-fs"
|
||||||
|
version = "2.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8c6ef84ee2f2094ce093e55106d90d763ba343fad57566992962e8f76d113f99"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"dunce",
|
||||||
|
"glob",
|
||||||
|
"percent-encoding",
|
||||||
|
"schemars",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_repr",
|
||||||
|
"tauri",
|
||||||
|
"tauri-plugin",
|
||||||
|
"tauri-utils",
|
||||||
|
"thiserror 2.0.11",
|
||||||
|
"toml",
|
||||||
|
"url",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tauri-plugin-global-shortcut"
|
name = "tauri-plugin-global-shortcut"
|
||||||
version = "2.2.0"
|
version = "2.2.0"
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
once_cell = "1"
|
once_cell = "1"
|
||||||
tauri = { version = "2", features = ["tray-icon", 'image-png'] }
|
tauri = { version = "2", features = [ "macos-private-api", "tray-icon", 'image-png'] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
|
@ -51,6 +51,8 @@ tauri-plugin-process = "2"
|
||||||
rodio = { git = "https://github.com/cjpais/rodio.git" }
|
rodio = { git = "https://github.com/cjpais/rodio.git" }
|
||||||
reqwest = { version = "0.11", features = ["json", "stream"] }
|
reqwest = { version = "0.11", features = ["json", "stream"] }
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
|
tauri-plugin-fs = "2"
|
||||||
|
rustfft = "6.4.0"
|
||||||
|
|
||||||
[dependencies.ort-sys]
|
[dependencies.ort-sys]
|
||||||
version = "=2.0.0-rc.9"
|
version = "=2.0.0-rc.9"
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
{
|
{
|
||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for the main window",
|
"description": "Capabilities for the app",
|
||||||
"windows": ["main"],
|
"windows": ["main", "recording_overlay"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"opener:default",
|
"opener:default",
|
||||||
|
|
@ -13,6 +13,8 @@
|
||||||
"global-shortcut:allow-register",
|
"global-shortcut:allow-register",
|
||||||
"global-shortcut:allow-unregister",
|
"global-shortcut:allow-unregister",
|
||||||
"global-shortcut:allow-unregister-all",
|
"global-shortcut:allow-unregister-all",
|
||||||
"macos-permissions:default"
|
"macos-permissions:default",
|
||||||
|
"fs:read-files",
|
||||||
|
"fs:allow-resource-read-recursive"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
17
src-tauri/gen/apple/PrivacyInfo.xcprivacy
Normal file
17
src-tauri/gen/apple/PrivacyInfo.xcprivacy
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPITypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>NSPrivacyAccessedAPIType</key>
|
||||||
|
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
|
||||||
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
|
<array>
|
||||||
|
<string>C617.1</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
|
@ -5,6 +5,8 @@ use crate::utils;
|
||||||
use crate::utils::change_tray_icon;
|
use crate::utils::change_tray_icon;
|
||||||
use crate::utils::play_recording_start_sound;
|
use crate::utils::play_recording_start_sound;
|
||||||
use crate::utils::play_recording_stop_sound;
|
use crate::utils::play_recording_stop_sound;
|
||||||
|
use crate::utils::show_recording_overlay;
|
||||||
|
use crate::utils::show_transcribing_overlay;
|
||||||
use crate::utils::TrayIconState;
|
use crate::utils::TrayIconState;
|
||||||
use log::debug;
|
use log::debug;
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
|
|
@ -30,6 +32,7 @@ impl ShortcutAction for TranscribeAction {
|
||||||
|
|
||||||
let binding_id = binding_id.to_string();
|
let binding_id = binding_id.to_string();
|
||||||
change_tray_icon(app, TrayIconState::Recording);
|
change_tray_icon(app, TrayIconState::Recording);
|
||||||
|
show_recording_overlay(app);
|
||||||
|
|
||||||
let rm = app.state::<Arc<AudioRecordingManager>>();
|
let rm = app.state::<Arc<AudioRecordingManager>>();
|
||||||
|
|
||||||
|
|
@ -78,6 +81,7 @@ impl ShortcutAction for TranscribeAction {
|
||||||
let tm = Arc::clone(&app.state::<Arc<TranscriptionManager>>());
|
let tm = Arc::clone(&app.state::<Arc<TranscriptionManager>>());
|
||||||
|
|
||||||
change_tray_icon(app, TrayIconState::Transcribing);
|
change_tray_icon(app, TrayIconState::Transcribing);
|
||||||
|
show_transcribing_overlay(app);
|
||||||
|
|
||||||
// Play audio feedback for recording stop
|
// Play audio feedback for recording stop
|
||||||
play_recording_stop_sound(app);
|
play_recording_stop_sound(app);
|
||||||
|
|
@ -119,23 +123,29 @@ impl ShortcutAction for TranscribeAction {
|
||||||
),
|
),
|
||||||
Err(e) => eprintln!("Failed to paste transcription: {}", e),
|
Err(e) => eprintln!("Failed to paste transcription: {}", e),
|
||||||
}
|
}
|
||||||
|
// Hide the overlay after transcription is complete
|
||||||
|
utils::hide_recording_overlay(&ah_clone);
|
||||||
change_tray_icon(&ah_clone, TrayIconState::Idle);
|
change_tray_icon(&ah_clone, TrayIconState::Idle);
|
||||||
})
|
})
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
eprintln!("Failed to run paste on main thread: {:?}", e);
|
eprintln!("Failed to run paste on main thread: {:?}", e);
|
||||||
|
utils::hide_recording_overlay(&ah);
|
||||||
change_tray_icon(&ah, TrayIconState::Idle);
|
change_tray_icon(&ah, TrayIconState::Idle);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
utils::hide_recording_overlay(&ah);
|
||||||
change_tray_icon(&ah, TrayIconState::Idle);
|
change_tray_icon(&ah, TrayIconState::Idle);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
debug!("Global Shortcut Transcription error: {}", err);
|
debug!("Global Shortcut Transcription error: {}", err);
|
||||||
|
utils::hide_recording_overlay(&ah);
|
||||||
change_tray_icon(&ah, TrayIconState::Idle);
|
change_tray_icon(&ah, TrayIconState::Idle);
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
debug!("No samples retrieved from recording stop");
|
debug!("No samples retrieved from recording stop");
|
||||||
|
utils::hide_recording_overlay(&ah);
|
||||||
change_tray_icon(&ah, TrayIconState::Idle);
|
change_tray_icon(&ah, TrayIconState::Idle);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
mod device;
|
mod device;
|
||||||
mod recorder;
|
mod recorder;
|
||||||
mod resampler;
|
mod resampler;
|
||||||
|
mod visualizer;
|
||||||
|
|
||||||
pub use device::{list_input_devices, list_output_devices, CpalDeviceInfo};
|
pub use device::{list_input_devices, list_output_devices, CpalDeviceInfo};
|
||||||
pub use recorder::AudioRecorder;
|
pub use recorder::AudioRecorder;
|
||||||
pub use resampler::FrameResampler;
|
pub use resampler::FrameResampler;
|
||||||
|
pub use visualizer::AudioVisualiser;
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ use cpal::{
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::audio_toolkit::{
|
use crate::audio_toolkit::{
|
||||||
audio::FrameResampler,
|
audio::{AudioVisualiser, FrameResampler},
|
||||||
constants,
|
constants,
|
||||||
vad::{self, VadFrame},
|
vad::{self, VadFrame},
|
||||||
VoiceActivityDetector,
|
VoiceActivityDetector,
|
||||||
|
|
@ -27,6 +27,7 @@ pub struct AudioRecorder {
|
||||||
cmd_tx: Option<mpsc::Sender<Cmd>>,
|
cmd_tx: Option<mpsc::Sender<Cmd>>,
|
||||||
worker_handle: Option<std::thread::JoinHandle<()>>,
|
worker_handle: Option<std::thread::JoinHandle<()>>,
|
||||||
vad: Option<Arc<Mutex<Box<dyn vad::VoiceActivityDetector>>>>,
|
vad: Option<Arc<Mutex<Box<dyn vad::VoiceActivityDetector>>>>,
|
||||||
|
level_cb: Option<Arc<dyn Fn(Vec<f32>) + Send + Sync + 'static>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AudioRecorder {
|
impl AudioRecorder {
|
||||||
|
|
@ -36,6 +37,7 @@ impl AudioRecorder {
|
||||||
cmd_tx: None,
|
cmd_tx: None,
|
||||||
worker_handle: None,
|
worker_handle: None,
|
||||||
vad: None,
|
vad: None,
|
||||||
|
level_cb: None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -44,6 +46,14 @@ impl AudioRecorder {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_level_callback<F>(mut self, cb: F) -> Self
|
||||||
|
where
|
||||||
|
F: Fn(Vec<f32>) + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
self.level_cb = Some(Arc::new(cb));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn open(&mut self, device: Option<Device>) -> Result<(), Box<dyn std::error::Error>> {
|
pub fn open(&mut self, device: Option<Device>) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
if self.worker_handle.is_some() {
|
if self.worker_handle.is_some() {
|
||||||
return Ok(()); // already open
|
return Ok(()); // already open
|
||||||
|
|
@ -62,6 +72,8 @@ impl AudioRecorder {
|
||||||
|
|
||||||
let thread_device = device.clone();
|
let thread_device = device.clone();
|
||||||
let vad = self.vad.clone();
|
let vad = self.vad.clone();
|
||||||
|
// Move the optional level callback into the worker thread
|
||||||
|
let level_cb = self.level_cb.clone();
|
||||||
|
|
||||||
let worker = std::thread::spawn(move || {
|
let worker = std::thread::spawn(move || {
|
||||||
let config = AudioRecorder::get_preferred_config(&thread_device)
|
let config = AudioRecorder::get_preferred_config(&thread_device)
|
||||||
|
|
@ -105,7 +117,7 @@ impl AudioRecorder {
|
||||||
stream.play().expect("failed to start stream");
|
stream.play().expect("failed to start stream");
|
||||||
|
|
||||||
// keep the stream alive while we process samples
|
// keep the stream alive while we process samples
|
||||||
run_consumer(sample_rate, vad, sample_rx, cmd_rx);
|
run_consumer(sample_rate, vad, sample_rx, cmd_rx, level_cb);
|
||||||
// stream is dropped here, after run_consumer returns
|
// stream is dropped here, after run_consumer returns
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -215,6 +227,7 @@ fn run_consumer(
|
||||||
vad: Option<Arc<Mutex<Box<dyn vad::VoiceActivityDetector>>>>,
|
vad: Option<Arc<Mutex<Box<dyn vad::VoiceActivityDetector>>>>,
|
||||||
sample_rx: mpsc::Receiver<Vec<f32>>,
|
sample_rx: mpsc::Receiver<Vec<f32>>,
|
||||||
cmd_rx: mpsc::Receiver<Cmd>,
|
cmd_rx: mpsc::Receiver<Cmd>,
|
||||||
|
level_cb: Option<Arc<dyn Fn(Vec<f32>) + Send + Sync + 'static>>,
|
||||||
) {
|
) {
|
||||||
let mut frame_resampler = FrameResampler::new(
|
let mut frame_resampler = FrameResampler::new(
|
||||||
in_sample_rate as usize,
|
in_sample_rate as usize,
|
||||||
|
|
@ -225,6 +238,17 @@ fn run_consumer(
|
||||||
let mut processed_samples = Vec::<f32>::new();
|
let mut processed_samples = Vec::<f32>::new();
|
||||||
let mut recording = false;
|
let mut recording = false;
|
||||||
|
|
||||||
|
// ---------- spectrum visualisation setup ---------------------------- //
|
||||||
|
const BUCKETS: usize = 16;
|
||||||
|
const WINDOW_SIZE: usize = 512;
|
||||||
|
let mut visualizer = AudioVisualiser::new(
|
||||||
|
in_sample_rate,
|
||||||
|
WINDOW_SIZE,
|
||||||
|
BUCKETS,
|
||||||
|
80.0, // vocal_min_hz
|
||||||
|
4000.0, // vocal_max_hz
|
||||||
|
);
|
||||||
|
|
||||||
fn handle_frame(
|
fn handle_frame(
|
||||||
samples: &[f32],
|
samples: &[f32],
|
||||||
recording: bool,
|
recording: bool,
|
||||||
|
|
@ -252,6 +276,14 @@ fn run_consumer(
|
||||||
Err(_) => break, // stream closed
|
Err(_) => break, // stream closed
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ---------- spectrum processing ---------------------------------- //
|
||||||
|
if let Some(buckets) = visualizer.feed(&raw) {
|
||||||
|
if let Some(cb) = &level_cb {
|
||||||
|
cb(buckets);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- existing pipeline ------------------------------------ //
|
||||||
frame_resampler.push(&raw, &mut |frame: &[f32]| {
|
frame_resampler.push(&raw, &mut |frame: &[f32]| {
|
||||||
handle_frame(frame, recording, &vad, &mut processed_samples)
|
handle_frame(frame, recording, &vad, &mut processed_samples)
|
||||||
});
|
});
|
||||||
|
|
@ -262,6 +294,7 @@ fn run_consumer(
|
||||||
Cmd::Start => {
|
Cmd::Start => {
|
||||||
processed_samples.clear();
|
processed_samples.clear();
|
||||||
recording = true;
|
recording = true;
|
||||||
|
visualizer.reset(); // Reset visualization buffer
|
||||||
if let Some(v) = &vad {
|
if let Some(v) = &vad {
|
||||||
v.lock().unwrap().reset();
|
v.lock().unwrap().reset();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
156
src-tauri/src/audio_toolkit/audio/visualizer.rs
Normal file
156
src-tauri/src/audio_toolkit/audio/visualizer.rs
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
use rustfft::{num_complex::Complex32, Fft, FftPlanner};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
const DB_MIN: f32 = -55.0;
|
||||||
|
const DB_MAX: f32 = -8.0;
|
||||||
|
const GAIN: f32 = 1.3;
|
||||||
|
const CURVE_POWER: f32 = 0.7;
|
||||||
|
|
||||||
|
pub struct AudioVisualiser {
|
||||||
|
fft: Arc<dyn Fft<f32>>,
|
||||||
|
window: Vec<f32>,
|
||||||
|
bucket_ranges: Vec<(usize, usize)>,
|
||||||
|
fft_input: Vec<Complex32>,
|
||||||
|
noise_floor: Vec<f32>,
|
||||||
|
buffer: Vec<f32>,
|
||||||
|
window_size: usize,
|
||||||
|
buckets: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AudioVisualiser {
|
||||||
|
pub fn new(
|
||||||
|
sample_rate: u32,
|
||||||
|
window_size: usize,
|
||||||
|
buckets: usize,
|
||||||
|
freq_min: f32,
|
||||||
|
freq_max: f32,
|
||||||
|
) -> Self {
|
||||||
|
let mut planner = FftPlanner::<f32>::new();
|
||||||
|
let fft = planner.plan_fft_forward(window_size);
|
||||||
|
|
||||||
|
// Pre-compute Hann window
|
||||||
|
let window: Vec<f32> = (0..window_size)
|
||||||
|
.map(|i| {
|
||||||
|
0.5 * (1.0 - (2.0 * std::f32::consts::PI * i as f32 / window_size as f32).cos())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Pre-compute bucket frequency ranges
|
||||||
|
let nyquist = sample_rate as f32 / 2.0;
|
||||||
|
let freq_min = freq_min.min(nyquist);
|
||||||
|
let freq_max = freq_max.min(nyquist);
|
||||||
|
|
||||||
|
let mut bucket_ranges = Vec::with_capacity(buckets);
|
||||||
|
|
||||||
|
for b in 0..buckets {
|
||||||
|
// Use logarithmic spacing for better perceptual representation
|
||||||
|
let log_start = (b as f32 / buckets as f32).powi(2);
|
||||||
|
let log_end = ((b + 1) as f32 / buckets as f32).powi(2);
|
||||||
|
|
||||||
|
let start_hz = freq_min + (freq_max - freq_min) * log_start;
|
||||||
|
let end_hz = freq_min + (freq_max - freq_min) * log_end;
|
||||||
|
|
||||||
|
let start_bin = ((start_hz * window_size as f32) / sample_rate as f32) as usize;
|
||||||
|
let mut end_bin = ((end_hz * window_size as f32) / sample_rate as f32) as usize;
|
||||||
|
|
||||||
|
// Ensure each bucket has at least one bin
|
||||||
|
if end_bin <= start_bin {
|
||||||
|
end_bin = start_bin + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clamp to valid range
|
||||||
|
let start_bin = start_bin.min(window_size / 2);
|
||||||
|
let end_bin = end_bin.min(window_size / 2);
|
||||||
|
|
||||||
|
bucket_ranges.push((start_bin, end_bin));
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
fft,
|
||||||
|
window,
|
||||||
|
bucket_ranges,
|
||||||
|
fft_input: vec![Complex32::new(0.0, 0.0); window_size],
|
||||||
|
noise_floor: vec![-40.0; buckets], // Initialize to reasonable noise floor
|
||||||
|
buffer: Vec::with_capacity(window_size * 2),
|
||||||
|
window_size,
|
||||||
|
buckets,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn feed(&mut self, samples: &[f32]) -> Option<Vec<f32>> {
|
||||||
|
// Add new samples to buffer
|
||||||
|
self.buffer.extend_from_slice(samples);
|
||||||
|
|
||||||
|
// Only process if we have enough samples
|
||||||
|
if self.buffer.len() < self.window_size {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take the required window of samples
|
||||||
|
let window_samples = &self.buffer[..self.window_size];
|
||||||
|
|
||||||
|
// Remove DC component
|
||||||
|
let mean = window_samples.iter().sum::<f32>() / self.window_size as f32;
|
||||||
|
|
||||||
|
// Apply window function and prepare FFT input
|
||||||
|
for (i, &sample) in window_samples.iter().enumerate() {
|
||||||
|
let windowed_sample = (sample - mean) * self.window[i];
|
||||||
|
self.fft_input[i] = Complex32::new(windowed_sample, 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform FFT
|
||||||
|
self.fft.process(&mut self.fft_input);
|
||||||
|
|
||||||
|
// Compute power spectrum and bucket levels
|
||||||
|
let mut buckets = vec![0.0; self.buckets];
|
||||||
|
|
||||||
|
for (bucket_idx, &(start_bin, end_bin)) in self.bucket_ranges.iter().enumerate() {
|
||||||
|
if start_bin >= end_bin || end_bin > self.fft_input.len() / 2 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate average power in this frequency range
|
||||||
|
let mut power_sum = 0.0;
|
||||||
|
for bin_idx in start_bin..end_bin {
|
||||||
|
let magnitude = self.fft_input[bin_idx].norm();
|
||||||
|
power_sum += magnitude * magnitude;
|
||||||
|
}
|
||||||
|
|
||||||
|
let avg_power = power_sum / (end_bin - start_bin) as f32;
|
||||||
|
|
||||||
|
// Convert to dB with proper scaling
|
||||||
|
let db = if avg_power > 1e-12 {
|
||||||
|
20.0 * (avg_power.sqrt() / self.window_size as f32).log10()
|
||||||
|
} else {
|
||||||
|
-80.0 // Very low floor for zero power
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only update noise floor when signal is quiet (below current floor + 10dB)
|
||||||
|
if db < self.noise_floor[bucket_idx] + 10.0 {
|
||||||
|
const NOISE_ALPHA: f32 = 0.001; // Very slow adaptation
|
||||||
|
self.noise_floor[bucket_idx] =
|
||||||
|
NOISE_ALPHA * db + (1.0 - NOISE_ALPHA) * self.noise_floor[bucket_idx];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map configurable dB range to 0-1 with gain and curve shaping
|
||||||
|
let normalized = ((db - DB_MIN) / (DB_MAX - DB_MIN)).clamp(0.0, 1.0);
|
||||||
|
buckets[bucket_idx] = (normalized * GAIN).powf(CURVE_POWER).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply light smoothing to reduce jitter
|
||||||
|
for i in 1..buckets.len() - 1 {
|
||||||
|
buckets[i] = buckets[i] * 0.7 + buckets[i - 1] * 0.15 + buckets[i + 1] * 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear processed samples from buffer
|
||||||
|
self.buffer.clear();
|
||||||
|
|
||||||
|
Some(buckets)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.buffer.clear();
|
||||||
|
// Reset noise floor to initial values
|
||||||
|
self.noise_floor.fill(-40.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -60,6 +60,7 @@ pub fn run() {
|
||||||
env_logger::init();
|
env_logger::init();
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_fs::init())
|
||||||
.plugin(tauri_plugin_process::init())
|
.plugin(tauri_plugin_process::init())
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||||
.plugin(tauri_plugin_os::init())
|
.plugin(tauri_plugin_os::init())
|
||||||
|
|
@ -126,6 +127,9 @@ pub fn run() {
|
||||||
app.manage(model_manager.clone());
|
app.manage(model_manager.clone());
|
||||||
app.manage(transcription_manager.clone());
|
app.manage(transcription_manager.clone());
|
||||||
|
|
||||||
|
// Create the recording overlay window (hidden by default)
|
||||||
|
utils::create_recording_overlay(&app.handle());
|
||||||
|
|
||||||
shortcut::init_shortcuts(app);
|
shortcut::init_shortcuts(app);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -153,6 +157,7 @@ pub fn run() {
|
||||||
shortcut::change_audio_feedback_setting,
|
shortcut::change_audio_feedback_setting,
|
||||||
shortcut::change_translate_to_english_setting,
|
shortcut::change_translate_to_english_setting,
|
||||||
shortcut::change_selected_language_setting,
|
shortcut::change_selected_language_setting,
|
||||||
|
shortcut::change_show_overlay_setting,
|
||||||
trigger_update_check,
|
trigger_update_check,
|
||||||
commands::cancel_operation,
|
commands::cancel_operation,
|
||||||
commands::models::get_available_models,
|
commands::models::get_available_models,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
use crate::audio_toolkit::{list_input_devices, vad::SmoothedVad, AudioRecorder, SileroVad};
|
use crate::audio_toolkit::{list_input_devices, vad::SmoothedVad, AudioRecorder, SileroVad};
|
||||||
use crate::settings::get_settings;
|
use crate::settings::get_settings;
|
||||||
|
use crate::utils;
|
||||||
use log::{debug, info};
|
use log::{debug, info};
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
@ -23,13 +24,26 @@ pub enum MicrophoneMode {
|
||||||
|
|
||||||
/* ──────────────────────────────────────────────────────────────── */
|
/* ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
fn create_audio_recorder(vad_path: &str) -> Result<AudioRecorder, anyhow::Error> {
|
fn create_audio_recorder(
|
||||||
|
vad_path: &str,
|
||||||
|
app_handle: &tauri::AppHandle,
|
||||||
|
) -> Result<AudioRecorder, anyhow::Error> {
|
||||||
let silero = SileroVad::new(vad_path, 0.3)
|
let silero = SileroVad::new(vad_path, 0.3)
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to create SileroVad: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Failed to create SileroVad: {}", e))?;
|
||||||
let smoothed_vad = SmoothedVad::new(Box::new(silero), 15, 15, 2);
|
let smoothed_vad = SmoothedVad::new(Box::new(silero), 15, 15, 2);
|
||||||
|
|
||||||
|
// Recorder with VAD plus a spectrum-level callback that forwards updates to
|
||||||
|
// the frontend.
|
||||||
let recorder = AudioRecorder::new()
|
let recorder = AudioRecorder::new()
|
||||||
.map_err(|e| anyhow::anyhow!("Failed to create AudioRecorder: {}", e))?
|
.map_err(|e| anyhow::anyhow!("Failed to create AudioRecorder: {}", e))?
|
||||||
.with_vad(Box::new(smoothed_vad));
|
.with_vad(Box::new(smoothed_vad))
|
||||||
|
.with_level_callback({
|
||||||
|
let app_handle = app_handle.clone();
|
||||||
|
move |levels| {
|
||||||
|
utils::emit_levels(&app_handle, &levels);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Ok(recorder)
|
Ok(recorder)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -97,7 +111,10 @@ impl AudioRecordingManager {
|
||||||
let mut recorder_opt = self.recorder.lock().unwrap();
|
let mut recorder_opt = self.recorder.lock().unwrap();
|
||||||
|
|
||||||
if recorder_opt.is_none() {
|
if recorder_opt.is_none() {
|
||||||
*recorder_opt = Some(create_audio_recorder(vad_path.to_str().unwrap())?);
|
*recorder_opt = Some(create_audio_recorder(
|
||||||
|
vad_path.to_str().unwrap(),
|
||||||
|
&self.app_handle,
|
||||||
|
)?);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the selected device from settings
|
// Get the selected device from settings
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ pub struct AppSettings {
|
||||||
pub translate_to_english: bool,
|
pub translate_to_english: bool,
|
||||||
#[serde(default = "default_selected_language")]
|
#[serde(default = "default_selected_language")]
|
||||||
pub selected_language: String,
|
pub selected_language: String,
|
||||||
|
#[serde(default = "default_show_overlay")]
|
||||||
|
pub show_overlay: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_model() -> String {
|
fn default_model() -> String {
|
||||||
|
|
@ -54,6 +56,11 @@ fn default_selected_language() -> String {
|
||||||
"auto".to_string()
|
"auto".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_show_overlay() -> bool {
|
||||||
|
// Default to true - users expect visual feedback by default
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
pub const SETTINGS_STORE_PATH: &str = "settings_store.json";
|
pub const SETTINGS_STORE_PATH: &str = "settings_store.json";
|
||||||
|
|
||||||
pub fn get_default_settings() -> AppSettings {
|
pub fn get_default_settings() -> AppSettings {
|
||||||
|
|
@ -99,6 +106,7 @@ pub fn get_default_settings() -> AppSettings {
|
||||||
selected_output_device: None,
|
selected_output_device: None,
|
||||||
translate_to_english: false,
|
translate_to_english: false,
|
||||||
selected_language: "auto".to_string(),
|
selected_language: "auto".to_string(),
|
||||||
|
show_overlay: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,14 @@ pub fn change_selected_language_setting(app: AppHandle, language: String) -> Res
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn change_show_overlay_setting(app: AppHandle, enabled: bool) -> Result<(), String> {
|
||||||
|
let mut settings = settings::get_settings(&app);
|
||||||
|
settings.show_overlay = enabled;
|
||||||
|
settings::write_settings(&app, settings);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn _register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {
|
fn _register_shortcut(app: &AppHandle, binding: ShortcutBinding) -> Result<(), String> {
|
||||||
// Parse shortcut and return error if it fails
|
// Parse shortcut and return error if it fails
|
||||||
let shortcut = match binding.current_binding.parse::<Shortcut>() {
|
let shortcut = match binding.current_binding.parse::<Shortcut>() {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ use enigo::Keyboard;
|
||||||
use enigo::Settings;
|
use enigo::Settings;
|
||||||
|
|
||||||
use cpal::traits::{DeviceTrait, HostTrait};
|
use cpal::traits::{DeviceTrait, HostTrait};
|
||||||
|
use log::debug;
|
||||||
use rodio::OutputStreamBuilder;
|
use rodio::OutputStreamBuilder;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::BufReader;
|
use std::io::BufReader;
|
||||||
|
|
@ -13,8 +14,7 @@ use std::thread;
|
||||||
use tauri::image::Image;
|
use tauri::image::Image;
|
||||||
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
|
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem};
|
||||||
use tauri::tray::TrayIcon;
|
use tauri::tray::TrayIcon;
|
||||||
use tauri::AppHandle;
|
use tauri::{AppHandle, Emitter, Manager, WebviewWindowBuilder};
|
||||||
use tauri::Manager;
|
|
||||||
use tauri_plugin_clipboard_manager::ClipboardExt;
|
use tauri_plugin_clipboard_manager::ClipboardExt;
|
||||||
|
|
||||||
fn send_paste() -> Result<(), String> {
|
fn send_paste() -> Result<(), String> {
|
||||||
|
|
@ -310,3 +310,107 @@ fn play_audio_file(
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ──────────────────────────────────────────────────────────────── */
|
||||||
|
/* OVERLAY MANAGEMENT */
|
||||||
|
/* ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/// Creates the recording overlay window and keeps it hidden by default
|
||||||
|
pub fn create_recording_overlay(app_handle: &AppHandle) {
|
||||||
|
// Get screen dimensions for positioning
|
||||||
|
if let Ok(monitors) = app_handle.primary_monitor() {
|
||||||
|
if let Some(monitor) = monitors {
|
||||||
|
let size = monitor.size();
|
||||||
|
let scale = monitor.scale_factor();
|
||||||
|
let screen_width = (size.width as f64 / scale) as i32;
|
||||||
|
let screen_height = (size.height as f64 / scale) as i32;
|
||||||
|
|
||||||
|
// Position at bottom center, 30px from bottom
|
||||||
|
let x = (screen_width - 150) / 2;
|
||||||
|
let y = screen_height - 30 - 30;
|
||||||
|
|
||||||
|
match WebviewWindowBuilder::new(
|
||||||
|
app_handle,
|
||||||
|
"recording_overlay",
|
||||||
|
tauri::WebviewUrl::App("src/overlay/index.html".into()),
|
||||||
|
)
|
||||||
|
.title("Recording")
|
||||||
|
.position(x as f64, y as f64)
|
||||||
|
.resizable(false)
|
||||||
|
.maximizable(false)
|
||||||
|
.minimizable(false)
|
||||||
|
.closable(false)
|
||||||
|
.decorations(false)
|
||||||
|
.always_on_top(true)
|
||||||
|
.skip_taskbar(true)
|
||||||
|
.transparent(true)
|
||||||
|
.focused(false)
|
||||||
|
.visible(false) // Start hidden
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(_window) => {
|
||||||
|
debug!("Recording overlay window created successfully (hidden)");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
debug!("Failed to create recording overlay window: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows the recording overlay window with fade-in animation
|
||||||
|
pub fn show_recording_overlay(app_handle: &AppHandle) {
|
||||||
|
// Check if show_overlay is enabled in settings
|
||||||
|
let settings = settings::get_settings(app_handle);
|
||||||
|
if !settings.show_overlay {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") {
|
||||||
|
let _ = overlay_window.show();
|
||||||
|
// Emit event to trigger fade-in animation with recording state
|
||||||
|
let _ = overlay_window.emit("show-overlay", "recording");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows the transcribing overlay window
|
||||||
|
pub fn show_transcribing_overlay(app_handle: &AppHandle) {
|
||||||
|
// Check if show_overlay is enabled in settings
|
||||||
|
let settings = settings::get_settings(app_handle);
|
||||||
|
if !settings.show_overlay {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") {
|
||||||
|
let _ = overlay_window.show();
|
||||||
|
// Emit event to switch to transcribing state
|
||||||
|
let _ = overlay_window.emit("show-overlay", "transcribing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hides the recording overlay window with fade-out animation
|
||||||
|
pub fn hide_recording_overlay(app_handle: &AppHandle) {
|
||||||
|
// Check if show_overlay is enabled in settings - if disabled, the overlay shouldn't be shown anyway
|
||||||
|
// but we still want to hide it in case the setting was changed while recording
|
||||||
|
if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") {
|
||||||
|
// Emit event to trigger fade-out animation
|
||||||
|
let _ = overlay_window.emit("hide-overlay", ());
|
||||||
|
// Hide the window after a short delay to allow animation to complete
|
||||||
|
let window_clone = overlay_window.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
std::thread::sleep(std::time::Duration::from_millis(300));
|
||||||
|
let _ = window_clone.hide();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit_levels(app_handle: &AppHandle, levels: &Vec<f32>) {
|
||||||
|
// emit levels to main app
|
||||||
|
let _ = app_handle.emit("mic-level", levels);
|
||||||
|
|
||||||
|
// also emit to the recording overlay if it's open
|
||||||
|
if let Some(overlay_window) = app_handle.get_webview_window("recording_overlay") {
|
||||||
|
let _ = overlay_window.emit("mic-level", levels);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,14 @@
|
||||||
"frontendDist": "../dist"
|
"frontendDist": "../dist"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
|
"macOSPrivateApi": true,
|
||||||
"windows": [
|
"windows": [
|
||||||
{
|
{
|
||||||
"title": "Handy",
|
"title": "Handy",
|
||||||
"width": 540,
|
"width": 540,
|
||||||
"height": 680,
|
"height": 720,
|
||||||
"minWidth": 540,
|
"minWidth": 540,
|
||||||
"minHeight": 680,
|
"minHeight": 720,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"maximizable": false
|
"maximizable": false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1 +0,0 @@
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4 KiB |
|
|
@ -4,6 +4,7 @@ import { AlwaysOnMicrophone } from "./AlwaysOnMicrophone";
|
||||||
import { PushToTalk } from "./PushToTalk";
|
import { PushToTalk } from "./PushToTalk";
|
||||||
import { AudioFeedback } from "./AudioFeedback";
|
import { AudioFeedback } from "./AudioFeedback";
|
||||||
import { OutputDeviceSelector } from "./OutputDeviceSelector";
|
import { OutputDeviceSelector } from "./OutputDeviceSelector";
|
||||||
|
import { ShowOverlay } from "./ShowOverlay";
|
||||||
import { HandyShortcut } from "./HandyShortcut";
|
import { HandyShortcut } from "./HandyShortcut";
|
||||||
import { TranslateToEnglish } from "./TranslateToEnglish";
|
import { TranslateToEnglish } from "./TranslateToEnglish";
|
||||||
import { LanguageSelector } from "./LanguageSelector";
|
import { LanguageSelector } from "./LanguageSelector";
|
||||||
|
|
@ -22,6 +23,7 @@ export const Settings: React.FC = () => {
|
||||||
<PushToTalk descriptionMode="tooltip" grouped={true} />
|
<PushToTalk descriptionMode="tooltip" grouped={true} />
|
||||||
<AudioFeedback descriptionMode="tooltip" grouped={true} />
|
<AudioFeedback descriptionMode="tooltip" grouped={true} />
|
||||||
<OutputDeviceSelector descriptionMode="tooltip" grouped={true} />
|
<OutputDeviceSelector descriptionMode="tooltip" grouped={true} />
|
||||||
|
<ShowOverlay descriptionMode="tooltip" grouped={true} />
|
||||||
<TranslateToEnglish descriptionMode="tooltip" grouped={true} />
|
<TranslateToEnglish descriptionMode="tooltip" grouped={true} />
|
||||||
<AlwaysOnMicrophone descriptionMode="tooltip" grouped={true} />
|
<AlwaysOnMicrophone descriptionMode="tooltip" grouped={true} />
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
|
|
|
||||||
29
src/components/settings/ShowOverlay.tsx
Normal file
29
src/components/settings/ShowOverlay.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import React from "react";
|
||||||
|
import { ToggleSwitch } from "../ui/ToggleSwitch";
|
||||||
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
|
|
||||||
|
interface ShowOverlayProps {
|
||||||
|
descriptionMode?: "inline" | "tooltip";
|
||||||
|
grouped?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ShowOverlay: React.FC<ShowOverlayProps> = ({
|
||||||
|
descriptionMode = "tooltip",
|
||||||
|
grouped = false,
|
||||||
|
}) => {
|
||||||
|
const { getSetting, updateSetting, isUpdating } = useSettings();
|
||||||
|
|
||||||
|
const showOverlayEnabled = getSetting("show_overlay") ?? true;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={showOverlayEnabled}
|
||||||
|
onChange={(enabled) => updateSetting("show_overlay", enabled)}
|
||||||
|
isUpdating={isUpdating("show_overlay")}
|
||||||
|
label="Show Overlay"
|
||||||
|
description="Display visual feedback overlay during recording and transcription"
|
||||||
|
descriptionMode={descriptionMode}
|
||||||
|
grouped={grouped}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -4,5 +4,6 @@ export { OutputDeviceSelector } from "./OutputDeviceSelector";
|
||||||
export { AlwaysOnMicrophone } from "./AlwaysOnMicrophone";
|
export { AlwaysOnMicrophone } from "./AlwaysOnMicrophone";
|
||||||
export { PushToTalk } from "./PushToTalk";
|
export { PushToTalk } from "./PushToTalk";
|
||||||
export { AudioFeedback } from "./AudioFeedback";
|
export { AudioFeedback } from "./AudioFeedback";
|
||||||
|
export { ShowOverlay } from "./ShowOverlay";
|
||||||
export { HandyShortcut } from "./HandyShortcut";
|
export { HandyShortcut } from "./HandyShortcut";
|
||||||
export { TranslateToEnglish } from "./TranslateToEnglish";
|
export { TranslateToEnglish } from "./TranslateToEnglish";
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,9 @@ export const useSettings = (): UseSettingsReturn => {
|
||||||
language: value,
|
language: value,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
|
case "show_overlay":
|
||||||
|
await invoke("change_show_overlay_setting", { enabled: value });
|
||||||
|
break;
|
||||||
case "bindings":
|
case "bindings":
|
||||||
// Handle bindings separately - they use their own invoke methods
|
// Handle bindings separately - they use their own invoke methods
|
||||||
break;
|
break;
|
||||||
|
|
@ -243,6 +246,7 @@ export const useSettings = (): UseSettingsReturn => {
|
||||||
selected_output_device: "Default",
|
selected_output_device: "Default",
|
||||||
translate_to_english: false,
|
translate_to_english: false,
|
||||||
selected_language: "auto",
|
selected_language: "auto",
|
||||||
|
show_overlay: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultValue = defaults[key];
|
const defaultValue = defaults[key];
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export const SettingsSchema = z.object({
|
||||||
selected_output_device: z.string().nullable().optional(),
|
selected_output_device: z.string().nullable().optional(),
|
||||||
translate_to_english: z.boolean(),
|
translate_to_english: z.boolean(),
|
||||||
selected_language: z.string(),
|
selected_language: z.string(),
|
||||||
|
show_overlay: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const BindingResponseSchema = z.object({
|
export const BindingResponseSchema = z.object({
|
||||||
|
|
|
||||||
55
src/overlay/RecordingOverlay.css
Normal file
55
src/overlay/RecordingOverlay.css
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
.recording-overlay {
|
||||||
|
height: 40px;
|
||||||
|
/*width: min-content;*/
|
||||||
|
width: 120px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding-left: 6px;
|
||||||
|
padding-right: 24px;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
border-radius: 24px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 300ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bars-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding-bottom: 0px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
width: 6px;
|
||||||
|
background: linear-gradient(to top, #faa2ca, #f28cbb);
|
||||||
|
max-height: 28px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: height 80ms linear;
|
||||||
|
min-height: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recording-overlay.fade-in {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcribing-text {
|
||||||
|
color: #faa2ca;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
animation: transcribing-pulse 1.5s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes transcribing-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
94
src/overlay/RecordingOverlay.tsx
Normal file
94
src/overlay/RecordingOverlay.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
import React, { useEffect, useState, useRef } from "react";
|
||||||
|
import "./RecordingOverlay.css";
|
||||||
|
import { listen } from "@tauri-apps/api/event";
|
||||||
|
|
||||||
|
type OverlayState = "recording" | "transcribing";
|
||||||
|
|
||||||
|
const RecordingOverlay: React.FC = () => {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [state, setState] = useState<OverlayState>("recording");
|
||||||
|
const [levels, setLevels] = useState<number[]>(Array(16).fill(0));
|
||||||
|
const smoothedLevelsRef = useRef<number[]>(Array(16).fill(0));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const setupEventListeners = async () => {
|
||||||
|
// Listen for show-overlay event from Rust
|
||||||
|
const unlistenShow = await listen("show-overlay", (event) => {
|
||||||
|
const overlayState = event.payload as OverlayState;
|
||||||
|
setState(overlayState);
|
||||||
|
setIsVisible(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for hide-overlay event from Rust
|
||||||
|
const unlistenHide = await listen("hide-overlay", () => {
|
||||||
|
setIsVisible(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for mic-level updates
|
||||||
|
const unlistenLevel = await listen<number[]>("mic-level", (event) => {
|
||||||
|
const newLevels = event.payload as number[];
|
||||||
|
|
||||||
|
// Apply smoothing to reduce jitter
|
||||||
|
const smoothed = smoothedLevelsRef.current.map((prev, i) => {
|
||||||
|
const target = newLevels[i] || 0;
|
||||||
|
return prev * 0.7 + target * 0.3; // Smooth transition
|
||||||
|
});
|
||||||
|
|
||||||
|
smoothedLevelsRef.current = smoothed;
|
||||||
|
console.log(smoothed.length);
|
||||||
|
setLevels(smoothed.slice(0, 10));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
return () => {
|
||||||
|
unlistenShow();
|
||||||
|
unlistenHide();
|
||||||
|
unlistenLevel();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
setupEventListeners();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getIconPath = () => {
|
||||||
|
return state === "recording"
|
||||||
|
? "/icon/recording.png"
|
||||||
|
: "/icon/transcribing.png";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIconAlt = () => {
|
||||||
|
return state === "recording" ? "Recording Icon" : "Transcribing Icon";
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`recording-overlay ${isVisible ? "fade-in" : ""}`}>
|
||||||
|
<img
|
||||||
|
width="28"
|
||||||
|
height="28"
|
||||||
|
src={getIconPath()}
|
||||||
|
alt={getIconAlt()}
|
||||||
|
style={{}}
|
||||||
|
/>
|
||||||
|
{state === "recording" && (
|
||||||
|
<div className="bars-container">
|
||||||
|
{levels.map((v, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bar"
|
||||||
|
style={{
|
||||||
|
height: `${4 + Math.pow(v, 0.7) * 32}px`, // Slight curve for better visual
|
||||||
|
transition: "height 60ms ease-out",
|
||||||
|
opacity: Math.max(0.4, v * 1.7), // Minimum opacity for visibility
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{state === "transcribing" && (
|
||||||
|
<div className="transcribing-text">Transcribing...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RecordingOverlay;
|
||||||
19
src/overlay/index.html
Normal file
19
src/overlay/index.html
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>Recording Overlay</title>
|
||||||
|
<style>
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/overlay/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
9
src/overlay/main.tsx
Normal file
9
src/overlay/main.tsx
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import RecordingOverlay from "./RecordingOverlay";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<RecordingOverlay />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
|
|
@ -1,14 +1,24 @@
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { resolve } from "path";
|
||||||
|
|
||||||
// @ts-expect-error process is a nodejs global
|
|
||||||
const host = process.env.TAURI_DEV_HOST;
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig(async () => ({
|
export default defineConfig(async () => ({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
|
||||||
|
// Multiple entry points for main app and overlay
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
main: resolve(__dirname, "index.html"),
|
||||||
|
overlay: resolve(__dirname, "src/overlay/index.html"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||||
//
|
//
|
||||||
// 1. prevent vite from obscuring rust errors
|
// 1. prevent vite from obscuring rust errors
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue