Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 29 additions & 26 deletions src-tauri/src/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use ferrous_opencc::{config::BuiltinConfig, OpenCC};
use log::{debug, error, info, warn};
use once_cell::sync::Lazy;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::{mpsc, Arc};
use std::time::Instant;
use tauri::AppHandle;
use tauri::Emitter;
Expand Down Expand Up @@ -52,6 +52,22 @@ pub trait ShortcutAction: Send + Sync {
// Transcribe Action
pub struct TranscribeAction;

fn play_start_feedback_and_apply_mute(
app: &AppHandle,
rm: Arc<AudioRecordingManager>,
) -> mpsc::Sender<()> {
let (recording_started_tx, recording_started_rx) = mpsc::channel();
let app_clone = app.clone();
std::thread::spawn(move || {
play_feedback_sound_blocking(&app_clone, SoundType::Start);
if recording_started_rx.recv().is_ok() && rm.is_recording() {
rm.apply_mute();
}
});

recording_started_tx
}

async fn maybe_convert_chinese_variant(
settings: &AppSettings,
transcription: &str,
Expand Down Expand Up @@ -111,11 +127,14 @@ impl ShortcutAction for TranscribeAction {
tm.initiate_model_load();

let binding_id = binding_id.to_string();
let rm = app.state::<Arc<AudioRecordingManager>>();

// Start the cue immediately on press; recording startup can continue in parallel.
let recording_started_tx = play_start_feedback_and_apply_mute(app, Arc::clone(&rm));

change_tray_icon(app, TrayIconState::Recording);
show_recording_overlay(app);

let rm = app.state::<Arc<AudioRecordingManager>>();

// Get the microphone mode to determine audio feedback timing
let settings = get_settings(app);
let is_always_on = settings.always_on_microphone;
Expand Down Expand Up @@ -191,39 +210,23 @@ impl ShortcutAction for TranscribeAction {

let mut recording_started = false;
if is_always_on {
// Always-on mode: Play audio feedback immediately, then apply mute after sound finishes
debug!("Always-on mode: Playing audio feedback immediately");
let rm_clone = Arc::clone(&rm);
let app_clone = app.clone();
std::thread::spawn(move || {
play_feedback_sound_blocking(&app_clone, SoundType::Start);
rm_clone.apply_mute();
});

recording_started = rm.try_start_recording(&binding_id, stream_tap_tx);
debug!("Recording started: {}", recording_started);
} else {
// On-demand mode: Start recording first, then play audio feedback, then apply mute
debug!("On-demand mode: Starting recording first, then audio feedback");
// On-demand mode: start recording immediately; feedback is already playing.
debug!("On-demand mode: Starting recording in parallel with audio feedback");
let recording_start_time = Instant::now();
let started = rm.try_start_recording(&binding_id, stream_tap_tx);
if started {
recording_started = true;
debug!("Recording started in {:?}", recording_start_time.elapsed());
let app_clone = app.clone();
let rm_clone = Arc::clone(&rm);
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(100));
debug!("Handling delayed audio feedback/mute sequence");
play_feedback_sound_blocking(&app_clone, SoundType::Start);
rm_clone.apply_mute();
});
} else {
debug!("Failed to start recording");
}
}

if recording_started {
let _ = recording_started_tx.send(());
shortcut::register_cancel_shortcut(app);
}

Expand Down Expand Up @@ -256,15 +259,15 @@ impl ShortcutAction for TranscribeAction {
let streaming_state = Arc::clone(&app.state::<ActiveStreamingState>());
let pipeline_handle = Arc::clone(&app.state::<PipelineAbortHandle>());

change_tray_icon(app, TrayIconState::Transcribing);
show_transcribing_overlay(app);

// Unmute before playing audio feedback so the stop sound is audible
rm.remove_mute();

// Play audio feedback for recording stop
// Trigger the stop cue as early as possible on key release.
play_feedback_sound(app, SoundType::Stop);

change_tray_icon(app, TrayIconState::Transcribing);
show_transcribing_overlay(app);

let binding_id = binding_id.to_string();

// Look up the post-processing prompt for this binding.
Expand Down
226 changes: 185 additions & 41 deletions src-tauri/src/audio_feedback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,87 @@ use crate::settings::SoundTheme;
use crate::settings::{self, AppSettings};
use cpal::traits::{DeviceTrait, HostTrait};
use log::{debug, error, warn};
use rodio::OutputStreamBuilder;
use std::fs::File;
use std::io::BufReader;
use once_cell::sync::Lazy;
use rodio::{buffer::SamplesBuffer, OutputStream, OutputStreamBuilder, Sink};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Sender};
use std::thread;
use std::time::Duration;
use tauri::{AppHandle, Manager};

pub enum SoundType {
Start,
Stop,
}

#[derive(Clone)]
struct SoundBuffer {
channels: u16,
sample_rate: u32,
samples: Vec<f32>,
duration: Duration,
}

struct FeedbackPlayer {
device_key: Option<String>,
stream: OutputStream,
sounds: HashMap<PathBuf, SoundBuffer>,
}

impl FeedbackPlayer {
fn new(device_key: Option<String>) -> Result<Self, Box<dyn std::error::Error>> {
Ok(Self {
stream: create_output_stream(device_key.as_deref())?,
device_key,
sounds: HashMap::new(),
})
}

fn sound_buffer(&mut self, path: &Path) -> Result<SoundBuffer, Box<dyn std::error::Error>> {
if let Some(buffer) = self.sounds.get(path) {
return Ok(buffer.clone());
}

let buffer = load_sound_buffer(path)?;
self.sounds.insert(path.to_path_buf(), buffer.clone());
Ok(buffer)
}
}

struct PlayRequest {
path: PathBuf,
selected_device: Option<String>,
volume: f32,
completion: Option<Sender<Result<Duration, String>>>,
}

static FEEDBACK_AUDIO_TX: Lazy<Sender<PlayRequest>> = Lazy::new(|| {
let (tx, rx) = mpsc::channel::<PlayRequest>();

thread::spawn(move || {
let mut player: Option<FeedbackPlayer> = None;

while let Ok(request) = rx.recv() {
let result = play_request(
&mut player,
request.path,
request.selected_device,
request.volume,
)
.map_err(|err| err.to_string());

if let Some(completion) = request.completion {
let _ = completion.send(result);
} else if let Err(err) = result {
error!("Failed to play feedback sound: {err}");
}
}
});

tx
});

fn resolve_sound_path(
app: &AppHandle,
settings: &AppSettings,
Expand Down Expand Up @@ -62,70 +131,145 @@ pub fn play_test_sound(app: &AppHandle, sound_type: SoundType) {
}

fn play_sound_async(app: &AppHandle, path: PathBuf) {
let app_handle = app.clone();
thread::spawn(move || {
if let Err(e) = play_sound_at_path(&app_handle, path.as_path()) {
error!("Failed to play sound '{}': {}", path.display(), e);
}
});
if let Err(err) = play_sound_at_path(app, path.as_path(), false) {
error!("Failed to play sound '{}': {}", path.display(), err);
}
}

fn play_sound_blocking(app: &AppHandle, path: &Path) {
if let Err(e) = play_sound_at_path(app, path) {
if let Err(e) = play_sound_at_path(app, path, true) {
error!("Failed to play sound '{}': {}", path.display(), e);
}
}

fn play_sound_at_path(app: &AppHandle, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
fn play_sound_at_path(
app: &AppHandle,
path: &Path,
blocking: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let settings = settings::get_settings(app);
let volume = settings.audio_feedback_volume;
let selected_device = settings.selected_output_device.clone();
play_audio_file(path, selected_device, volume)
let selected_device = normalize_device_name(settings.selected_output_device.clone());
play_cached_audio(path, selected_device, volume, blocking)
}

fn play_audio_file(
path: &std::path::Path,
fn play_cached_audio(
path: &Path,
selected_device: Option<String>,
volume: f32,
blocking: bool,
) -> Result<(), Box<dyn std::error::Error>> {
if blocking {
let (tx, rx) = mpsc::channel();
FEEDBACK_AUDIO_TX.send(PlayRequest {
path: path.to_path_buf(),
selected_device,
volume,
completion: Some(tx),
})?;
let duration = rx.recv()?.map_err(std::io::Error::other)?;
thread::sleep(duration);
} else {
FEEDBACK_AUDIO_TX.send(PlayRequest {
path: path.to_path_buf(),
selected_device,
volume,
completion: None,
})?;
}

Ok(())
}

fn normalize_device_name(device_name: Option<String>) -> Option<String> {
match device_name.as_deref() {
Some("default") | Some("Default") | None => None,
Some(name) => Some(name.to_string()),
}
}

fn create_output_stream(
selected_device: Option<&str>,
) -> Result<OutputStream, Box<dyn std::error::Error>> {
let stream_builder = if let Some(device_name) = selected_device {
if device_name == "Default" {
debug!("Using default device");
OutputStreamBuilder::from_default_device()?
} else {
let host = crate::audio_toolkit::get_cpal_host();
let devices = host.output_devices()?;

let mut found_device = None;
for device in devices {
if device.name()? == device_name {
found_device = Some(device);
break;
}
let host = crate::audio_toolkit::get_cpal_host();
let devices = host.output_devices()?;

let mut found_device = None;
for device in devices {
if device.name()? == device_name {
found_device = Some(device);
break;
}
}

match found_device {
Some(device) => OutputStreamBuilder::from_device(device)?,
None => {
warn!("Device '{}' not found, using default device", device_name);
OutputStreamBuilder::from_default_device()?
}
match found_device {
Some(device) => OutputStreamBuilder::from_device(device)?,
None => {
warn!("Device '{}' not found, using default device", device_name);
OutputStreamBuilder::from_default_device()?
}
}
} else {
debug!("Using default device");
OutputStreamBuilder::from_default_device()?
};

let stream_handle = stream_builder.open_stream()?;
let mixer = stream_handle.mixer();
Ok(stream_builder.open_stream()?)
}

let file = File::open(path)?;
let buf_reader = BufReader::new(file);
fn play_request(
player: &mut Option<FeedbackPlayer>,
path: PathBuf,
selected_device: Option<String>,
volume: f32,
) -> Result<Duration, Box<dyn std::error::Error>> {
// Reopen the stream whenever Handless is following the system default
// output so runtime device switches are picked up on the next cue.
let recreate_player = if selected_device.is_none() {
true
} else {
player
.as_ref()
.is_none_or(|current| current.device_key != selected_device)
};
if recreate_player {
*player = Some(FeedbackPlayer::new(selected_device.clone())?);
}

let sink = rodio::play(mixer, buf_reader)?;
let player = player.as_mut().expect("feedback player initialized");
let buffer = player.sound_buffer(&path)?;
let sink = Sink::connect_new(player.stream.mixer());
sink.set_volume(volume);
sink.sleep_until_end();
sink.append(SamplesBuffer::new(
buffer.channels,
buffer.sample_rate,
buffer.samples,
));
sink.detach();
Ok(buffer.duration)
}

Ok(())
fn load_sound_buffer(path: &Path) -> Result<SoundBuffer, Box<dyn std::error::Error>> {
let mut reader = hound::WavReader::open(path)?;
let spec = reader.spec();
let samples = match spec.sample_format {
hound::SampleFormat::Float => reader.samples::<f32>().collect::<Result<Vec<_>, _>>()?,
hound::SampleFormat::Int => {
let max_value = ((1_i64 << (spec.bits_per_sample - 1)) - 1) as f32;
reader
.samples::<i32>()
.map(|sample| sample.map(|value| value as f32 / max_value))
.collect::<Result<Vec<_>, _>>()?
}
};

Ok(SoundBuffer {
channels: spec.channels,
sample_rate: spec.sample_rate,
duration: Duration::from_secs_f64(
samples.len() as f64 / (spec.sample_rate as f64 * spec.channels as f64),
),
samples,
})
}
Loading