diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 1d42291..65afce8 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -1,5 +1,6 @@ use crate::audio::buffer_pool::BufferPool; use crate::audio::clip::ClipId; +use crate::audio::metronome::Metronome; use crate::audio::midi::{MidiClip, MidiClipId, MidiEvent}; use crate::audio::node_graph::{nodes::*, AudioGraph}; use crate::audio::pool::AudioPool; @@ -55,6 +56,9 @@ pub struct Engine { // MIDI input manager for external MIDI devices midi_input_manager: Option, + + // Metronome for click track + metronome: Metronome, } impl Engine { @@ -96,6 +100,7 @@ impl Engine { recording_progress_counter: 0, midi_recording_state: None, midi_input_manager: None, + metronome: Metronome::new(sample_rate), } } @@ -247,6 +252,15 @@ impl Engine { // Copy mix to output output.copy_from_slice(&self.mix_buffer); + // Mix in metronome clicks + self.metronome.process( + output, + self.playhead, + self.playing, + self.sample_rate, + self.channels, + ); + // Update playhead (convert total samples to frames) self.playhead += (output.len() / self.channels as usize) as u64; @@ -756,6 +770,10 @@ impl Engine { } } + Command::SetMetronomeEnabled(enabled) => { + self.metronome.set_enabled(enabled); + } + // Node graph commands Command::GraphAddNode(track_id, node_type, x, y) => { eprintln!("[DEBUG] GraphAddNode received: track_id={}, node_type={}, x={}, y={}", track_id, node_type, x, y); @@ -2143,6 +2161,11 @@ impl EngineController { let _ = self.command_tx.push(Command::SetActiveMidiTrack(track_id)); } + /// Enable or disable the metronome click track + pub fn set_metronome_enabled(&mut self, enabled: bool) { + let _ = self.command_tx.push(Command::SetMetronomeEnabled(enabled)); + } + // Node graph operations /// Add a node to a track's instrument graph diff --git a/daw-backend/src/audio/metronome.rs b/daw-backend/src/audio/metronome.rs new file mode 100644 index 0000000..f50b29b --- /dev/null +++ b/daw-backend/src/audio/metronome.rs @@ -0,0 +1,168 @@ +/// Metronome for providing click track during playback +pub struct Metronome { + enabled: bool, + bpm: f32, + time_signature_numerator: u32, + time_signature_denominator: u32, + last_beat: i64, // Last beat number that was played (-1 = none) + + // Pre-generated click samples (mono) + high_click: Vec, // Accent click for first beat + low_click: Vec, // Normal click for other beats + + // Click playback state + click_position: usize, // Current position in the click sample (0 = not playing) + playing_high_click: bool, // Which click we're currently playing + + sample_rate: u32, +} + +impl Metronome { + /// Create a new metronome with pre-generated click sounds + pub fn new(sample_rate: u32) -> Self { + let (high_click, low_click) = Self::generate_clicks(sample_rate); + + Self { + enabled: false, + bpm: 120.0, + time_signature_numerator: 4, + time_signature_denominator: 4, + last_beat: -1, + high_click, + low_click, + click_position: 0, + playing_high_click: false, + sample_rate, + } + } + + /// Generate woodblock-style click samples + fn generate_clicks(sample_rate: u32) -> (Vec, Vec) { + let click_duration_ms = 10.0; // 10ms click + let click_samples = ((sample_rate as f32 * click_duration_ms) / 1000.0) as usize; + + // High click (accent): 1200 Hz + 2400 Hz (higher pitched woodblock) + let high_freq1 = 1200.0; + let high_freq2 = 2400.0; + let mut high_click = Vec::with_capacity(click_samples); + + for i in 0..click_samples { + let t = i as f32 / sample_rate as f32; + let envelope = 1.0 - (i as f32 / click_samples as f32); // Linear decay + let envelope = envelope * envelope; // Square for faster decay + + // Mix two sine waves for woodblock character + let sample = 0.3 * (2.0 * std::f32::consts::PI * high_freq1 * t).sin() + + 0.2 * (2.0 * std::f32::consts::PI * high_freq2 * t).sin(); + + // Add a bit of noise for attack transient + let noise = (i as f32 * 0.1).sin() * 0.1; + + high_click.push((sample + noise) * envelope * 0.5); // Scale down to avoid clipping + } + + // Low click: 800 Hz + 1600 Hz (lower pitched woodblock) + let low_freq1 = 800.0; + let low_freq2 = 1600.0; + let mut low_click = Vec::with_capacity(click_samples); + + for i in 0..click_samples { + let t = i as f32 / sample_rate as f32; + let envelope = 1.0 - (i as f32 / click_samples as f32); + let envelope = envelope * envelope; + + let sample = 0.3 * (2.0 * std::f32::consts::PI * low_freq1 * t).sin() + + 0.2 * (2.0 * std::f32::consts::PI * low_freq2 * t).sin(); + + let noise = (i as f32 * 0.1).sin() * 0.1; + + low_click.push((sample + noise) * envelope * 0.4); // Slightly quieter than high click + } + + (high_click, low_click) + } + + /// Enable or disable the metronome + pub fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + if !enabled { + self.last_beat = -1; // Reset beat tracking when disabled + self.click_position = 0; // Stop any playing click + } else { + // When enabling, don't trigger a click until the next beat + self.click_position = usize::MAX; // Set to max to prevent immediate click + } + } + + /// Update BPM and time signature + pub fn update_timing(&mut self, bpm: f32, time_signature: (u32, u32)) { + self.bpm = bpm; + self.time_signature_numerator = time_signature.0; + self.time_signature_denominator = time_signature.1; + } + + /// Process audio and mix in metronome clicks + pub fn process( + &mut self, + output: &mut [f32], + playhead_samples: u64, + playing: bool, + sample_rate: u32, + channels: u32, + ) { + if !self.enabled || !playing { + self.click_position = 0; // Reset if not playing + return; + } + + let frames = output.len() / channels as usize; + + for frame in 0..frames { + let current_sample = playhead_samples + frame as u64; + + // Calculate current beat number + let current_time_seconds = current_sample as f64 / sample_rate as f64; + let beats_per_second = self.bpm as f64 / 60.0; + let current_beat = (current_time_seconds * beats_per_second).floor() as i64; + + // Check if we crossed a beat boundary + if current_beat != self.last_beat && current_beat >= 0 { + self.last_beat = current_beat; + + // Only trigger a click if we're not in the "just enabled" state + if self.click_position != usize::MAX { + // Determine which click to play + // Beat 1 of each measure gets the accent (high click) + let beat_in_measure = (current_beat as u32 % self.time_signature_numerator) as usize; + let is_first_beat = beat_in_measure == 0; + + // Start playing the appropriate click + self.playing_high_click = is_first_beat; + self.click_position = 0; // Start from beginning of click + } else { + // We just got enabled - reset position but don't play yet + self.click_position = self.high_click.len(); // Set past end so no click plays + } + } + + // Continue playing click sample if we're currently in one + let click = if self.playing_high_click { + &self.high_click + } else { + &self.low_click + }; + + if self.click_position < click.len() { + let click_sample = click[self.click_position]; + + // Mix into all channels + for ch in 0..channels as usize { + let output_idx = frame * channels as usize + ch; + output[output_idx] += click_sample; + } + + self.click_position += 1; + } + } + } +} diff --git a/daw-backend/src/audio/mod.rs b/daw-backend/src/audio/mod.rs index c0c0e20..c2e8bdc 100644 --- a/daw-backend/src/audio/mod.rs +++ b/daw-backend/src/audio/mod.rs @@ -3,6 +3,7 @@ pub mod bpm_detector; pub mod buffer_pool; pub mod clip; pub mod engine; +pub mod metronome; pub mod midi; pub mod node_graph; pub mod pool; @@ -15,6 +16,7 @@ pub use automation::{AutomationLane, AutomationLaneId, AutomationPoint, CurveTyp pub use buffer_pool::BufferPool; pub use clip::{Clip, ClipId}; pub use engine::{Engine, EngineController}; +pub use metronome::Metronome; pub use midi::{MidiClip, MidiClipId, MidiEvent}; pub use pool::{AudioFile as PoolAudioFile, AudioPool}; pub use project::Project; diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 099bdad..665c38a 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -116,6 +116,10 @@ pub enum Command { /// Set the active MIDI track for external MIDI input routing (track_id or None) SetActiveMidiTrack(Option), + // Metronome command + /// Enable or disable the metronome click track + SetMetronomeEnabled(bool), + // Node graph commands /// Add a node to a track's instrument graph (track_id, node_type, position_x, position_y) GraphAddNode(TrackId, String, f32, f32), diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 9578d37..4e553a6 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -201,6 +201,20 @@ pub async fn audio_stop(state: tauri::State<'_, Arc>>) -> Resu } } +#[tauri::command] +pub async fn set_metronome_enabled( + state: tauri::State<'_, Arc>>, + enabled: bool +) -> Result<(), String> { + let mut audio_state = state.lock().unwrap(); + if let Some(controller) = &mut audio_state.controller { + controller.set_metronome_enabled(enabled); + Ok(()) + } else { + Err("Audio not initialized".to_string()) + } +} + #[tauri::command] pub async fn audio_test_beep(state: tauri::State<'_, Arc>>) -> Result<(), String> { let mut audio_state = state.lock().unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 29902ff..d598b1b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -197,6 +197,7 @@ pub fn run() { audio::audio_reset, audio::audio_play, audio::audio_stop, + audio::set_metronome_enabled, audio::audio_seek, audio::audio_test_beep, audio::audio_set_track_parameter, diff --git a/src/assets/metronome.svg b/src/assets/metronome.svg new file mode 100644 index 0000000..4a2304a --- /dev/null +++ b/src/assets/metronome.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/main.js b/src/main.js index 7e44a11..8ee39a5 100644 --- a/src/main.js +++ b/src/main.js @@ -1608,6 +1608,10 @@ async function _newFile(width, height, fps, layoutKey) { // Set default time format to measures for music mode if (layoutKey === 'audioDaw' && context.timelineWidget?.timelineState) { context.timelineWidget.timelineState.timeFormat = 'measures'; + // Show metronome button for audio projects + if (context.metronomeGroup) { + context.metronomeGroup.style.display = ''; + } } } @@ -4536,6 +4540,54 @@ function timelineV2() { controls.push(recordGroup); + // Metronome button (only visible in measures mode) + const metronomeGroup = document.createElement("div"); + metronomeGroup.className = "playback-controls-group"; + + // Initially hide if not in measures mode + if (timelineWidget.timelineState.timeFormat !== 'measures') { + metronomeGroup.style.display = 'none'; + } + + const metronomeButton = document.createElement("button"); + metronomeButton.className = context.metronomeEnabled + ? "playback-btn playback-btn-metronome active" + : "playback-btn playback-btn-metronome"; + metronomeButton.title = context.metronomeEnabled ? "Disable Metronome" : "Enable Metronome"; + + // Load SVG inline for currentColor support + (async () => { + try { + const response = await fetch('./assets/metronome.svg'); + const svgText = await response.text(); + metronomeButton.innerHTML = svgText; + } catch (error) { + console.error('Failed to load metronome icon:', error); + } + })(); + + metronomeButton.addEventListener("click", async () => { + context.metronomeEnabled = !context.metronomeEnabled; + const { invoke } = window.__TAURI__.core; + try { + await invoke('set_metronome_enabled', { enabled: context.metronomeEnabled }); + // Update button appearance + metronomeButton.className = context.metronomeEnabled + ? "playback-btn playback-btn-metronome active" + : "playback-btn playback-btn-metronome"; + metronomeButton.title = context.metronomeEnabled ? "Disable Metronome" : "Enable Metronome"; + } catch (error) { + console.error('Failed to set metronome:', error); + } + }); + metronomeGroup.appendChild(metronomeButton); + + // Store reference for state updates and visibility toggling + context.metronomeButton = metronomeButton; + context.metronomeGroup = metronomeGroup; + + controls.push(metronomeGroup); + // Time display const timeDisplay = document.createElement("div"); timeDisplay.className = "time-display"; @@ -4610,6 +4662,10 @@ function timelineV2() { timelineWidget.toggleTimeFormat(); updateTimeDisplay(); updateCanvasSize(); + // Update metronome button visibility + if (context.metronomeGroup) { + context.metronomeGroup.style.display = timelineWidget.timelineState.timeFormat === 'measures' ? '' : 'none'; + } return; } @@ -4620,6 +4676,10 @@ function timelineV2() { timelineWidget.toggleTimeFormat(); updateTimeDisplay(); updateCanvasSize(); + // Update metronome button visibility + if (context.metronomeGroup) { + context.metronomeGroup.style.display = timelineWidget.timelineState.timeFormat === 'measures' ? '' : 'none'; + } } else if (action === 'edit-fps') { // Clicked on FPS - show input to edit framerate console.log('[FPS Edit] Starting FPS edit'); @@ -10722,6 +10782,13 @@ function switchLayout(layoutKey) { updateLayers(); updateMenu(); + // Update metronome button visibility based on timeline format + // (especially important when switching to audioDaw layout) + if (context.metronomeGroup && context.timelineWidget?.timelineState) { + const shouldShow = context.timelineWidget.timelineState.timeFormat === 'measures'; + context.metronomeGroup.style.display = shouldShow ? '' : 'none'; + } + console.log(`Layout switched to: ${layoutDef.name}`); } catch (error) { console.error(`Error switching layout:`, error); diff --git a/src/state.js b/src/state.js index 603bd6b..ca55b54 100644 --- a/src/state.js +++ b/src/state.js @@ -43,6 +43,10 @@ export let context = { playPauseButton: null, // Reference to play/pause button for updating appearance // MIDI activity indicator lastMidiInputTime: 0, // Timestamp (Date.now()) of last MIDI input + // Metronome state + metronomeEnabled: false, + metronomeButton: null, // Reference to metronome button for updating appearance + metronomeGroup: null, // Reference to metronome button group for showing/hiding }; // Application configuration diff --git a/src/styles.css b/src/styles.css index b31d04d..701e424 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1022,6 +1022,24 @@ button { animation: pulse 1s ease-in-out infinite; } +/* Metronome Button - Inline SVG with currentColor */ +.playback-btn-metronome { + color: var(--text-primary); +} + +.playback-btn-metronome svg { + width: 18px; + height: 18px; + display: block; + margin: auto; +} + +/* Active metronome state - use highlight color */ +.playback-btn-metronome.active { + background-color: var(--highlight); + border-color: var(--highlight); +} + /* Dark mode playback button adjustments */ @media (prefers-color-scheme: dark) { .playback-btn { diff --git a/src/widgets.js b/src/widgets.js index b5aeda8..940a0db 100644 --- a/src/widgets.js +++ b/src/widgets.js @@ -814,27 +814,11 @@ class TimelineWindowV2 extends Widget { const fadeTime = 1000 // Fade out over 1 second (increased for visibility) if (elapsed < fadeTime) { - // const mindicatorSize = 12 // Made larger - // const mindicatorX = this.trackHeaderWidth - 35 // Position to the left of buttons - // const mindicatorY = y + this.trackHierarchy.trackHeight / 2 - - // console.log(`[MIDI mIndicator] Drawing at (${mindicatorX}, ${mindicatorY}) with alpha ${1}`) - - // // Draw pulsing circle with border - // ctx.strokeStyle = `rgba(0, 255, 0, ${1})` - // ctx.fillStyle = `rgba(0, 255, 0, ${1 * 0.5})` - // ctx.lineWidth = 2 - // ctx.beginPath() - // ctx.arc(mindicatorX, mindicatorY, mindicatorSize / 2, 0, Math.PI * 2) - // ctx.fill() - // ctx.stroke() - const alpha = Math.max(0.2, 1 - (elapsed / fadeTime)) // Minimum alpha of 0.3 for visibility const indicatorSize = 10 const indicatorX = this.trackHeaderWidth - 35 // Position to the left of buttons const indicatorY = y + this.trackHierarchy.trackHeight / 2 - console.log(`[MIDI Indicator] Drawing at (${indicatorX}, ${indicatorY}) with alpha ${alpha}`) // Draw pulsing circle with border ctx.strokeStyle = `rgba(0, 255, 0, ${alpha})`