diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index f70a680..625ba5f 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -17,6 +17,17 @@ MIDIMaster binds controls on your MIDI device (faders, knobs, buttons) to target When a target changes, MIDIMaster can send feedback back to your MIDI controller (for motorized faders, LEDs, etc.). +### Audio-reactive LEDs + +For fader and button bindings, you can switch LED feedback to **Audio Reactive** mode: + +1. Open the binding configuration. +2. For faders/knobs, set **LED mode** to **Audio Reactive**. For buttons, choose **Audio Reactive** in the button light list. +3. Adjust **LED sensitivity** so the light responds more (or less) to peaks and small level changes around the current meter. +4. Optionally learn or type a custom Note/CC/Pitch Bend address for the LED output (already supported under Feedback output / Indicator output). + +In Audio Reactive mode the light follows live audio peaks for the bound target (master, device, application, session, or focused app). Quiet passages stay dark; louder beats and short rises above the recent level drive brighter pulses. Higher sensitivity makes those mid-metering fluctuations more visible. If a fader also has a custom LED address, motor fader position feedback still updates the primary control while the LED address receives the reactive signal. + ## First-Time Setup ### 1) Configure MIDI Routes diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8147b10..d7d543c 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -43,6 +43,7 @@ windows = { version = "0.61", features = [ "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading", "Win32_System_Registry", + "Win32_System_Variant", "Win32_Foundation", "Win32_Graphics_Gdi", "Win32_Storage_FileSystem", diff --git a/src-tauri/src/audio/mod.rs b/src-tauri/src/audio/mod.rs index 46435fc..c7a7c19 100644 --- a/src-tauri/src/audio/mod.rs +++ b/src-tauri/src/audio/mod.rs @@ -1,4 +1,22 @@ use crate::model::SessionInfo; +use std::collections::HashMap; + +#[derive(Debug, Clone, Default)] +pub struct SessionPeakIdentity { + pub display_name: String, + pub application_key: Option, + pub process_name: Option, + pub process_path: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct AudioPeakLevels { + pub master: f32, + pub devices: HashMap, + pub sessions: HashMap, + pub focused_session_id: Option, + pub session_matches: Vec<(SessionPeakIdentity, f32)>, +} pub trait AudioBackend: Send + Sync { fn list_sessions(&self) -> anyhow::Result>; @@ -16,6 +34,9 @@ pub trait AudioBackend: Send + Sync { fn focused_session_state(&self) -> anyhow::Result> { self.focused_session() } + fn peak_levels(&self) -> anyhow::Result { + Ok(AudioPeakLevels::default()) + } // Mute methods fn set_master_mute(&self, muted: bool) -> anyhow::Result<()>; diff --git a/src-tauri/src/audio/windows.rs b/src-tauri/src/audio/windows.rs index 02153c3..3fe4048 100644 --- a/src-tauri/src/audio/windows.rs +++ b/src-tauri/src/audio/windows.rs @@ -1,5 +1,5 @@ use crate::audio::target_match::{application_name_matches, ApplicationMatchInfo}; -use crate::audio::AudioBackend; +use crate::audio::{AudioBackend, AudioPeakLevels, SessionPeakIdentity}; use crate::device_target::{parse_device_target, DeviceTargetKind}; use crate::model::{PlaybackDeviceInfo, SessionInfo}; use anyhow::{anyhow, Result}; @@ -11,7 +11,7 @@ use std::sync::{Mutex, OnceLock}; use std::time::{Duration, Instant}; use windows::core::{IUnknown_Vtbl, Interface, GUID, HRESULT, PCWSTR}; use windows::Win32::Foundation::{PROPERTYKEY, RPC_E_CHANGED_MODE}; -use windows::Win32::Media::Audio::Endpoints::IAudioEndpointVolume; +use windows::Win32::Media::Audio::Endpoints::{IAudioEndpointVolume, IAudioMeterInformation}; use windows::Win32::Media::Audio::{ eCapture, eCommunications, eConsole, eMultimedia, eRender, EDataFlow, ERole, IAudioSessionControl2, IAudioSessionManager2, IMMDevice, IMMDeviceEnumerator, @@ -208,6 +208,10 @@ impl AudioBackend for WindowsAudioBackend { list_sessions_with_visuals(false) } + fn peak_levels(&self) -> Result { + collect_peak_levels() + } + fn list_playback_devices(&self) -> Result> { let _com = init_com()?; let enumerator = get_device_enumerator()?; @@ -1078,6 +1082,143 @@ fn get_endpoint_volume( Ok(endpoint) } +fn endpoint_peak_value(device: &IMMDevice) -> f32 { + let meter: IAudioMeterInformation = match unsafe { device.Activate(CLSCTX_ALL, None) } { + Ok(meter) => meter, + Err(_) => return 0.0, + }; + match unsafe { meter.GetPeakValue() } { + Ok(value) => value.clamp(0.0, 1.0), + Err(_) => 0.0, + } +} + +fn session_peak_value(control: &IAudioSessionControl2, simple: &ISimpleAudioVolume) -> f32 { + let muted = unsafe { simple.GetMute() } + .map(|value| value.as_bool()) + .unwrap_or(false); + if muted { + return 0.0; + } + let meter: IAudioMeterInformation = match control.cast() { + Ok(meter) => meter, + Err(_) => return 0.0, + }; + match unsafe { meter.GetPeakValue() } { + Ok(value) => value.clamp(0.0, 1.0), + Err(_) => 0.0, + } +} + +fn collect_peak_levels() -> Result { + let _com = init_com()?; + let enumerator = get_device_enumerator()?; + let default_device = get_default_device_from(&enumerator)?; + let default_device_id = device_id_string(&default_device); + let master = endpoint_peak_value(&default_device); + + let mut devices = HashMap::new(); + for (device, device_id) in enumerate_active_devices(&enumerator, eRender)? { + devices.insert(device_id, endpoint_peak_value(&device)); + } + + let mut sessions = HashMap::new(); + let mut session_matches = Vec::new(); + let mut seen_ids = HashSet::new(); + + for (device, device_id) in enumerate_active_devices(&enumerator, eRender)? { + let _ = visit_audio_sessions::<()>(&device, |control2, simple, process_id| { + let base_id = session_identifier(control2, process_id) + .unwrap_or_else(|| format!("pid:{}", process_id)); + let session_id = if default_device_id.as_deref() == Some(device_id.as_str()) { + base_id + } else { + format!("{}|{}", device_id, base_id) + }; + if !seen_ids.insert(session_id.clone()) { + return Ok(SessionVisit::Continue); + } + + let peak = session_peak_value(control2, simple); + let identity = query_effective_process_identity_cached(process_id); + let process_path = identity.path.clone(); + let process_name = process_path + .as_ref() + .and_then(|path| Path::new(path).file_name()) + .and_then(|name| name.to_str()) + .map(|name| name.to_string()); + let raw_display_name = unsafe { control2.GetDisplayName() } + .ok() + .and_then(owned_pwstr_to_string); + let display_name = session_display_name(raw_display_name.as_deref()); + let application_key = stable_application_key( + &identity, + process_path.as_deref(), + process_name.as_deref(), + display_name.as_deref(), + ); + let friendly_name = friendly_session_name( + display_name.as_deref(), + process_path.as_deref(), + process_name.as_deref(), + &identity, + ); + + if should_skip_session( + process_id, + &display_name, + &process_name, + &process_path, + &friendly_name, + &application_key, + &identity, + ) { + return Ok(SessionVisit::Continue); + } + + sessions.insert(session_id, peak); + session_matches.push(( + SessionPeakIdentity { + display_name: friendly_name, + application_key, + process_name, + process_path, + }, + peak, + )); + Ok(SessionVisit::Continue) + }); + } + + let focused_session_id = focused_session_peak_id(&default_device, default_device_id.as_deref()); + + Ok(AudioPeakLevels { + master, + devices, + sessions, + focused_session_id, + session_matches, + }) +} + +fn focused_session_peak_id(default_device: &IMMDevice, default_device_id: Option<&str>) -> Option { + let process_id = foreground_process_id()?; + let identity = query_effective_process_identity_cached(process_id); + let device_id = default_device_id?; + session_info_for_process( + default_device, + device_id, + Some(device_id), + process_id, + &identity, + &mut HashMap::new(), + false, + ) + .ok() + .flatten() + .map(|session| session.id) +} + fn get_session_manager( device: &windows::Win32::Media::Audio::IMMDevice, ) -> Result { diff --git a/src-tauri/src/background_tasks.rs b/src-tauri/src/background_tasks.rs index ac92a70..26bc9ff 100644 --- a/src-tauri/src/background_tasks.rs +++ b/src-tauri/src/background_tasks.rs @@ -1,5 +1,5 @@ use crate::bindings::BindingKey; -use crate::feedback; +use crate::feedback::{self, FeedbackControlKey}; use crate::midi_event_queue::log_queue_stats; use crate::model::{BindingTarget, Profile, SessionInfo}; use crate::run_logger; @@ -16,6 +16,7 @@ use tokio::time::sleep; const MIDI_QUEUE_BATCH_DELAY: Duration = Duration::from_millis(4); const FEEDBACK_SYNC_INTERVAL: Duration = Duration::from_millis(750); const FOCUS_FEEDBACK_SYNC_INTERVAL: Duration = Duration::from_millis(250); +const AUDIO_REACTIVE_LED_INTERVAL: Duration = Duration::from_millis(40); const FULL_FEEDBACK_RESEND_INTERVAL: Duration = Duration::from_secs(10); const LEARN_COMMIT_CHECK_INTERVAL: Duration = Duration::from_millis(25); const OSD_HIDE_CHECK_INTERVAL: Duration = Duration::from_millis(100); @@ -76,6 +77,13 @@ fn profile_has_focus_target(profile: &crate::model::Profile) -> bool { }) } +fn profile_has_audio_reactive_led(profile: &crate::model::Profile) -> bool { + profile + .bindings + .iter() + .any(|binding| binding.uses_audio_reactive_led()) +} + fn feedback_context_signature(profile: &Profile, active_routes: Vec<(String, String)>) -> String { let binding_ids = profile .bindings @@ -253,6 +261,11 @@ pub(crate) fn spawn_feedback_refresh_loop( .unwrap_or_else(Instant::now); let mut last_feedback_context = String::new(); let mut last_sent_feedback: HashMap = HashMap::new(); + let mut last_reactive_led_sync = Instant::now() + .checked_sub(AUDIO_REACTIVE_LED_INTERVAL) + .unwrap_or_else(Instant::now); + let mut reactive_led_state: HashMap = + HashMap::new(); loop { if shutdown_requested(&shutdown) { @@ -303,6 +316,10 @@ pub(crate) fn spawn_feedback_refresh_loop( .ok() .and_then(|profile| profile.clone()); let profile_active = profile.is_some(); + let has_audio_reactive_led = profile + .as_ref() + .map(|snapshot| profile_has_audio_reactive_led(snapshot)) + .unwrap_or(false); if let Some(profile) = profile.as_ref() { let sync_interval = if profile_has_focus_target(profile) { FOCUS_FEEDBACK_SYNC_INTERVAL @@ -323,6 +340,7 @@ pub(crate) fn spawn_feedback_refresh_loop( if context_changed { last_feedback_context = context; last_sent_feedback.clear(); + reactive_led_state.clear(); } let force_feedback_resend = context_changed @@ -352,20 +370,43 @@ pub(crate) fn spawn_feedback_refresh_loop( let key = BindingKey::from_binding(binding); if binding.feedback_enabled { if let Some(volume) = feedback_snapshot.get(&key).cloned() { - let output_key = - feedback::binding_feedback_control_key(binding) - .to_binding_key(); - if should_send_feedback( - &mut last_sent_feedback, - output_key, - volume, - force_feedback_resend, - ) { - if binding.is_button_binding() { - let _ = - midi.send_binding_light_feedback(binding, volume); - } else { - let _ = midi.send_binding_feedback(binding, volume); + if binding.uses_audio_reactive_led() { + if !binding.is_button_binding() + && binding.custom_feedback_output_control().is_some() + { + let primary_key = FeedbackControlKey::from_binding(binding) + .to_binding_key(); + if should_send_feedback( + &mut last_sent_feedback, + primary_key, + volume, + force_feedback_resend, + ) { + let _ = midi.send_feedback( + &binding.device_id, + binding.control.channel, + binding.control.controller, + volume, + binding.control.msg_type.clone(), + ); + } + } + } else { + let output_key = + feedback::binding_feedback_control_key(binding) + .to_binding_key(); + if should_send_feedback( + &mut last_sent_feedback, + output_key, + volume, + force_feedback_resend, + ) { + if binding.is_button_binding() { + let _ = midi + .send_binding_light_feedback(binding, volume); + } else { + let _ = midi.send_binding_feedback(binding, volume); + } } } } @@ -392,10 +433,49 @@ pub(crate) fn spawn_feedback_refresh_loop( } } } + + if has_audio_reactive_led + && last_reactive_led_sync.elapsed() >= AUDIO_REACTIVE_LED_INTERVAL + { + last_reactive_led_sync = loop_started; + let peaks = crate::led_feedback::collect_audio_peaks(state.audio.as_ref()); + if let Ok(mut midi) = state.midi.lock() { + for binding in &profile.bindings { + let output_control = + feedback::binding_feedback_control_key(binding); + let output_key = output_control.to_binding_key(); + let previous = reactive_led_state + .get(&output_key) + .copied() + .unwrap_or_default(); + let Some((level, next_state)) = + crate::led_feedback::reactive_led_level_for_binding( + binding, &peaks, previous, + ) + else { + continue; + }; + reactive_led_state.insert(output_key.clone(), next_state); + if should_send_feedback( + &mut last_sent_feedback, + output_key, + level, + false, + ) { + if binding.is_button_binding() { + let _ = midi.send_binding_light_feedback(binding, level); + } else { + let _ = midi.send_binding_feedback(binding, level); + } + } + } + } + } } else { last_focused_session = None; last_feedback_context.clear(); last_sent_feedback.clear(); + reactive_led_state.clear(); } if last_osd_hide_check.elapsed() >= OSD_HIDE_CHECK_INTERVAL { @@ -453,6 +533,8 @@ pub(crate) fn spawn_feedback_refresh_loop( let next_sleep = if learn_active { LEARN_COMMIT_CHECK_INTERVAL + } else if has_audio_reactive_led { + AUDIO_REACTIVE_LED_INTERVAL } else if osd_active { OSD_HIDE_CHECK_INTERVAL } else if active_profile_has_focus_target { diff --git a/src-tauri/src/binding_services/feedback_outputs.rs b/src-tauri/src/binding_services/feedback_outputs.rs index 53ddbea..b33e186 100644 --- a/src-tauri/src/binding_services/feedback_outputs.rs +++ b/src-tauri/src/binding_services/feedback_outputs.rs @@ -11,6 +11,24 @@ pub(super) fn send_resolved_binding_feedback( if !binding.feedback_enabled { return; } + if binding.uses_audio_reactive_led() { + if binding.is_button_binding() { + return; + } + if binding.custom_feedback_output_control().is_some() { + feedback::send_feedback_to_control( + state, + &FeedbackControlKey::from_binding(binding), + FeedbackSendOptions { + value, + silent, + force_hardware_feedback, + context, + }, + ); + } + return; + } if binding.is_button_binding() { feedback::send_button_light_feedback_to_binding( state, diff --git a/src-tauri/src/binding_services/feedback_updates.rs b/src-tauri/src/binding_services/feedback_updates.rs index 402e968..9933237 100644 --- a/src-tauri/src/binding_services/feedback_updates.rs +++ b/src-tauri/src/binding_services/feedback_updates.rs @@ -52,6 +52,9 @@ pub fn update_midi_feedback( } if binding.is_button_binding() { + if binding.uses_audio_reactive_led() { + continue; + } feedback::send_button_light_feedback_to_binding( state, binding, @@ -70,6 +73,22 @@ pub fn update_midi_feedback( continue; } + if binding.uses_audio_reactive_led() { + if binding.custom_feedback_output_control().is_some() { + feedback::send_feedback_to_control( + state, + &FeedbackControlKey::from_binding(binding), + FeedbackSendOptions { + value: feedback_value, + silent: false, + force_hardware_feedback: false, + context: &format!("target_feedback_primary:{}", binding.id), + }, + ); + } + continue; + } + feedback::send_feedback_to_binding( state, binding, diff --git a/src-tauri/src/commands/midi.rs b/src-tauri/src/commands/midi.rs index d21f0f1..c123782 100644 --- a/src-tauri/src/commands/midi.rs +++ b/src-tauri/src/commands/midi.rs @@ -5,11 +5,12 @@ use crate::midi_reconciliation::{ use crate::run_logger; use crate::{ midi::MidiConnectionHealth, - model::{DeviceInfo, MidiDeviceRoute, Profile}, + model::{DeviceInfo, MidiDeviceRoute, MidiMessageType, Profile}, AppState, }; use serde::Serialize; use std::sync::Arc; +use std::time::Duration; use tauri::{AppHandle, Emitter, Manager, State}; #[derive(Debug, Clone, Serialize)] @@ -457,6 +458,64 @@ pub fn consume_learned_control( Ok(next) } +#[tauri::command] +pub fn test_midi_feedback_output( + state: State<'_, AppState>, + device_id: String, + channel: u8, + controller: u8, + msg_type: String, +) -> Result<(), String> { + let device_id = device_id.trim().to_string(); + if device_id.is_empty() { + return Err("MIDI device is required to test LED feedback".to_string()); + } + let msg_type = match msg_type.as_str() { + "Note" => MidiMessageType::Note, + "PitchBend" => MidiMessageType::PitchBend, + "ControlChange" | "CC" => MidiMessageType::ControlChange, + "Disabled" => return Err("Choose a Note, CC, or Pitch Bend feedback type first".to_string()), + _ => MidiMessageType::ControlChange, + }; + if matches!(msg_type, MidiMessageType::ProgramChange) { + return Err("Program Change cannot drive LED feedback".to_string()); + } + let channel = channel.min(15); + let controller = if matches!(msg_type, MidiMessageType::PitchBend) { + 0 + } else { + controller.min(127) + }; + + { + let midi = state.midi.lock().map_err(|_| "Lock poisoned".to_string())?; + if midi.active_routes().is_empty() { + return Err("Connect a MIDI input/output route before testing LED feedback".to_string()); + } + } + + run_logger::info( + "midi_cmd", + "test_feedback_output", + &format!( + "device_id={} channel={} controller={} msg_type={:?}", + device_id, channel, controller, msg_type + ), + ); + + let midi = Arc::clone(&state.midi); + tauri::async_runtime::spawn(async move { + // Three clear on/off pulses so a mapped LED is obvious when the address is correct. + for value in [1.0_f32, 0.0, 1.0, 0.0, 1.0, 0.0] { + if let Ok(mut guard) = midi.lock() { + let _ = guard.send_feedback(&device_id, channel, controller, value, msg_type.clone()); + } + tokio::time::sleep(Duration::from_millis(140)).await; + } + }); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index be13811..c192b31 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -75,6 +75,7 @@ macro_rules! command_registry { import_profile_from_file, start_midi_learn, consume_learned_control, + test_midi_feedback_output, add_binding, remove_binding, update_midi_feedback, diff --git a/src-tauri/src/led_feedback.rs b/src-tauri/src/led_feedback.rs new file mode 100644 index 0000000..2fa4569 --- /dev/null +++ b/src-tauri/src/led_feedback.rs @@ -0,0 +1,255 @@ +use crate::audio::target_match::{application_name_matches, ApplicationMatchInfo}; +use crate::audio::{AudioBackend, AudioPeakLevels}; +use crate::device_target::{parse_device_target, DeviceTargetKind}; +use crate::model::{Binding, BindingTarget}; + +/// Decay applied to the previous LED level so flashes have a short visible tail. +pub const LED_ENVELOPE_DECAY: f32 = 0.78; +/// How quickly the adaptive baseline tracks steady metering (per ~40ms tick). +pub const LED_BASELINE_ALPHA: f32 = 0.08; + +#[derive(Debug, Clone, Copy, Default)] +pub struct ReactiveLedState { + pub level: f32, + pub baseline: f32, +} + +pub fn normalize_intensity(intensity: f32) -> f32 { + if intensity.is_finite() { + intensity.clamp(0.0, 2.0) + } else { + 1.0 + } +} + +pub fn normalize_peak(peak: f32) -> f32 { + if peak.is_finite() { + peak.clamp(0.0, 1.0) + } else { + 0.0 + } +} + +/// Map sensitivity (0..=2) onto a shaped LED drive for a single peak sample. +/// +/// Higher sensitivity: +/// - lifts mid/quiet peaks with a softer gamma curve +/// - amplifies short rises above a slow baseline so steady metering still flashes +pub fn scale_led_level(peak: f32, intensity: f32) -> f32 { + let peak = normalize_peak(peak); + let intensity = normalize_intensity(intensity); + if intensity <= 0.0 || peak <= 0.0 { + return 0.0; + } + // intensity 1 → near-linear; 2 → strong lift of quieter peaks; low → darker. + let gamma = (1.15 / intensity.max(0.12)).clamp(0.32, 3.8); + let shaped = peak.powf(gamma); + let gain = 0.55 + intensity * 0.85; + (shaped * gain).clamp(0.0, 1.0) +} + +/// Adaptive response: absolute shaped level + punch for peaks above the rolling baseline. +pub fn sensitive_led_drive(peak: f32, baseline: f32, intensity: f32) -> (f32, f32) { + let peak = normalize_peak(peak); + let intensity = normalize_intensity(intensity); + let baseline = normalize_peak(baseline); + + let next_baseline = if intensity <= 0.0 { + 0.0 + } else if baseline <= 0.001 { + peak + } else { + baseline + (peak - baseline) * LED_BASELINE_ALPHA + }; + + if intensity <= 0.0 { + return (0.0, next_baseline); + } + + let absolute = scale_led_level(peak, intensity); + // Excess above the recent average — this is what makes steady loud music still pulse. + let delta = (peak - next_baseline).max(0.0); + // Punch grows with the slider: ~100% is moderate, ~200% catches small mid-meter bumps. + let punch_gain = 1.0 + intensity * 2.5 + intensity * intensity * 3.5; + let punch = (delta * punch_gain).clamp(0.0, 1.0); + // Blend: higher sensitivity leans more on punch so micro-dynamics dominate. + let punch_mix = (0.3 + intensity * 0.35).clamp(0.3, 1.0); + let driven = (absolute * (1.0 - punch_mix * 0.5) + punch * punch_mix).clamp(0.0, 1.0); + (driven, next_baseline) +} + +pub fn envelope_led_level(previous: f32, drive: f32, decay: f32) -> f32 { + let drive = normalize_peak(drive); + let previous = normalize_peak(previous); + let decay = if decay.is_finite() { + decay.clamp(0.0, 1.0) + } else { + LED_ENVELOPE_DECAY + }; + drive.max(previous * decay) +} + +pub fn peak_for_target(target: &BindingTarget, peaks: &AudioPeakLevels) -> f32 { + match target { + BindingTarget::Master => peaks.master, + BindingTarget::Focus => peaks + .focused_session_id + .as_ref() + .and_then(|id| peaks.sessions.get(id).copied()) + .unwrap_or(0.0), + BindingTarget::Session { session_id } => { + peaks.sessions.get(session_id).copied().unwrap_or(0.0) + } + BindingTarget::Application { name, .. } => { + let mut peak = 0.0_f32; + for (info, session_peak) in &peaks.session_matches { + if application_name_matches( + name, + ApplicationMatchInfo { + process_path: info.process_path.as_deref(), + process_name: info.process_name.as_deref(), + display_name: Some(info.display_name.as_str()), + application_key: info.application_key.as_deref(), + friendly_process_label: None, + humanized_process_name: None, + package_family_name: None, + package_full_name: None, + application_user_model_id: None, + }, + ) { + peak = peak.max(*session_peak); + } + } + peak + } + BindingTarget::Device { device_id } => { + let (kind, raw_id) = parse_device_target(device_id); + match kind { + DeviceTargetKind::Playback => peaks.devices.get(raw_id).copied().unwrap_or(0.0), + DeviceTargetKind::Recording => 0.0, + } + } + _ => 0.0, + } +} + +pub fn reactive_led_level_for_binding( + binding: &Binding, + peaks: &AudioPeakLevels, + state: ReactiveLedState, +) -> Option<(f32, ReactiveLedState)> { + if !binding.uses_audio_reactive_led() { + return None; + } + let mut peak = 0.0_f32; + for target in binding.normalized_targets_ref() { + peak = peak.max(peak_for_target(target, peaks)); + } + let intensity = binding.normalized_led_intensity(); + let (drive, baseline) = sensitive_led_drive(peak, state.baseline, intensity); + let level = envelope_led_level(state.level, drive, LED_ENVELOPE_DECAY); + Some(( + level, + ReactiveLedState { + level, + baseline, + }, + )) +} + +pub fn collect_audio_peaks(audio: &dyn AudioBackend) -> AudioPeakLevels { + audio.peak_levels().unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::model::{BindingAction, BindingTarget}; + use crate::test_support::binding; + use std::collections::HashMap; + + #[test] + fn intensity_lifts_mid_peaks_non_linearly() { + let linearish = scale_led_level(0.35, 1.0); + let sensitive = scale_led_level(0.35, 2.0); + assert!(sensitive > linearish + 0.08); + assert!((scale_led_level(0.1, 0.0) - 0.0).abs() < 0.0001); + assert!(scale_led_level(0.8, 2.0) >= 0.95); + } + + #[test] + fn sensitivity_amplifies_rises_above_steady_meter() { + // Steady metering around 0.45, then a small bump — high sensitivity should flash harder. + let baseline = 0.45; + let bump = 0.52; + let (_, next_base_low) = sensitive_led_drive(bump, baseline, 0.5); + let (low, _) = sensitive_led_drive(bump, baseline, 0.5); + let (high, next_base_high) = sensitive_led_drive(bump, baseline, 2.0); + assert!(high > low + 0.2); + assert!(next_base_low > 0.0); + assert!(next_base_high > 0.0); + } + + #[test] + fn envelope_holds_brief_pulse_tail() { + let first = envelope_led_level(0.0, 1.0, 0.8); + assert!((first - 1.0).abs() < 0.0001); + let second = envelope_led_level(first, 0.0, 0.8); + assert!((second - 0.8).abs() < 0.0001); + let third = envelope_led_level(second, 0.1, 0.8); + assert!((third - 0.64).abs() < 0.0001); + } + + #[test] + fn peak_for_master_and_application_targets() { + let peaks = AudioPeakLevels { + master: 0.55, + devices: HashMap::new(), + sessions: HashMap::from([("chrome".into(), 0.2)]), + focused_session_id: None, + session_matches: vec![( + crate::audio::SessionPeakIdentity { + display_name: "Chrome".into(), + application_key: Some("chrome".into()), + process_name: Some("chrome.exe".into()), + process_path: None, + }, + 0.7, + )], + }; + assert!((peak_for_target(&BindingTarget::Master, &peaks) - 0.55).abs() < 0.0001); + assert!( + (peak_for_target( + &BindingTarget::Application { + name: "chrome".into(), + display_name: None, + icon_data: None, + }, + &peaks + ) - 0.7) + .abs() + < 0.0001 + ); + } + + #[test] + fn reactive_level_respects_mode_and_intensity() { + let mut binding = binding(); + binding.action = BindingAction::Volume; + binding.targets = vec![BindingTarget::Master]; + binding.led_feedback_mode = crate::model::LedFeedbackMode::FollowValue; + let peaks = AudioPeakLevels { + master: 0.5, + ..AudioPeakLevels::default() + }; + assert!(reactive_led_level_for_binding(&binding, &peaks, ReactiveLedState::default()).is_none()); + + binding.led_feedback_mode = crate::model::LedFeedbackMode::AudioReactive; + binding.led_intensity = 2.0; + let (value, state) = + reactive_led_level_for_binding(&binding, &peaks, ReactiveLedState::default()) + .expect("reactive"); + assert!(value > 0.4); + assert!(state.baseline > 0.0); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dc0023f..9b63ca7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -15,6 +15,7 @@ mod device_target; mod durable_json_store; mod fader_curve; mod feedback; +mod led_feedback; mod midi; mod midi_event_queue; mod midi_reconciliation; diff --git a/src-tauri/src/model.rs b/src-tauri/src/model.rs index d00b1e4..8dabd93 100644 --- a/src-tauri/src/model.rs +++ b/src-tauri/src/model.rs @@ -12,9 +12,9 @@ pub use binding_types::{ normalize_macro_draft_steps, normalize_macro_steps, AssignMode, AutoHotkeyScriptMapping, AuxiliaryControl, Binding, BindingAction, BindingControlKind, BindingTarget, BindingTargetFeedbackSource, ButtonLightBehavior, ButtonLightMode, FaderCurve, FaderCurvePoint, - HotkeyMapping, MacroActionState, MacroActionStep, MacroStep, MidiMode, MuteBehavior, - OpenApplicationMapping, RelativeFormat, SoundboardMapping, MACRO_MAX_PARALLEL_STEPS, - MACRO_MAX_TOP_LEVEL_STEPS, MACRO_MAX_WAIT_MS, + HotkeyMapping, LedFeedbackMode, MacroActionState, MacroActionStep, MacroStep, MidiMode, + MuteBehavior, OpenApplicationMapping, RelativeFormat, SoundboardMapping, + MACRO_MAX_PARALLEL_STEPS, MACRO_MAX_TOP_LEVEL_STEPS, MACRO_MAX_WAIT_MS, }; #[allow(unused_imports)] pub use midi_types::{ diff --git a/src-tauri/src/model/binding_types.rs b/src-tauri/src/model/binding_types.rs index e3e2e73..1e6a4b4 100644 --- a/src-tauri/src/model/binding_types.rs +++ b/src-tauri/src/model/binding_types.rs @@ -1,5 +1,5 @@ pub use super::action_mappings::*; -use super::control_types::default_feedback_enabled; +use super::control_types::{default_feedback_enabled, default_led_intensity}; pub use super::control_types::*; pub use super::macro_types::*; use super::midi_types::{MidiControl, MidiMessageType}; @@ -41,6 +41,10 @@ pub struct Binding { #[serde(default = "default_feedback_enabled")] pub feedback_enabled: bool, #[serde(default)] + pub led_feedback_mode: LedFeedbackMode, + #[serde(default = "default_led_intensity")] + pub led_intensity: f32, + #[serde(default)] pub indicator_control: Option, #[serde(default)] pub mute_control: Option, @@ -143,6 +147,17 @@ impl Binding { )) } + pub fn uses_audio_reactive_led(&self) -> bool { + self.feedback_enabled && self.led_feedback_mode.is_audio_reactive() + } + + pub fn normalized_led_intensity(&self) -> f32 { + if !self.led_intensity.is_finite() { + return 1.0; + } + self.led_intensity.clamp(0.0, 2.0) + } + pub fn mapped_button_light_feedback_value(&self) -> Option { self.mapped_button_light_feedback_value_with_availability(|_| true) } @@ -153,6 +168,7 @@ impl Binding { ) -> Option { if !self.feedback_enabled || !self.is_button_binding() + || self.uses_audio_reactive_led() || !matches!(&self.button_light_mode, ButtonLightMode::MappedWhenAssigned) { return None; @@ -207,6 +223,9 @@ impl Binding { if !self.feedback_enabled || !self.is_button_binding() { return None; } + if self.uses_audio_reactive_led() { + return None; + } if let Some(value) = self.mapped_button_light_feedback_value() { return Some(value); diff --git a/src-tauri/src/model/control_types.rs b/src-tauri/src/model/control_types.rs index 77cd519..ef0346c 100644 --- a/src-tauri/src/model/control_types.rs +++ b/src-tauri/src/model/control_types.rs @@ -80,6 +80,23 @@ pub(super) fn default_feedback_enabled() -> bool { true } +pub(super) fn default_led_intensity() -> f32 { + 1.0 +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum LedFeedbackMode { + #[default] + FollowValue, + AudioReactive, +} + +impl LedFeedbackMode { + pub fn is_audio_reactive(&self) -> bool { + matches!(self, Self::AudioReactive) + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] pub enum RelativeFormat { #[default] diff --git a/src-tauri/src/model/tests.rs b/src-tauri/src/model/tests.rs index 8264ad1..48e0fe1 100644 --- a/src-tauri/src/model/tests.rs +++ b/src-tauri/src/model/tests.rs @@ -639,6 +639,25 @@ fn deserialize_binding_defaults_mute_behavior_to_toggle_on_press() { assert_eq!(binding.mute_behavior, MuteBehavior::ToggleOnPress); } +#[test] +fn deserialize_binding_defaults_led_feedback_mode_to_follow_value() { + let binding: Binding = + serde_json::from_value(binding_base_json()).expect("binding should deserialize"); + assert_eq!(binding.led_feedback_mode, LedFeedbackMode::FollowValue); + assert!((binding.led_intensity - 1.0).abs() < f32::EPSILON); + assert!(!binding.uses_audio_reactive_led()); +} + +#[test] +fn audio_reactive_led_uses_intensity_clamp() { + let mut binding: Binding = + serde_json::from_value(binding_base_json()).expect("binding should deserialize"); + binding.led_feedback_mode = LedFeedbackMode::AudioReactive; + binding.led_intensity = 3.5; + assert!(binding.uses_audio_reactive_led()); + assert!((binding.normalized_led_intensity() - 2.0).abs() < f32::EPSILON); +} + #[test] fn deserialize_binding_defaults_button_light_mode_to_activity() { let binding: Binding = diff --git a/src-tauri/src/test_support.rs b/src-tauri/src/test_support.rs index a68690d..81b0c3e 100644 --- a/src-tauri/src/test_support.rs +++ b/src-tauri/src/test_support.rs @@ -26,6 +26,8 @@ pub fn binding() -> Binding { button_light_mode: model::ButtonLightMode::Activity, button_light_behavior: model::ButtonLightBehavior::FollowState, feedback_enabled: true, + led_feedback_mode: model::LedFeedbackMode::FollowValue, + led_intensity: 1.0, indicator_control: None, mute_control: None, assign_control: None, diff --git a/src/app/dom_refs.js b/src/app/dom_refs.js index c3219fd..b0f7da1 100644 --- a/src/app/dom_refs.js +++ b/src/app/dom_refs.js @@ -37,12 +37,17 @@ export const DOM_REF_IDS = Object.freeze({ bindingConfigButtonLightSection: "binding-config-button-light-section", bindingConfigButtonLightSelectRow: "binding-config-button-light-select-row", bindingConfigButtonLightSelect: "binding-config-button-light-select", + bindingConfigLedIntensityRow: "binding-config-led-intensity-row", + bindingConfigLedIntensity: "binding-config-led-intensity", + bindingConfigLedIntensityValue: "binding-config-led-intensity-value", bindingConfigIndicatorCustom: "binding-config-indicator-custom", bindingConfigIndicatorMsgType: "binding-config-indicator-msg-type", bindingConfigIndicatorChannel: "binding-config-indicator-channel", bindingConfigIndicatorController: "binding-config-indicator-controller", bindingConfigIndicatorLearn: "binding-config-indicator-learn", bindingConfigIndicatorClear: "binding-config-indicator-clear", + bindingConfigIndicatorTest: "binding-config-indicator-test", + bindingConfigIndicatorTestStatus: "binding-config-indicator-test-status", bindingConfigButtonLearnSection: "binding-config-button-learn-section", bindingConfigButtonLearnButton: "binding-config-button-learn-button", bindingConfigButtonLearnIndicator: "binding-config-button-learn-indicator", @@ -78,12 +83,19 @@ export const DOM_REF_IDS = Object.freeze({ bindingConfigSoundboardVirtualMic: "binding-config-soundboard-virtual-mic", bindingConfigCurveSection: "binding-config-curve-section", bindingConfigFeedbackOutputSection: "binding-config-feedback-output-section", + bindingConfigLedModeRow: "binding-config-led-mode-row", + bindingConfigLedModeSelect: "binding-config-led-mode-select", + bindingConfigFeedbackLedIntensityRow: "binding-config-feedback-led-intensity-row", + bindingConfigFeedbackLedIntensity: "binding-config-feedback-led-intensity", + bindingConfigFeedbackLedIntensityValue: "binding-config-feedback-led-intensity-value", bindingConfigFeedbackOutputCustom: "binding-config-feedback-output-custom", bindingConfigFeedbackMsgType: "binding-config-feedback-msg-type", bindingConfigFeedbackChannel: "binding-config-feedback-channel", bindingConfigFeedbackController: "binding-config-feedback-controller", bindingConfigFeedbackLearn: "binding-config-feedback-learn", bindingConfigFeedbackClear: "binding-config-feedback-clear", + bindingConfigFeedbackTest: "binding-config-feedback-test", + bindingConfigFeedbackTestStatus: "binding-config-feedback-test-status", bindingConfigMuteSection: "binding-config-mute-section", bindingConfigAssignSection: "binding-config-assign-section", bindingConfigMuteLabel: "binding-config-mute-label", diff --git a/src/core/binding_model.js b/src/core/binding_model.js index 19f9c04..2c411bc 100644 --- a/src/core/binding_model.js +++ b/src/core/binding_model.js @@ -535,5 +535,8 @@ export function normalizeButtonLightFields(binding) { binding.button_light_mode = "Activity"; binding.button_light_behavior = normalizeButtonLightBehavior(binding.button_light_behavior); } + binding.led_feedback_mode = binding.led_feedback_mode === "AudioReactive" ? "AudioReactive" : "FollowValue"; + const intensity = Number(binding.led_intensity); + binding.led_intensity = Number.isFinite(intensity) ? Math.min(2, Math.max(0, intensity)) : 1; return binding; } diff --git a/src/features/bindings/bindings.js b/src/features/bindings/bindings.js index 053aba2..09a0718 100644 --- a/src/features/bindings/bindings.js +++ b/src/features/bindings/bindings.js @@ -371,12 +371,15 @@ export function createBindingsFeature({ syncFeedbackControllerInputState, syncIndicatorUi, syncFeedbackOutputUi, + testLedOutput, updateIndicatorFromFields, updateFeedbackOutputFromFields, + updateLedIntensityFromInput, } = createFeedbackEditor({ elements, editorState, getConfigBinding: (...args) => getConfigBinding(...args), + invoke, listState, renderConfigPreview: (...args) => renderConfigPreview(...args), t, @@ -778,8 +781,10 @@ export function createBindingsFeature({ t, updateAuxLearnUi, updateCustomCurveFromPointer, + testLedOutput, updateFeedbackOutputFromFields, updateIndicatorFromFields, + updateLedIntensityFromInput, updateMacroDrag, }); diff --git a/src/features/bindings/controllers/config_events.js b/src/features/bindings/controllers/config_events.js index 1a2a041..d179059 100644 --- a/src/features/bindings/controllers/config_events.js +++ b/src/features/bindings/controllers/config_events.js @@ -48,10 +48,12 @@ export function createConfigEvents({ syncIndicatorUi, syncMuteModeUi, t, + testLedOutput, updateAuxLearnUi, updateCustomCurveFromPointer, updateFeedbackOutputFromFields, updateIndicatorFromFields, + updateLedIntensityFromInput, updateMacroDrag, }) { function bindConfigModalUi() { @@ -84,6 +86,13 @@ export function createConfigEvents({ title: t("bindings.feedbackMessageType"), }); } + if (elements.bindingConfigLedModeSelect && !listState.ledModeDropdown) { + listState.ledModeDropdown = createSelectDropdownShell({ + selectEl: elements.bindingConfigLedModeSelect, + rootClass: "binding-config-light-dropdown settings-select-dropdown", + title: t("bindings.ledMode"), + }); + } renderIndicatorDropdowns(); if (elements.bindingConfigPanel) { @@ -124,12 +133,19 @@ export function createConfigEvents({ const nextMode = elements.bindingConfigButtonLightSelect.value; if (nextMode === "Disabled") { binding.feedback_enabled = false; + binding.led_feedback_mode = "FollowValue"; + } else if (nextMode === "AudioReactive") { + binding.feedback_enabled = true; + binding.led_feedback_mode = "AudioReactive"; + if (binding.led_intensity == null) binding.led_intensity = 1; } else if (nextMode === "MappedWhenAssigned") { binding.feedback_enabled = true; + binding.led_feedback_mode = "FollowValue"; binding.button_light_mode = "MappedWhenAssigned"; binding.button_light_behavior = normalizeButtonLightBehavior(binding.button_light_behavior); } else { binding.feedback_enabled = true; + binding.led_feedback_mode = "FollowValue"; binding.button_light_mode = "Activity"; binding.button_light_behavior = normalizeButtonLightBehavior(nextMode); } @@ -138,6 +154,41 @@ export function createConfigEvents({ renderConfigPreview(); }); } + if (elements.bindingConfigLedModeSelect) { + lifetime.listen(elements.bindingConfigLedModeSelect, "change", () => { + const binding = getConfigBinding(); + if (!binding) return; + binding.led_feedback_mode = + elements.bindingConfigLedModeSelect.value === "AudioReactive" ? "AudioReactive" : "FollowValue"; + if (binding.led_feedback_mode === "AudioReactive" && binding.feedback_enabled === false) { + binding.feedback_enabled = true; + } + if (binding.led_intensity == null) binding.led_intensity = 1; + syncFeedbackOutputUi(binding); + updateAuxLearnUi(); + renderConfigPreview(); + }); + } + if (elements.bindingConfigLedIntensity) { + lifetime.listen(elements.bindingConfigLedIntensity, "input", () => { + updateLedIntensityFromInput(elements.bindingConfigLedIntensity); + }); + } + if (elements.bindingConfigFeedbackLedIntensity) { + lifetime.listen(elements.bindingConfigFeedbackLedIntensity, "input", () => { + updateLedIntensityFromInput(elements.bindingConfigFeedbackLedIntensity); + }); + } + if (elements.bindingConfigFeedbackTest) { + lifetime.listen(elements.bindingConfigFeedbackTest, "click", () => { + testLedOutput("feedback").catch((err) => console.error("LED test failed:", err)); + }); + } + if (elements.bindingConfigIndicatorTest) { + lifetime.listen(elements.bindingConfigIndicatorTest, "click", () => { + testLedOutput("indicator").catch((err) => console.error("LED test failed:", err)); + }); + } lifetime.listen(elements.bindingConfigIndicatorMsgType, "change", updateIndicatorFromFields); lifetime.listen(elements.bindingConfigIndicatorChannel, "input", updateIndicatorFromFields); lifetime.listen(elements.bindingConfigIndicatorController, "input", updateIndicatorFromFields); diff --git a/src/features/bindings/controllers/feedback_editor.js b/src/features/bindings/controllers/feedback_editor.js index 065f15a..168dcb6 100644 --- a/src/features/bindings/controllers/feedback_editor.js +++ b/src/features/bindings/controllers/feedback_editor.js @@ -7,15 +7,21 @@ export function createFeedbackEditor({ elements, editorState, getConfigBinding, + invoke, listState, renderConfigPreview, t, updateAuxLearnUi, }) { + let ledTestTimer = 0; + let ledTestInFlight = false; function buttonLightOptionText(value) { if (value === "Disabled") { return t("bindings.feedbackDisabled"); } + if (value === "AudioReactive") { + return t("bindings.ledMusicReactive"); + } const mode = value === "MappedWhenAssigned" ? "MappedWhenAssigned" : normalizeButtonLightBehavior(value); switch (mode) { case "MappedWhenAssigned": @@ -33,9 +39,66 @@ export function createFeedbackEditor({ function buttonLightSelectValue(binding) { if (binding?.feedback_enabled === false) return "Disabled"; + if (binding?.led_feedback_mode === "AudioReactive") return "AudioReactive"; return effectiveButtonLightMode(binding); } + function normalizeLedIntensity(raw) { + const number = Number(raw); + if (!Number.isFinite(number)) return 1; + return Math.min(2, Math.max(0, number)); + } + + function ledIntensityPercent(binding) { + return Math.round(normalizeLedIntensity(binding?.led_intensity) * 100); + } + + function syncLedIntensityUi(binding, options = {}) { + const reactive = binding?.feedback_enabled !== false && binding?.led_feedback_mode === "AudioReactive"; + const buttonRow = elements.bindingConfigLedIntensityRow; + const feedbackRow = elements.bindingConfigFeedbackLedIntensityRow; + const showButton = options.forButton === true && reactive; + const showFeedback = options.forFeedback === true && reactive; + if (buttonRow) buttonRow.classList.toggle("hidden", !showButton); + if (feedbackRow) feedbackRow.classList.toggle("hidden", !showFeedback); + const percent = String(ledIntensityPercent(binding)); + if (elements.bindingConfigLedIntensity) elements.bindingConfigLedIntensity.value = percent; + if (elements.bindingConfigLedIntensityValue) elements.bindingConfigLedIntensityValue.textContent = `${percent}%`; + if (elements.bindingConfigFeedbackLedIntensity) elements.bindingConfigFeedbackLedIntensity.value = percent; + if (elements.bindingConfigFeedbackLedIntensityValue) { + elements.bindingConfigFeedbackLedIntensityValue.textContent = `${percent}%`; + } + } + + function updateLedIntensityFromInput(inputEl) { + const binding = getConfigBinding(); + if (!binding || !inputEl) return; + binding.led_intensity = normalizeLedIntensity((Number(inputEl.value) || 0) / 100); + syncLedIntensityUi(binding, { + forButton: Boolean(elements.bindingConfigButtonLightSection && !elements.bindingConfigButtonLightSection.classList.contains("hidden")), + forFeedback: Boolean(elements.bindingConfigFeedbackOutputSection && !elements.bindingConfigFeedbackOutputSection.classList.contains("hidden")), + }); + renderConfigPreview(); + } + + function syncLedModeUi(binding) { + if (elements.bindingConfigLedModeSelect) { + elements.bindingConfigLedModeSelect.value = + binding?.led_feedback_mode === "AudioReactive" ? "AudioReactive" : "FollowValue"; + } + if (listState.ledModeDropdown && elements.bindingConfigLedModeSelect) { + renderNativeSelectDropdown({ + entry: listState.ledModeDropdown, + selectEl: elements.bindingConfigLedModeSelect, + fallbackText: t("bindings.ledFollowValue"), + formatOptionText: (option) => + option.value === "AudioReactive" ? t("bindings.ledMusicReactive") : t("bindings.ledFollowValue"), + truncateDisplayLabel: false, + }); + } + syncLedIntensityUi(binding, { forFeedback: true }); + } + function renderButtonLightDropdown() { if (!listState.buttonLightDropdown || !elements.bindingConfigButtonLightSelect) return; renderNativeSelectDropdown({ @@ -179,6 +242,7 @@ export function createFeedbackEditor({ if (elements.bindingConfigIndicatorController) elements.bindingConfigIndicatorController.value = String(control?.controller ?? 0); renderIndicatorDropdowns(); + syncLedIntensityUi(binding, { forButton: true }); } function syncFeedbackOutputUi(binding, options = {}) { @@ -212,6 +276,7 @@ export function createFeedbackEditor({ feedbackDisabled, ); renderIndicatorDropdowns(); + syncLedModeUi(binding); } function updateIndicatorFromFields() { @@ -273,6 +338,92 @@ export function createFeedbackEditor({ renderConfigPreview(); } + function setLedTestStatus(kind, message) { + for (const statusEl of [ + elements.bindingConfigFeedbackTestStatus, + elements.bindingConfigIndicatorTestStatus, + ]) { + if (!statusEl) continue; + statusEl.textContent = message || ""; + statusEl.classList.toggle("is-error", kind === "error"); + } + } + + function setLedTestBusy(busy) { + ledTestInFlight = busy; + for (const button of [elements.bindingConfigFeedbackTest, elements.bindingConfigIndicatorTest]) { + if (!button) continue; + button.disabled = busy || Boolean(editorState.learnField) || Boolean(editorState.transferPrompt); + button.classList.toggle("is-testing", busy); + } + } + + async function testLedOutput(source = "feedback") { + const binding = getConfigBinding(); + if (!binding || typeof invoke !== "function") return; + if (ledTestInFlight) return; + + if (source === "indicator") { + updateIndicatorFromFields(); + } else { + updateFeedbackOutputFromFields(); + } + const latest = getConfigBinding(); + if (!latest) return; + + const msgType = + source === "indicator" + ? elements.bindingConfigIndicatorMsgType?.value || latest.indicator_control?.msg_type || "Note" + : elements.bindingConfigFeedbackMsgType?.value || latest.indicator_control?.msg_type || "ControlChange"; + if (msgType === "Disabled") { + setLedTestStatus("error", t("bindings.testLedFailed", { error: t("bindings.feedbackDisabled") })); + return; + } + + const channelRaw = + source === "indicator" + ? elements.bindingConfigIndicatorChannel?.value + : elements.bindingConfigFeedbackChannel?.value; + const controllerRaw = + source === "indicator" + ? elements.bindingConfigIndicatorController?.value + : elements.bindingConfigFeedbackController?.value; + const channel = clampMidiNumber((Number(channelRaw) || 1) - 1, 0, 15, 0); + const controller = + msgType === "PitchBend" ? 0 : clampMidiNumber(controllerRaw, 0, 127, latest.control?.controller ?? 0); + const deviceId = String(latest.indicator_control?.device_id || latest.device_id || "").trim(); + if (!deviceId) { + setLedTestStatus("error", t("bindings.testLedFailed", { error: "Missing MIDI device" })); + return; + } + + setLedTestBusy(true); + setLedTestStatus("info", t("bindings.testLedTesting")); + if (ledTestTimer) window.clearTimeout(ledTestTimer); + try { + // Tauri command args are camelCase on the wire (device_id → deviceId). + await invoke("test_midi_feedback_output", { + deviceId, + channel, + controller, + msgType, + }); + setLedTestStatus("info", t("bindings.testLedDone")); + ledTestTimer = window.setTimeout(() => { + setLedTestStatus("info", ""); + ledTestTimer = 0; + }, 1800); + } catch (error) { + const raw = error?.message || String(error || "Unknown error"); + // Prefer the human-readable tail of Tauri IPC errors over the full arg dump. + const message = raw.includes(": ") ? raw.slice(raw.lastIndexOf(": ") + 2).trim() || raw : raw; + setLedTestStatus("error", t("bindings.testLedFailed", { error: message })); + } finally { + // Keep the button busy for the full pulse sequence so users don't stack tests. + window.setTimeout(() => setLedTestBusy(false), 900); + } + } + return { buttonLightSelectValue, renderButtonLightDropdown, @@ -281,7 +432,11 @@ export function createFeedbackEditor({ syncFeedbackControllerInputState, syncIndicatorUi, syncFeedbackOutputUi, + syncLedModeUi, + syncLedIntensityUi, + testLedOutput, updateIndicatorFromFields, updateFeedbackOutputFromFields, + updateLedIntensityFromInput, }; } diff --git a/src/features/bindings/controllers/midi_learn.js b/src/features/bindings/controllers/midi_learn.js index 4cefc0e..45b3410 100644 --- a/src/features/bindings/controllers/midi_learn.js +++ b/src/features/bindings/controllers/midi_learn.js @@ -75,6 +75,13 @@ export function createMidiLearn({ if (assignClear) assignClear.disabled = lockClear; if (indicatorClear) indicatorClear.disabled = lockClear || buttonFeedbackDisabled; if (feedbackClear) feedbackClear.disabled = lockClear; + if (elements.bindingConfigFeedbackTest) { + elements.bindingConfigFeedbackTest.disabled = + lockClear || feedbackDisabled || elements.bindingConfigFeedbackMsgType?.value === "Disabled"; + } + if (elements.bindingConfigIndicatorTest) { + elements.bindingConfigIndicatorTest.disabled = lockClear || buttonFeedbackDisabled; + } if (elements.bindingConfigIndicatorMsgType) elements.bindingConfigIndicatorMsgType.disabled = lockClear || buttonFeedbackDisabled; if (elements.bindingConfigIndicatorChannel) diff --git a/src/features/bindings/template.js b/src/features/bindings/template.js index c3c811f..e01f8cf 100644 --- a/src/features/bindings/template.js +++ b/src/features/bindings/template.js @@ -35,8 +35,19 @@ export const bindingsTemplate = `
When On + +
Indicator output @@ -73,6 +84,12 @@ export const bindingsTemplate = `
+ + +
diff --git a/src/locales/.i18n-meta.json b/src/locales/.i18n-meta.json index 673eb99..032dd42 100644 --- a/src/locales/.i18n-meta.json +++ b/src/locales/.i18n-meta.json @@ -549,7 +549,7 @@ "bindings.curvePresetLimitTitle": "b33a8e7cbaed93c7eeb49c54b4a12612c7fa20bee0a2520312a3d0f3fea53394", "bindings.curvePresetLimitMessage": "1b3c21265d97324dbc5345108e1ea4358e6e45e8726f2a399ed46d5090037911", "bindings.feedbackOutput": "18a90388ccba3cfbe9d0b7b37e74ad1239215a09b41c51ecc999486674c97528", - "bindings.feedbackOutputHelp": "96764e2359e9d5b72d27eca432f50ab883641f0271493c8e0bebe737052e1199", + "bindings.feedbackOutputHelp": "b0ec3a985b218f4d39cc8835ef15895464050dcb43f4c506c3963634fa66d92c", "bindings.feedbackMessageType": "122d60a966c83521205723b6dbed144d944bea93e0724cd15442206429e2e0eb", "bindings.feedbackChannelLabel": "cf0742ac1ad150bacbf7c980c7f93dc770afb1dfd054615f18b68fde2f47a85f", "bindings.feedbackControlLabel": "7ca8405192dd40c79ae4be7e773355f582534b968090ce8af43f7d16c889d8fb", @@ -683,7 +683,18 @@ "virtualAudio.blockedUnsafeVersion": "858883278e55cfe3ffbeadabc9eb288ed4847d49c9f88f876fd00dfc5a78d6d0", "virtualAudio.blockedUnknownVersion": "39529aa8adce738e281f3ba1c1ae94eef99a60be3aba0200058539607a750d1f", "virtualAudio.copyDiagnostics": "87a3f848e997fe63746507a102205aa488043077ff797741494d1fadc77cbe6a", - "virtualAudio.diagnosticsCopied": "18e9da0fd761b965e5cd4c3e7adcfe7e6fb9f691484c06c98a894c6dea57c077" + "virtualAudio.diagnosticsCopied": "18e9da0fd761b965e5cd4c3e7adcfe7e6fb9f691484c06c98a894c6dea57c077", + "bindings.ledMode": "8b797e51f1310507dcdfef4cf8dc8db164afe34a3ad5595e454bb0b24e6c18cb", + "bindings.ledModeHelp": "e1fdb51d3321ded27e44b28d52acea95e94aa578e23fcd3dde0e8590322e94b1", + "bindings.ledFollowValue": "c03c266e971f4a504e7dd0aa4166d73ea6c91fce08d035815b7e63b43747f32f", + "bindings.ledMusicReactive": "4187beb90a5e4423eb405f60ad6849231d34f76f1c8bdcd4eef222460282c18a", + "bindings.ledIntensity": "37aede6ebb44595af3d3f6a85cf90c3c8430de5a66de6e503de1e047ae3397a4", + "bindings.ledIntensityHelp": "644d876e9a276e7ff74d0d4eeee5351ab05a462f2325f8e7d4ae3697111e6659", + "bindings.testLed": "80ccc51db9708fb9e6e053877a81fca717ca0348be079cf746f8446a878bd083", + "bindings.testLedHelp": "76129f95c4917ccc81da4a9cafa5523856933aadff158bbfd4c8023ebd4d1c66", + "bindings.testLedTesting": "f411c8dfb627c9040c05ca2800031645dfdd82bb1dab0c7b46d950781f760972", + "bindings.testLedDone": "1faf9a55f725b69eeaea74eea513851695809fc837fad490983c5f70b4ea75b6", + "bindings.testLedFailed": "da6748dec806bdb84ed0e2bd1c1e41658063fc636e7e9c3a26169543cd0445a1" }, "es": { "bindings.feedbackDisabled": "75081b593d15cf6e631971bc6768723f593b88b172477e40ae7d363e4829816d", @@ -1235,7 +1246,7 @@ "bindings.curvePresetLimitTitle": "b33a8e7cbaed93c7eeb49c54b4a12612c7fa20bee0a2520312a3d0f3fea53394", "bindings.curvePresetLimitMessage": "1b3c21265d97324dbc5345108e1ea4358e6e45e8726f2a399ed46d5090037911", "bindings.feedbackOutput": "18a90388ccba3cfbe9d0b7b37e74ad1239215a09b41c51ecc999486674c97528", - "bindings.feedbackOutputHelp": "96764e2359e9d5b72d27eca432f50ab883641f0271493c8e0bebe737052e1199", + "bindings.feedbackOutputHelp": "b0ec3a985b218f4d39cc8835ef15895464050dcb43f4c506c3963634fa66d92c", "bindings.feedbackMessageType": "122d60a966c83521205723b6dbed144d944bea93e0724cd15442206429e2e0eb", "bindings.feedbackChannelLabel": "cf0742ac1ad150bacbf7c980c7f93dc770afb1dfd054615f18b68fde2f47a85f", "bindings.feedbackControlLabel": "7ca8405192dd40c79ae4be7e773355f582534b968090ce8af43f7d16c889d8fb", @@ -1369,7 +1380,18 @@ "virtualAudio.blockedUnsafeVersion": "858883278e55cfe3ffbeadabc9eb288ed4847d49c9f88f876fd00dfc5a78d6d0", "virtualAudio.blockedUnknownVersion": "39529aa8adce738e281f3ba1c1ae94eef99a60be3aba0200058539607a750d1f", "virtualAudio.copyDiagnostics": "87a3f848e997fe63746507a102205aa488043077ff797741494d1fadc77cbe6a", - "virtualAudio.diagnosticsCopied": "18e9da0fd761b965e5cd4c3e7adcfe7e6fb9f691484c06c98a894c6dea57c077" + "virtualAudio.diagnosticsCopied": "18e9da0fd761b965e5cd4c3e7adcfe7e6fb9f691484c06c98a894c6dea57c077", + "bindings.ledMode": "8b797e51f1310507dcdfef4cf8dc8db164afe34a3ad5595e454bb0b24e6c18cb", + "bindings.ledModeHelp": "e1fdb51d3321ded27e44b28d52acea95e94aa578e23fcd3dde0e8590322e94b1", + "bindings.ledFollowValue": "c03c266e971f4a504e7dd0aa4166d73ea6c91fce08d035815b7e63b43747f32f", + "bindings.ledMusicReactive": "4187beb90a5e4423eb405f60ad6849231d34f76f1c8bdcd4eef222460282c18a", + "bindings.ledIntensity": "37aede6ebb44595af3d3f6a85cf90c3c8430de5a66de6e503de1e047ae3397a4", + "bindings.ledIntensityHelp": "644d876e9a276e7ff74d0d4eeee5351ab05a462f2325f8e7d4ae3697111e6659", + "bindings.testLed": "80ccc51db9708fb9e6e053877a81fca717ca0348be079cf746f8446a878bd083", + "bindings.testLedHelp": "76129f95c4917ccc81da4a9cafa5523856933aadff158bbfd4c8023ebd4d1c66", + "bindings.testLedTesting": "f411c8dfb627c9040c05ca2800031645dfdd82bb1dab0c7b46d950781f760972", + "bindings.testLedDone": "1faf9a55f725b69eeaea74eea513851695809fc837fad490983c5f70b4ea75b6", + "bindings.testLedFailed": "da6748dec806bdb84ed0e2bd1c1e41658063fc636e7e9c3a26169543cd0445a1" }, "de": { "bindings.feedbackDisabled": "75081b593d15cf6e631971bc6768723f593b88b172477e40ae7d363e4829816d", @@ -1921,7 +1943,7 @@ "bindings.curvePresetLimitTitle": "b33a8e7cbaed93c7eeb49c54b4a12612c7fa20bee0a2520312a3d0f3fea53394", "bindings.curvePresetLimitMessage": "1b3c21265d97324dbc5345108e1ea4358e6e45e8726f2a399ed46d5090037911", "bindings.feedbackOutput": "18a90388ccba3cfbe9d0b7b37e74ad1239215a09b41c51ecc999486674c97528", - "bindings.feedbackOutputHelp": "96764e2359e9d5b72d27eca432f50ab883641f0271493c8e0bebe737052e1199", + "bindings.feedbackOutputHelp": "b0ec3a985b218f4d39cc8835ef15895464050dcb43f4c506c3963634fa66d92c", "bindings.feedbackMessageType": "122d60a966c83521205723b6dbed144d944bea93e0724cd15442206429e2e0eb", "bindings.feedbackChannelLabel": "cf0742ac1ad150bacbf7c980c7f93dc770afb1dfd054615f18b68fde2f47a85f", "bindings.feedbackControlLabel": "7ca8405192dd40c79ae4be7e773355f582534b968090ce8af43f7d16c889d8fb", @@ -2055,7 +2077,18 @@ "virtualAudio.blockedUnsafeVersion": "858883278e55cfe3ffbeadabc9eb288ed4847d49c9f88f876fd00dfc5a78d6d0", "virtualAudio.blockedUnknownVersion": "39529aa8adce738e281f3ba1c1ae94eef99a60be3aba0200058539607a750d1f", "virtualAudio.copyDiagnostics": "87a3f848e997fe63746507a102205aa488043077ff797741494d1fadc77cbe6a", - "virtualAudio.diagnosticsCopied": "18e9da0fd761b965e5cd4c3e7adcfe7e6fb9f691484c06c98a894c6dea57c077" + "virtualAudio.diagnosticsCopied": "18e9da0fd761b965e5cd4c3e7adcfe7e6fb9f691484c06c98a894c6dea57c077", + "bindings.ledMode": "8b797e51f1310507dcdfef4cf8dc8db164afe34a3ad5595e454bb0b24e6c18cb", + "bindings.ledModeHelp": "e1fdb51d3321ded27e44b28d52acea95e94aa578e23fcd3dde0e8590322e94b1", + "bindings.ledFollowValue": "c03c266e971f4a504e7dd0aa4166d73ea6c91fce08d035815b7e63b43747f32f", + "bindings.ledMusicReactive": "4187beb90a5e4423eb405f60ad6849231d34f76f1c8bdcd4eef222460282c18a", + "bindings.ledIntensity": "37aede6ebb44595af3d3f6a85cf90c3c8430de5a66de6e503de1e047ae3397a4", + "bindings.ledIntensityHelp": "644d876e9a276e7ff74d0d4eeee5351ab05a462f2325f8e7d4ae3697111e6659", + "bindings.testLed": "80ccc51db9708fb9e6e053877a81fca717ca0348be079cf746f8446a878bd083", + "bindings.testLedHelp": "76129f95c4917ccc81da4a9cafa5523856933aadff158bbfd4c8023ebd4d1c66", + "bindings.testLedTesting": "f411c8dfb627c9040c05ca2800031645dfdd82bb1dab0c7b46d950781f760972", + "bindings.testLedDone": "1faf9a55f725b69eeaea74eea513851695809fc837fad490983c5f70b4ea75b6", + "bindings.testLedFailed": "da6748dec806bdb84ed0e2bd1c1e41658063fc636e7e9c3a26169543cd0445a1" }, "it": { "bindings.feedbackDisabled": "75081b593d15cf6e631971bc6768723f593b88b172477e40ae7d363e4829816d", @@ -2607,7 +2640,7 @@ "bindings.curvePresetLimitTitle": "b33a8e7cbaed93c7eeb49c54b4a12612c7fa20bee0a2520312a3d0f3fea53394", "bindings.curvePresetLimitMessage": "1b3c21265d97324dbc5345108e1ea4358e6e45e8726f2a399ed46d5090037911", "bindings.feedbackOutput": "18a90388ccba3cfbe9d0b7b37e74ad1239215a09b41c51ecc999486674c97528", - "bindings.feedbackOutputHelp": "96764e2359e9d5b72d27eca432f50ab883641f0271493c8e0bebe737052e1199", + "bindings.feedbackOutputHelp": "b0ec3a985b218f4d39cc8835ef15895464050dcb43f4c506c3963634fa66d92c", "bindings.feedbackMessageType": "122d60a966c83521205723b6dbed144d944bea93e0724cd15442206429e2e0eb", "bindings.feedbackChannelLabel": "cf0742ac1ad150bacbf7c980c7f93dc770afb1dfd054615f18b68fde2f47a85f", "bindings.feedbackControlLabel": "7ca8405192dd40c79ae4be7e773355f582534b968090ce8af43f7d16c889d8fb", @@ -2741,7 +2774,18 @@ "virtualAudio.blockedUnsafeVersion": "858883278e55cfe3ffbeadabc9eb288ed4847d49c9f88f876fd00dfc5a78d6d0", "virtualAudio.blockedUnknownVersion": "39529aa8adce738e281f3ba1c1ae94eef99a60be3aba0200058539607a750d1f", "virtualAudio.copyDiagnostics": "87a3f848e997fe63746507a102205aa488043077ff797741494d1fadc77cbe6a", - "virtualAudio.diagnosticsCopied": "18e9da0fd761b965e5cd4c3e7adcfe7e6fb9f691484c06c98a894c6dea57c077" + "virtualAudio.diagnosticsCopied": "18e9da0fd761b965e5cd4c3e7adcfe7e6fb9f691484c06c98a894c6dea57c077", + "bindings.ledMode": "8b797e51f1310507dcdfef4cf8dc8db164afe34a3ad5595e454bb0b24e6c18cb", + "bindings.ledModeHelp": "e1fdb51d3321ded27e44b28d52acea95e94aa578e23fcd3dde0e8590322e94b1", + "bindings.ledFollowValue": "c03c266e971f4a504e7dd0aa4166d73ea6c91fce08d035815b7e63b43747f32f", + "bindings.ledMusicReactive": "4187beb90a5e4423eb405f60ad6849231d34f76f1c8bdcd4eef222460282c18a", + "bindings.ledIntensity": "37aede6ebb44595af3d3f6a85cf90c3c8430de5a66de6e503de1e047ae3397a4", + "bindings.ledIntensityHelp": "644d876e9a276e7ff74d0d4eeee5351ab05a462f2325f8e7d4ae3697111e6659", + "bindings.testLed": "80ccc51db9708fb9e6e053877a81fca717ca0348be079cf746f8446a878bd083", + "bindings.testLedHelp": "76129f95c4917ccc81da4a9cafa5523856933aadff158bbfd4c8023ebd4d1c66", + "bindings.testLedTesting": "f411c8dfb627c9040c05ca2800031645dfdd82bb1dab0c7b46d950781f760972", + "bindings.testLedDone": "1faf9a55f725b69eeaea74eea513851695809fc837fad490983c5f70b4ea75b6", + "bindings.testLedFailed": "da6748dec806bdb84ed0e2bd1c1e41658063fc636e7e9c3a26169543cd0445a1" }, "pt-BR": { "bindings.feedbackDisabled": "75081b593d15cf6e631971bc6768723f593b88b172477e40ae7d363e4829816d", @@ -3293,7 +3337,7 @@ "bindings.curvePresetLimitTitle": "b33a8e7cbaed93c7eeb49c54b4a12612c7fa20bee0a2520312a3d0f3fea53394", "bindings.curvePresetLimitMessage": "1b3c21265d97324dbc5345108e1ea4358e6e45e8726f2a399ed46d5090037911", "bindings.feedbackOutput": "18a90388ccba3cfbe9d0b7b37e74ad1239215a09b41c51ecc999486674c97528", - "bindings.feedbackOutputHelp": "96764e2359e9d5b72d27eca432f50ab883641f0271493c8e0bebe737052e1199", + "bindings.feedbackOutputHelp": "b0ec3a985b218f4d39cc8835ef15895464050dcb43f4c506c3963634fa66d92c", "bindings.feedbackMessageType": "122d60a966c83521205723b6dbed144d944bea93e0724cd15442206429e2e0eb", "bindings.feedbackChannelLabel": "cf0742ac1ad150bacbf7c980c7f93dc770afb1dfd054615f18b68fde2f47a85f", "bindings.feedbackControlLabel": "7ca8405192dd40c79ae4be7e773355f582534b968090ce8af43f7d16c889d8fb", @@ -3427,7 +3471,18 @@ "virtualAudio.blockedUnsafeVersion": "858883278e55cfe3ffbeadabc9eb288ed4847d49c9f88f876fd00dfc5a78d6d0", "virtualAudio.blockedUnknownVersion": "39529aa8adce738e281f3ba1c1ae94eef99a60be3aba0200058539607a750d1f", "virtualAudio.copyDiagnostics": "87a3f848e997fe63746507a102205aa488043077ff797741494d1fadc77cbe6a", - "virtualAudio.diagnosticsCopied": "18e9da0fd761b965e5cd4c3e7adcfe7e6fb9f691484c06c98a894c6dea57c077" + "virtualAudio.diagnosticsCopied": "18e9da0fd761b965e5cd4c3e7adcfe7e6fb9f691484c06c98a894c6dea57c077", + "bindings.ledMode": "8b797e51f1310507dcdfef4cf8dc8db164afe34a3ad5595e454bb0b24e6c18cb", + "bindings.ledModeHelp": "e1fdb51d3321ded27e44b28d52acea95e94aa578e23fcd3dde0e8590322e94b1", + "bindings.ledFollowValue": "c03c266e971f4a504e7dd0aa4166d73ea6c91fce08d035815b7e63b43747f32f", + "bindings.ledMusicReactive": "4187beb90a5e4423eb405f60ad6849231d34f76f1c8bdcd4eef222460282c18a", + "bindings.ledIntensity": "37aede6ebb44595af3d3f6a85cf90c3c8430de5a66de6e503de1e047ae3397a4", + "bindings.ledIntensityHelp": "644d876e9a276e7ff74d0d4eeee5351ab05a462f2325f8e7d4ae3697111e6659", + "bindings.testLed": "80ccc51db9708fb9e6e053877a81fca717ca0348be079cf746f8446a878bd083", + "bindings.testLedHelp": "76129f95c4917ccc81da4a9cafa5523856933aadff158bbfd4c8023ebd4d1c66", + "bindings.testLedTesting": "f411c8dfb627c9040c05ca2800031645dfdd82bb1dab0c7b46d950781f760972", + "bindings.testLedDone": "1faf9a55f725b69eeaea74eea513851695809fc837fad490983c5f70b4ea75b6", + "bindings.testLedFailed": "da6748dec806bdb84ed0e2bd1c1e41658063fc636e7e9c3a26169543cd0445a1" }, "nl": { "bindings.feedbackDisabled": "75081b593d15cf6e631971bc6768723f593b88b172477e40ae7d363e4829816d", @@ -3979,7 +4034,7 @@ "bindings.curvePresetLimitTitle": "b33a8e7cbaed93c7eeb49c54b4a12612c7fa20bee0a2520312a3d0f3fea53394", "bindings.curvePresetLimitMessage": "1b3c21265d97324dbc5345108e1ea4358e6e45e8726f2a399ed46d5090037911", "bindings.feedbackOutput": "18a90388ccba3cfbe9d0b7b37e74ad1239215a09b41c51ecc999486674c97528", - "bindings.feedbackOutputHelp": "96764e2359e9d5b72d27eca432f50ab883641f0271493c8e0bebe737052e1199", + "bindings.feedbackOutputHelp": "b0ec3a985b218f4d39cc8835ef15895464050dcb43f4c506c3963634fa66d92c", "bindings.feedbackMessageType": "122d60a966c83521205723b6dbed144d944bea93e0724cd15442206429e2e0eb", "bindings.feedbackChannelLabel": "cf0742ac1ad150bacbf7c980c7f93dc770afb1dfd054615f18b68fde2f47a85f", "bindings.feedbackControlLabel": "7ca8405192dd40c79ae4be7e773355f582534b968090ce8af43f7d16c889d8fb", @@ -4113,7 +4168,18 @@ "virtualAudio.blockedUnsafeVersion": "858883278e55cfe3ffbeadabc9eb288ed4847d49c9f88f876fd00dfc5a78d6d0", "virtualAudio.blockedUnknownVersion": "39529aa8adce738e281f3ba1c1ae94eef99a60be3aba0200058539607a750d1f", "virtualAudio.copyDiagnostics": "87a3f848e997fe63746507a102205aa488043077ff797741494d1fadc77cbe6a", - "virtualAudio.diagnosticsCopied": "18e9da0fd761b965e5cd4c3e7adcfe7e6fb9f691484c06c98a894c6dea57c077" + "virtualAudio.diagnosticsCopied": "18e9da0fd761b965e5cd4c3e7adcfe7e6fb9f691484c06c98a894c6dea57c077", + "bindings.ledMode": "8b797e51f1310507dcdfef4cf8dc8db164afe34a3ad5595e454bb0b24e6c18cb", + "bindings.ledModeHelp": "e1fdb51d3321ded27e44b28d52acea95e94aa578e23fcd3dde0e8590322e94b1", + "bindings.ledFollowValue": "c03c266e971f4a504e7dd0aa4166d73ea6c91fce08d035815b7e63b43747f32f", + "bindings.ledMusicReactive": "4187beb90a5e4423eb405f60ad6849231d34f76f1c8bdcd4eef222460282c18a", + "bindings.ledIntensity": "37aede6ebb44595af3d3f6a85cf90c3c8430de5a66de6e503de1e047ae3397a4", + "bindings.ledIntensityHelp": "644d876e9a276e7ff74d0d4eeee5351ab05a462f2325f8e7d4ae3697111e6659", + "bindings.testLed": "80ccc51db9708fb9e6e053877a81fca717ca0348be079cf746f8446a878bd083", + "bindings.testLedHelp": "76129f95c4917ccc81da4a9cafa5523856933aadff158bbfd4c8023ebd4d1c66", + "bindings.testLedTesting": "f411c8dfb627c9040c05ca2800031645dfdd82bb1dab0c7b46d950781f760972", + "bindings.testLedDone": "1faf9a55f725b69eeaea74eea513851695809fc837fad490983c5f70b4ea75b6", + "bindings.testLedFailed": "da6748dec806bdb84ed0e2bd1c1e41658063fc636e7e9c3a26169543cd0445a1" }, "pl": { "bindings.feedbackDisabled": "75081b593d15cf6e631971bc6768723f593b88b172477e40ae7d363e4829816d", @@ -4665,7 +4731,7 @@ "bindings.curvePresetLimitTitle": "b33a8e7cbaed93c7eeb49c54b4a12612c7fa20bee0a2520312a3d0f3fea53394", "bindings.curvePresetLimitMessage": "1b3c21265d97324dbc5345108e1ea4358e6e45e8726f2a399ed46d5090037911", "bindings.feedbackOutput": "18a90388ccba3cfbe9d0b7b37e74ad1239215a09b41c51ecc999486674c97528", - "bindings.feedbackOutputHelp": "96764e2359e9d5b72d27eca432f50ab883641f0271493c8e0bebe737052e1199", + "bindings.feedbackOutputHelp": "b0ec3a985b218f4d39cc8835ef15895464050dcb43f4c506c3963634fa66d92c", "bindings.feedbackMessageType": "122d60a966c83521205723b6dbed144d944bea93e0724cd15442206429e2e0eb", "bindings.feedbackChannelLabel": "cf0742ac1ad150bacbf7c980c7f93dc770afb1dfd054615f18b68fde2f47a85f", "bindings.feedbackControlLabel": "7ca8405192dd40c79ae4be7e773355f582534b968090ce8af43f7d16c889d8fb", @@ -4799,7 +4865,18 @@ "virtualAudio.blockedUnsafeVersion": "858883278e55cfe3ffbeadabc9eb288ed4847d49c9f88f876fd00dfc5a78d6d0", "virtualAudio.blockedUnknownVersion": "39529aa8adce738e281f3ba1c1ae94eef99a60be3aba0200058539607a750d1f", "virtualAudio.copyDiagnostics": "87a3f848e997fe63746507a102205aa488043077ff797741494d1fadc77cbe6a", - "virtualAudio.diagnosticsCopied": "18e9da0fd761b965e5cd4c3e7adcfe7e6fb9f691484c06c98a894c6dea57c077" + "virtualAudio.diagnosticsCopied": "18e9da0fd761b965e5cd4c3e7adcfe7e6fb9f691484c06c98a894c6dea57c077", + "bindings.ledMode": "8b797e51f1310507dcdfef4cf8dc8db164afe34a3ad5595e454bb0b24e6c18cb", + "bindings.ledModeHelp": "e1fdb51d3321ded27e44b28d52acea95e94aa578e23fcd3dde0e8590322e94b1", + "bindings.ledFollowValue": "c03c266e971f4a504e7dd0aa4166d73ea6c91fce08d035815b7e63b43747f32f", + "bindings.ledMusicReactive": "4187beb90a5e4423eb405f60ad6849231d34f76f1c8bdcd4eef222460282c18a", + "bindings.ledIntensity": "37aede6ebb44595af3d3f6a85cf90c3c8430de5a66de6e503de1e047ae3397a4", + "bindings.ledIntensityHelp": "644d876e9a276e7ff74d0d4eeee5351ab05a462f2325f8e7d4ae3697111e6659", + "bindings.testLed": "80ccc51db9708fb9e6e053877a81fca717ca0348be079cf746f8446a878bd083", + "bindings.testLedHelp": "76129f95c4917ccc81da4a9cafa5523856933aadff158bbfd4c8023ebd4d1c66", + "bindings.testLedTesting": "f411c8dfb627c9040c05ca2800031645dfdd82bb1dab0c7b46d950781f760972", + "bindings.testLedDone": "1faf9a55f725b69eeaea74eea513851695809fc837fad490983c5f70b4ea75b6", + "bindings.testLedFailed": "da6748dec806bdb84ed0e2bd1c1e41658063fc636e7e9c3a26169543cd0445a1" }, "ja": { "bindings.feedbackDisabled": "75081b593d15cf6e631971bc6768723f593b88b172477e40ae7d363e4829816d", @@ -5351,7 +5428,7 @@ "bindings.curvePresetLimitTitle": "b33a8e7cbaed93c7eeb49c54b4a12612c7fa20bee0a2520312a3d0f3fea53394", "bindings.curvePresetLimitMessage": "1b3c21265d97324dbc5345108e1ea4358e6e45e8726f2a399ed46d5090037911", "bindings.feedbackOutput": "18a90388ccba3cfbe9d0b7b37e74ad1239215a09b41c51ecc999486674c97528", - "bindings.feedbackOutputHelp": "96764e2359e9d5b72d27eca432f50ab883641f0271493c8e0bebe737052e1199", + "bindings.feedbackOutputHelp": "b0ec3a985b218f4d39cc8835ef15895464050dcb43f4c506c3963634fa66d92c", "bindings.feedbackMessageType": "122d60a966c83521205723b6dbed144d944bea93e0724cd15442206429e2e0eb", "bindings.feedbackChannelLabel": "cf0742ac1ad150bacbf7c980c7f93dc770afb1dfd054615f18b68fde2f47a85f", "bindings.feedbackControlLabel": "7ca8405192dd40c79ae4be7e773355f582534b968090ce8af43f7d16c889d8fb", @@ -5485,7 +5562,18 @@ "virtualAudio.blockedUnsafeVersion": "858883278e55cfe3ffbeadabc9eb288ed4847d49c9f88f876fd00dfc5a78d6d0", "virtualAudio.blockedUnknownVersion": "39529aa8adce738e281f3ba1c1ae94eef99a60be3aba0200058539607a750d1f", "virtualAudio.copyDiagnostics": "87a3f848e997fe63746507a102205aa488043077ff797741494d1fadc77cbe6a", - "virtualAudio.diagnosticsCopied": "18e9da0fd761b965e5cd4c3e7adcfe7e6fb9f691484c06c98a894c6dea57c077" + "virtualAudio.diagnosticsCopied": "18e9da0fd761b965e5cd4c3e7adcfe7e6fb9f691484c06c98a894c6dea57c077", + "bindings.ledMode": "8b797e51f1310507dcdfef4cf8dc8db164afe34a3ad5595e454bb0b24e6c18cb", + "bindings.ledModeHelp": "e1fdb51d3321ded27e44b28d52acea95e94aa578e23fcd3dde0e8590322e94b1", + "bindings.ledFollowValue": "c03c266e971f4a504e7dd0aa4166d73ea6c91fce08d035815b7e63b43747f32f", + "bindings.ledMusicReactive": "4187beb90a5e4423eb405f60ad6849231d34f76f1c8bdcd4eef222460282c18a", + "bindings.ledIntensity": "37aede6ebb44595af3d3f6a85cf90c3c8430de5a66de6e503de1e047ae3397a4", + "bindings.ledIntensityHelp": "644d876e9a276e7ff74d0d4eeee5351ab05a462f2325f8e7d4ae3697111e6659", + "bindings.testLed": "80ccc51db9708fb9e6e053877a81fca717ca0348be079cf746f8446a878bd083", + "bindings.testLedHelp": "76129f95c4917ccc81da4a9cafa5523856933aadff158bbfd4c8023ebd4d1c66", + "bindings.testLedTesting": "f411c8dfb627c9040c05ca2800031645dfdd82bb1dab0c7b46d950781f760972", + "bindings.testLedDone": "1faf9a55f725b69eeaea74eea513851695809fc837fad490983c5f70b4ea75b6", + "bindings.testLedFailed": "da6748dec806bdb84ed0e2bd1c1e41658063fc636e7e9c3a26169543cd0445a1" }, "ko": { "bindings.feedbackDisabled": "75081b593d15cf6e631971bc6768723f593b88b172477e40ae7d363e4829816d", @@ -6037,7 +6125,7 @@ "bindings.curvePresetLimitTitle": "b33a8e7cbaed93c7eeb49c54b4a12612c7fa20bee0a2520312a3d0f3fea53394", "bindings.curvePresetLimitMessage": "1b3c21265d97324dbc5345108e1ea4358e6e45e8726f2a399ed46d5090037911", "bindings.feedbackOutput": "18a90388ccba3cfbe9d0b7b37e74ad1239215a09b41c51ecc999486674c97528", - "bindings.feedbackOutputHelp": "96764e2359e9d5b72d27eca432f50ab883641f0271493c8e0bebe737052e1199", + "bindings.feedbackOutputHelp": "b0ec3a985b218f4d39cc8835ef15895464050dcb43f4c506c3963634fa66d92c", "bindings.feedbackMessageType": "122d60a966c83521205723b6dbed144d944bea93e0724cd15442206429e2e0eb", "bindings.feedbackChannelLabel": "cf0742ac1ad150bacbf7c980c7f93dc770afb1dfd054615f18b68fde2f47a85f", "bindings.feedbackControlLabel": "7ca8405192dd40c79ae4be7e773355f582534b968090ce8af43f7d16c889d8fb", @@ -6171,7 +6259,18 @@ "virtualAudio.blockedUnsafeVersion": "858883278e55cfe3ffbeadabc9eb288ed4847d49c9f88f876fd00dfc5a78d6d0", "virtualAudio.blockedUnknownVersion": "39529aa8adce738e281f3ba1c1ae94eef99a60be3aba0200058539607a750d1f", "virtualAudio.copyDiagnostics": "87a3f848e997fe63746507a102205aa488043077ff797741494d1fadc77cbe6a", - "virtualAudio.diagnosticsCopied": "18e9da0fd761b965e5cd4c3e7adcfe7e6fb9f691484c06c98a894c6dea57c077" + "virtualAudio.diagnosticsCopied": "18e9da0fd761b965e5cd4c3e7adcfe7e6fb9f691484c06c98a894c6dea57c077", + "bindings.ledMode": "8b797e51f1310507dcdfef4cf8dc8db164afe34a3ad5595e454bb0b24e6c18cb", + "bindings.ledModeHelp": "e1fdb51d3321ded27e44b28d52acea95e94aa578e23fcd3dde0e8590322e94b1", + "bindings.ledFollowValue": "c03c266e971f4a504e7dd0aa4166d73ea6c91fce08d035815b7e63b43747f32f", + "bindings.ledMusicReactive": "4187beb90a5e4423eb405f60ad6849231d34f76f1c8bdcd4eef222460282c18a", + "bindings.ledIntensity": "37aede6ebb44595af3d3f6a85cf90c3c8430de5a66de6e503de1e047ae3397a4", + "bindings.ledIntensityHelp": "644d876e9a276e7ff74d0d4eeee5351ab05a462f2325f8e7d4ae3697111e6659", + "bindings.testLed": "80ccc51db9708fb9e6e053877a81fca717ca0348be079cf746f8446a878bd083", + "bindings.testLedHelp": "76129f95c4917ccc81da4a9cafa5523856933aadff158bbfd4c8023ebd4d1c66", + "bindings.testLedTesting": "f411c8dfb627c9040c05ca2800031645dfdd82bb1dab0c7b46d950781f760972", + "bindings.testLedDone": "1faf9a55f725b69eeaea74eea513851695809fc837fad490983c5f70b4ea75b6", + "bindings.testLedFailed": "da6748dec806bdb84ed0e2bd1c1e41658063fc636e7e9c3a26169543cd0445a1" }, "zh-Hans": { "bindings.feedbackDisabled": "75081b593d15cf6e631971bc6768723f593b88b172477e40ae7d363e4829816d", @@ -6723,7 +6822,7 @@ "bindings.curvePresetLimitTitle": "b33a8e7cbaed93c7eeb49c54b4a12612c7fa20bee0a2520312a3d0f3fea53394", "bindings.curvePresetLimitMessage": "1b3c21265d97324dbc5345108e1ea4358e6e45e8726f2a399ed46d5090037911", "bindings.feedbackOutput": "18a90388ccba3cfbe9d0b7b37e74ad1239215a09b41c51ecc999486674c97528", - "bindings.feedbackOutputHelp": "96764e2359e9d5b72d27eca432f50ab883641f0271493c8e0bebe737052e1199", + "bindings.feedbackOutputHelp": "b0ec3a985b218f4d39cc8835ef15895464050dcb43f4c506c3963634fa66d92c", "bindings.feedbackMessageType": "122d60a966c83521205723b6dbed144d944bea93e0724cd15442206429e2e0eb", "bindings.feedbackChannelLabel": "cf0742ac1ad150bacbf7c980c7f93dc770afb1dfd054615f18b68fde2f47a85f", "bindings.feedbackControlLabel": "7ca8405192dd40c79ae4be7e773355f582534b968090ce8af43f7d16c889d8fb", @@ -6857,7 +6956,18 @@ "virtualAudio.blockedUnsafeVersion": "858883278e55cfe3ffbeadabc9eb288ed4847d49c9f88f876fd00dfc5a78d6d0", "virtualAudio.blockedUnknownVersion": "39529aa8adce738e281f3ba1c1ae94eef99a60be3aba0200058539607a750d1f", "virtualAudio.copyDiagnostics": "87a3f848e997fe63746507a102205aa488043077ff797741494d1fadc77cbe6a", - "virtualAudio.diagnosticsCopied": "18e9da0fd761b965e5cd4c3e7adcfe7e6fb9f691484c06c98a894c6dea57c077" + "virtualAudio.diagnosticsCopied": "18e9da0fd761b965e5cd4c3e7adcfe7e6fb9f691484c06c98a894c6dea57c077", + "bindings.ledMode": "8b797e51f1310507dcdfef4cf8dc8db164afe34a3ad5595e454bb0b24e6c18cb", + "bindings.ledModeHelp": "e1fdb51d3321ded27e44b28d52acea95e94aa578e23fcd3dde0e8590322e94b1", + "bindings.ledFollowValue": "c03c266e971f4a504e7dd0aa4166d73ea6c91fce08d035815b7e63b43747f32f", + "bindings.ledMusicReactive": "4187beb90a5e4423eb405f60ad6849231d34f76f1c8bdcd4eef222460282c18a", + "bindings.ledIntensity": "37aede6ebb44595af3d3f6a85cf90c3c8430de5a66de6e503de1e047ae3397a4", + "bindings.ledIntensityHelp": "644d876e9a276e7ff74d0d4eeee5351ab05a462f2325f8e7d4ae3697111e6659", + "bindings.testLed": "80ccc51db9708fb9e6e053877a81fca717ca0348be079cf746f8446a878bd083", + "bindings.testLedHelp": "76129f95c4917ccc81da4a9cafa5523856933aadff158bbfd4c8023ebd4d1c66", + "bindings.testLedTesting": "f411c8dfb627c9040c05ca2800031645dfdd82bb1dab0c7b46d950781f760972", + "bindings.testLedDone": "1faf9a55f725b69eeaea74eea513851695809fc837fad490983c5f70b4ea75b6", + "bindings.testLedFailed": "da6748dec806bdb84ed0e2bd1c1e41658063fc636e7e9c3a26169543cd0445a1" }, "settings.appearance.description": "Customize how MIDIMaster looks and feels.", "settings.appearance.themePresets": "Theme Presets", @@ -6900,5 +7010,10 @@ "settings.appearance.instantApply": "Changes are applied instantly.", "settings.appearance.restartNote": "Some settings may require an application restart to take full effect.", "settings.appearance.exportTheme": "Export Theme", - "settings.appearance.importTheme": "Import Theme" + "settings.appearance.importTheme": "Import Theme", + "en": { + "bindings.ledMusicReactive": "4187beb90a5e4423eb405f60ad6849231d34f76f1c8bdcd4eef222460282c18a", + "bindings.ledIntensity": "37aede6ebb44595af3d3f6a85cf90c3c8430de5a66de6e503de1e047ae3397a4", + "bindings.ledIntensityHelp": "644d876e9a276e7ff74d0d4eeee5351ab05a462f2325f8e7d4ae3697111e6659" + } } diff --git a/src/locales/de.json b/src/locales/de.json index a325d32..61db420 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -684,5 +684,16 @@ "bindings.buttonLightWhenMapped": "Wenn zugewiesen", "bindings.buttonLightWhenOn": "Wenn ein", "bindings.buttonLightWhenOff": "Wenn aus", - "bindings.buttonLightWhilePressed": "Während gedrückt" + "bindings.buttonLightWhilePressed": "Während gedrückt", + "bindings.ledMode": "LED mode", + "bindings.ledModeHelp": "Follow the fader value, or pulse the light with the bound audio level.", + "bindings.ledFollowValue": "Follow Value", + "bindings.ledMusicReactive": "Audio Reactive", + "bindings.ledIntensity": "LED sensitivity", + "bindings.ledIntensityHelp": "How strongly the light reacts to peaks and small level changes around the current meter.", + "bindings.testLed": "Test LED", + "bindings.testLedHelp": "Flash the configured LED address three times.", + "bindings.testLedTesting": "Flashing LED…", + "bindings.testLedDone": "Test pulse sent.", + "bindings.testLedFailed": "LED test failed: {error}" } diff --git a/src/locales/en.json b/src/locales/en.json index 3d185ce..a2efce0 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -154,14 +154,25 @@ "bindings.learnIndicatorOutput": "Learn indicator output", "bindings.resetIndicatorOutput": "Reset indicator output", "bindings.feedbackOutput": "Feedback output", - "bindings.feedbackOutputHelp": "Note, CC, or Pitch Bend address that receives fader feedback.", + "bindings.feedbackOutputHelp": "Note, CC, or Pitch Bend address that receives fader or LED feedback.", "bindings.feedbackMessageType": "Feedback message type", "bindings.feedbackDisabled": "Disabled", "bindings.feedbackChannelLabel": "Feedback channel", "bindings.feedbackControlLabel": "Feedback control", + "bindings.ledMode": "LED mode", + "bindings.ledModeHelp": "Follow the fader value, or pulse the light with the bound audio level.", + "bindings.ledFollowValue": "Follow Value", + "bindings.ledMusicReactive": "Audio Reactive", + "bindings.ledIntensity": "LED sensitivity", + "bindings.ledIntensityHelp": "How strongly the light reacts to peaks and small level changes around the current meter.", "bindings.pitchBend": "Pitch Bend", "bindings.learnFeedbackOutput": "Learn feedback output", "bindings.resetFeedbackOutput": "Reset feedback output", + "bindings.testLed": "Test LED", + "bindings.testLedHelp": "Flash the configured LED address three times.", + "bindings.testLedTesting": "Flashing LED…", + "bindings.testLedDone": "Test pulse sent.", + "bindings.testLedFailed": "LED test failed: {error}", "bindings.lightStateControlledHelp": "This button shows the target state.", "bindings.lightUnavailableHelp": "Choose a stateless button action to use mapped lighting.", "bindings.mute": "Mute", diff --git a/src/locales/es.json b/src/locales/es.json index dea94ae..f75eeb2 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -684,5 +684,16 @@ "bindings.buttonLightWhenMapped": "Cuando está asignado", "bindings.buttonLightWhenOn": "Cuando está activado", "bindings.buttonLightWhenOff": "Cuando está desactivado", - "bindings.buttonLightWhilePressed": "Mientras se pulsa" + "bindings.buttonLightWhilePressed": "Mientras se pulsa", + "bindings.ledMode": "LED mode", + "bindings.ledModeHelp": "Follow the fader value, or pulse the light with the bound audio level.", + "bindings.ledFollowValue": "Follow Value", + "bindings.ledMusicReactive": "Audio Reactive", + "bindings.ledIntensity": "LED sensitivity", + "bindings.ledIntensityHelp": "How strongly the light reacts to peaks and small level changes around the current meter.", + "bindings.testLed": "Test LED", + "bindings.testLedHelp": "Flash the configured LED address three times.", + "bindings.testLedTesting": "Flashing LED…", + "bindings.testLedDone": "Test pulse sent.", + "bindings.testLedFailed": "LED test failed: {error}" } diff --git a/src/locales/fr.json b/src/locales/fr.json index 7e53682..b2efa32 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -684,5 +684,16 @@ "bindings.buttonLightWhenMapped": "Lorsque mappé", "bindings.buttonLightWhenOn": "Quand activé", "bindings.buttonLightWhenOff": "Quand désactivé", - "bindings.buttonLightWhilePressed": "Pendant l’appui" + "bindings.buttonLightWhilePressed": "Pendant l’appui", + "bindings.ledMode": "LED mode", + "bindings.ledModeHelp": "Follow the fader value, or pulse the light with the bound audio level.", + "bindings.ledFollowValue": "Follow Value", + "bindings.ledMusicReactive": "Audio Reactive", + "bindings.ledIntensity": "LED sensitivity", + "bindings.ledIntensityHelp": "How strongly the light reacts to peaks and small level changes around the current meter.", + "bindings.testLed": "Test LED", + "bindings.testLedHelp": "Flash the configured LED address three times.", + "bindings.testLedTesting": "Flashing LED…", + "bindings.testLedDone": "Test pulse sent.", + "bindings.testLedFailed": "LED test failed: {error}" } diff --git a/src/locales/it.json b/src/locales/it.json index cd65296..fa1b8a1 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -684,5 +684,16 @@ "bindings.buttonLightWhenMapped": "Quando mappato", "bindings.buttonLightWhenOn": "Quando attivo", "bindings.buttonLightWhenOff": "Quando disattivo", - "bindings.buttonLightWhilePressed": "Durante la pressione" + "bindings.buttonLightWhilePressed": "Durante la pressione", + "bindings.ledMode": "LED mode", + "bindings.ledModeHelp": "Follow the fader value, or pulse the light with the bound audio level.", + "bindings.ledFollowValue": "Follow Value", + "bindings.ledMusicReactive": "Audio Reactive", + "bindings.ledIntensity": "LED sensitivity", + "bindings.ledIntensityHelp": "How strongly the light reacts to peaks and small level changes around the current meter.", + "bindings.testLed": "Test LED", + "bindings.testLedHelp": "Flash the configured LED address three times.", + "bindings.testLedTesting": "Flashing LED…", + "bindings.testLedDone": "Test pulse sent.", + "bindings.testLedFailed": "LED test failed: {error}" } diff --git a/src/locales/ja.json b/src/locales/ja.json index 097f455..b02f9d2 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -684,5 +684,16 @@ "bindings.buttonLightWhenMapped": "マッピング時", "bindings.buttonLightWhenOn": "オンのとき", "bindings.buttonLightWhenOff": "オフのとき", - "bindings.buttonLightWhilePressed": "押している間" + "bindings.buttonLightWhilePressed": "押している間", + "bindings.ledMode": "LED mode", + "bindings.ledModeHelp": "Follow the fader value, or pulse the light with the bound audio level.", + "bindings.ledFollowValue": "Follow Value", + "bindings.ledMusicReactive": "Audio Reactive", + "bindings.ledIntensity": "LED sensitivity", + "bindings.ledIntensityHelp": "How strongly the light reacts to peaks and small level changes around the current meter.", + "bindings.testLed": "Test LED", + "bindings.testLedHelp": "Flash the configured LED address three times.", + "bindings.testLedTesting": "Flashing LED…", + "bindings.testLedDone": "Test pulse sent.", + "bindings.testLedFailed": "LED test failed: {error}" } diff --git a/src/locales/ko.json b/src/locales/ko.json index b12a6d5..98a8719 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -684,5 +684,16 @@ "bindings.buttonLightWhenMapped": "매핑 시", "bindings.buttonLightWhenOn": "켜짐일 때", "bindings.buttonLightWhenOff": "꺼짐일 때", - "bindings.buttonLightWhilePressed": "누르는 동안" + "bindings.buttonLightWhilePressed": "누르는 동안", + "bindings.ledMode": "LED mode", + "bindings.ledModeHelp": "Follow the fader value, or pulse the light with the bound audio level.", + "bindings.ledFollowValue": "Follow Value", + "bindings.ledMusicReactive": "Audio Reactive", + "bindings.ledIntensity": "LED sensitivity", + "bindings.ledIntensityHelp": "How strongly the light reacts to peaks and small level changes around the current meter.", + "bindings.testLed": "Test LED", + "bindings.testLedHelp": "Flash the configured LED address three times.", + "bindings.testLedTesting": "Flashing LED…", + "bindings.testLedDone": "Test pulse sent.", + "bindings.testLedFailed": "LED test failed: {error}" } diff --git a/src/locales/nl.json b/src/locales/nl.json index 3464eca..82d586e 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -684,5 +684,16 @@ "bindings.buttonLightWhenMapped": "Bij toewijzing", "bindings.buttonLightWhenOn": "Wanneer aan", "bindings.buttonLightWhenOff": "Wanneer uit", - "bindings.buttonLightWhilePressed": "Tijdens indrukken" + "bindings.buttonLightWhilePressed": "Tijdens indrukken", + "bindings.ledMode": "LED mode", + "bindings.ledModeHelp": "Follow the fader value, or pulse the light with the bound audio level.", + "bindings.ledFollowValue": "Follow Value", + "bindings.ledMusicReactive": "Audio Reactive", + "bindings.ledIntensity": "LED sensitivity", + "bindings.ledIntensityHelp": "How strongly the light reacts to peaks and small level changes around the current meter.", + "bindings.testLed": "Test LED", + "bindings.testLedHelp": "Flash the configured LED address three times.", + "bindings.testLedTesting": "Flashing LED…", + "bindings.testLedDone": "Test pulse sent.", + "bindings.testLedFailed": "LED test failed: {error}" } diff --git a/src/locales/pl.json b/src/locales/pl.json index 5977351..04a588e 100644 --- a/src/locales/pl.json +++ b/src/locales/pl.json @@ -684,5 +684,16 @@ "bindings.buttonLightWhenMapped": "Po przypisaniu", "bindings.buttonLightWhenOn": "Gdy włączone", "bindings.buttonLightWhenOff": "Gdy wyłączone", - "bindings.buttonLightWhilePressed": "Podczas naciskania" + "bindings.buttonLightWhilePressed": "Podczas naciskania", + "bindings.ledMode": "LED mode", + "bindings.ledModeHelp": "Follow the fader value, or pulse the light with the bound audio level.", + "bindings.ledFollowValue": "Follow Value", + "bindings.ledMusicReactive": "Audio Reactive", + "bindings.ledIntensity": "LED sensitivity", + "bindings.ledIntensityHelp": "How strongly the light reacts to peaks and small level changes around the current meter.", + "bindings.testLed": "Test LED", + "bindings.testLedHelp": "Flash the configured LED address three times.", + "bindings.testLedTesting": "Flashing LED…", + "bindings.testLedDone": "Test pulse sent.", + "bindings.testLedFailed": "LED test failed: {error}" } diff --git a/src/locales/pt-BR.json b/src/locales/pt-BR.json index ebcfb21..19e56b2 100644 --- a/src/locales/pt-BR.json +++ b/src/locales/pt-BR.json @@ -684,5 +684,16 @@ "bindings.buttonLightWhenMapped": "Quando mapeado", "bindings.buttonLightWhenOn": "Quando ligado", "bindings.buttonLightWhenOff": "Quando desligado", - "bindings.buttonLightWhilePressed": "Enquanto pressionado" + "bindings.buttonLightWhilePressed": "Enquanto pressionado", + "bindings.ledMode": "LED mode", + "bindings.ledModeHelp": "Follow the fader value, or pulse the light with the bound audio level.", + "bindings.ledFollowValue": "Follow Value", + "bindings.ledMusicReactive": "Audio Reactive", + "bindings.ledIntensity": "LED sensitivity", + "bindings.ledIntensityHelp": "How strongly the light reacts to peaks and small level changes around the current meter.", + "bindings.testLed": "Test LED", + "bindings.testLedHelp": "Flash the configured LED address three times.", + "bindings.testLedTesting": "Flashing LED…", + "bindings.testLedDone": "Test pulse sent.", + "bindings.testLedFailed": "LED test failed: {error}" } diff --git a/src/locales/zh-Hans.json b/src/locales/zh-Hans.json index 24adef3..f570509 100644 --- a/src/locales/zh-Hans.json +++ b/src/locales/zh-Hans.json @@ -684,5 +684,16 @@ "bindings.buttonLightWhenMapped": "映射时", "bindings.buttonLightWhenOn": "开启时", "bindings.buttonLightWhenOff": "关闭时", - "bindings.buttonLightWhilePressed": "按下时" + "bindings.buttonLightWhilePressed": "按下时", + "bindings.ledMode": "LED mode", + "bindings.ledModeHelp": "Follow the fader value, or pulse the light with the bound audio level.", + "bindings.ledFollowValue": "Follow Value", + "bindings.ledMusicReactive": "Audio Reactive", + "bindings.ledIntensity": "LED sensitivity", + "bindings.ledIntensityHelp": "How strongly the light reacts to peaks and small level changes around the current meter.", + "bindings.testLed": "Test LED", + "bindings.testLedHelp": "Flash the configured LED address three times.", + "bindings.testLedTesting": "Flashing LED…", + "bindings.testLedDone": "Test pulse sent.", + "bindings.testLedFailed": "LED test failed: {error}" } diff --git a/src/styles/bindings/config-panel/feedback-controls.css b/src/styles/bindings/config-panel/feedback-controls.css index 1789a8b..92327cd 100644 --- a/src/styles/bindings/config-panel/feedback-controls.css +++ b/src/styles/bindings/config-panel/feedback-controls.css @@ -168,6 +168,48 @@ height: 40px; } +.binding-config-feedback-test-row { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + min-width: 0; + padding-top: 2px; +} + +.binding-config-feedback-test { + flex: 0 0 auto; + width: auto; + min-width: 110px; + height: 40px; + padding: 0 14px; + font-weight: 700; + white-space: nowrap; +} + +.binding-config-feedback-test.is-testing { + opacity: 0.72; +} + +.binding-config-feedback-test-status { + flex: 1 1 auto; + min-width: 0; + color: #93a5c4; + font-size: 12px; + font-weight: 600; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.binding-config-feedback-test-status.is-error { + color: #ff8f8f; +} + +.binding-config-section--feedback-output .binding-config-feedback-test-row, +.binding-config-section--button-light .binding-config-feedback-test-row { + margin-top: 2px; +} + @media (max-width: 720px) { .binding-config-indicator-custom { grid-template-columns: minmax(104px, 1fr) minmax(68px, 0.7fr) minmax(78px, 0.8fr) 40px 40px; @@ -178,6 +220,35 @@ } } +.binding-config-section--feedback-output { + align-content: start; + min-width: 0; +} + +.binding-config-led-intensity-row { + margin-top: 0; +} + +.binding-config-led-intensity-control { + display: inline-flex; + align-items: center; + gap: 10px; + min-width: min(180px, 42vw); +} + +.binding-config-led-intensity-control input[type="range"] { + width: min(120px, 28vw); + accent-color: #7eb6ff; +} + +.binding-config-led-intensity-control strong { + min-width: 3.2em; + text-align: right; + color: #e3eeff; + font-size: 13px; + font-weight: 700; +} + .binding-config-light-dropdown { width: min(180px, 42vw); min-width: 150px; diff --git a/src/styles/bindings/config-panel/preview-and-layout.css b/src/styles/bindings/config-panel/preview-and-layout.css index 6e4e180..1189e9e 100644 --- a/src/styles/bindings/config-panel/preview-and-layout.css +++ b/src/styles/bindings/config-panel/preview-and-layout.css @@ -339,7 +339,8 @@ .binding-config-panel--fader:not(.binding-config-panel--macro-page) .binding-config-layout { display: grid; - flex: 0 1 auto; + /* Keep content height; body scrolls. Shrinking + overflow:visible painted over the Save footer. */ + flex: 0 0 auto; grid-template-columns: minmax(0, 0.675fr) minmax(0, 0.675fr) minmax(270px, 0.85fr); grid-template-areas: "name name live" @@ -350,7 +351,7 @@ align-items: stretch; align-content: start; padding: 10px; - overflow: visible; + overflow: hidden; } .binding-config-panel--fader:not(.binding-config-panel--macro-page) .binding-config-main-column { @@ -396,6 +397,8 @@ .binding-config-panel--fader:not(.binding-config-panel--macro-page) .binding-config-section--feedback-output { grid-area: feedback; + min-width: 0; + min-height: min-content; } .binding-config-panel--fader:not(.binding-config-panel--macro-page) .binding-config-section--mute { diff --git a/src/styles/bindings/config-panel/theme-and-responsive.css b/src/styles/bindings/config-panel/theme-and-responsive.css index 51b28ba..2cfb985 100644 --- a/src/styles/bindings/config-panel/theme-and-responsive.css +++ b/src/styles/bindings/config-panel/theme-and-responsive.css @@ -246,16 +246,24 @@ body[data-theme] .binding-config-midi-control { .binding-config-footer { display: flex; justify-content: flex-end; + flex-wrap: nowrap; + align-items: center; gap: 8px; padding: 8px 10px 10px; border-top: 1px solid rgba(122, 141, 179, 0.16); flex: 0 0 auto; + min-width: 0; + position: relative; + z-index: 2; + background: rgba(13, 20, 31, 0.98); } .binding-config-footer button { + flex: 0 0 auto; min-width: 136px; height: 34px; border-radius: 10px; + white-space: nowrap; } @media (max-height: 780px) { diff --git a/src/styles/settings/theme-overrides.css b/src/styles/settings/theme-overrides.css index aae17cb..873c05f 100644 --- a/src/styles/settings/theme-overrides.css +++ b/src/styles/settings/theme-overrides.css @@ -401,6 +401,7 @@ body[data-theme] #binding-config-panel .binding-config-layout { body[data-theme] #binding-config-panel .binding-config-main-column, body[data-theme] #binding-config-panel .binding-config-footer { border-color: var(--binding-config-border-soft); + background: var(--binding-config-panel-bg); } body[data-theme] #binding-config-panel .binding-config-section,