diff --git a/Changelog.md b/Changelog.md index af0e374..1415c48 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,3 +1,17 @@ +# 0.8.1-alpha: +Changes: +- Rewrite timeline UI +- Add start screen +- Move audio engine to backend +- Add node editor for audio synthesis +- Add factory presets for instruments +- Add MIDI input support +- Add BPM handling and time signature +- Add metronome +- Add preset layouts for different tasks +- Add video import +- Add animation curves for object properties + # 0.7.14-alpha: Changes: - Moving frames can now be undone diff --git a/README.md b/README.md index cc7d386..a36af27 100644 --- a/README.md +++ b/README.md @@ -1,133 +1,51 @@ # Lightningbeam -An open-source vector animation tool built with Tauri. A spiritual successor to Macromedia Flash 8 / Adobe Animate. +A free and open-source 2D multimedia editor combining vector animation, audio production, and video editing in a single application. -[![Version](https://img.shields.io/badge/version-0.7.14--alpha-orange)](https://github.com/skykooler/Lightningbeam/releases) +## Screenshots -## Overview +![Animation View](screenshots/animation.png) -Lightningbeam is a cross-platform vector animation application for creating keyframe-based animations and interactive content. Originally started in 2010 as an open-source alternative to Adobe Flash, the project has been rewritten using modern web technologies (JavaScript/Canvas) with a Tauri-based native desktop application wrapper. +![Music Editing View](screenshots/music.png) -## Current Status +![Video Editing View](screenshots/video.png) -**⚠️ Alpha Software**: Lightningbeam is in active development and not yet feature-complete. The codebase is currently undergoing significant refactoring, particularly the timeline system which is being migrated from frame-based to curve-based animation. +## Current Features -Current branch `new_timeline` implements a major timeline redesign inspired by GarageBand, featuring hierarchical tracks and animation curve visualization. +**Vector Animation** +- Draw and animate vector shapes with keyframe-based timeline +- Non-destructive editing workflow -## Features +**Audio Production** +- Multi-track audio recording +- MIDI sequencing with synthesized and sampled instruments +- Integrated DAW functionality -Current functionality includes: +**Video Editing** +- Basic video timeline and editing (early stage) +- FFmpeg-based video decoding -- **Vector Drawing Tools**: Pen, brush, line, rectangle, ellipse, polygon tools -- **Keyframe Animation**: Timeline-based animation with interpolation -- **Shape Tweening**: Morph between different vector shapes -- **Motion Tweening**: Smooth object movement with curve-based interpolation -- **Layer System**: Multiple layers with visibility controls -- **Hierarchical Objects**: Group objects and edit nested timelines -- **Audio Support**: Import MP3 audio files -- **Video Export**: Export animations as MP4 or WebM -- **Transform Tools**: Move, rotate, and scale objects -- **Color Tools**: Color picker, paint bucket with flood fill -- **Undo/Redo**: Full history management -- **Copy/Paste**: Duplicate objects and keyframes +## Technical Stack -## Installation +- **Frontend:** Vanilla JavaScript +- **Backend:** Rust (Tauri framework) +- **Audio:** cpal + dasp for audio processing +- **Video:** FFmpeg for encode/decode -### Pre-built Releases +## Project Status -Download the latest release for your platform from the [Releases page](https://github.com/skykooler/Lightningbeam/releases). +Lightningbeam is under active development. Current focus is on core functionality and architecture. Full project export is not yet fully implemented. -Supported platforms: -- Linux (AppImage, .deb, .rpm) -- macOS -- Windows -- Web (limited functionality) +### Known Architectural Challenge -### Building from Source +The current Tauri implementation hits IPC bandwidth limitations when streaming decoded video frames from Rust to JavaScript. Tauri's IPC layer has significant serialization overhead (~few MB/s), which is insufficient for real-time high-resolution video rendering. -**Prerequisites:** -- [pnpm](https://pnpm.io/) package manager -- [Rust](https://rustup.rs/) toolchain (installed automatically by Tauri) -- Platform-specific dependencies for Tauri (see [Tauri Prerequisites](https://tauri.app/v1/guides/getting-started/prerequisites)) +I'm currently exploring a full Rust rewrite using wgpu/egui to eliminate the IPC bottleneck and handle rendering entirely in native code. -**Build steps:** +## Project History -```bash -# Clone the repository -git clone https://github.com/skykooler/Lightningbeam.git -cd Lightningbeam +Lightningbeam evolved from earlier multimedia editing projects I've worked on since 2010, including the FreeJam DAW. The current JavaScript/Tauri iteration began in November 2023. -# Install dependencies -pnpm install +## Goals -# Run in development mode -pnpm tauri dev - -# Build for production -pnpm tauri build -``` - -**Note for Linux users:** `pnpm tauri dev` works on any distribution, but `pnpm tauri build` currently only works on Ubuntu due to limitations in Tauri's AppImage generation. If you're on a non-Ubuntu distro, you can build in an Ubuntu container/VM or use the development mode instead. - -## Quick Start - -1. Launch Lightningbeam -2. Create a new file (File → New) -3. Select a drawing tool from the toolbar -4. Draw shapes on the canvas -5. Create keyframes on the timeline to animate objects -6. Use motion or shape tweens to interpolate between keyframes -7. Export your animation (File → Export → Video) - -## File Format - -Lightningbeam uses the `.beam` file extension. Files are stored in JSON format and contain all project data including vector shapes, keyframes, layers, and animation curves. - -**Note**: The file format specification is not yet documented and may change during development. - -## Known Limitations - -### Platform-Specific Issues - -- **Linux**: Pinch-to-zoom gestures zoom the entire window instead of individual canvases. This is a [known Tauri/GTK WebView limitation](https://github.com/tauri-apps/tauri/discussions/3843) with no current workaround. -- **macOS**: Limited testing; some platform-specific bugs may exist. -- **Windows**: Minimal testing; application has been confirmed to run but may have undiscovered issues. - -### Web Version Limitations - -The web version has several limitations compared to desktop: -- Restricted file system access -- Keyboard shortcut conflicts with browser -- Higher audio latency -- No native file association -- Memory limitations with video export - -### General Limitations - -- The current timeline system is being replaced; legacy frame-based features may be unstable -- Many features and optimizations are still in development -- Performance benchmarking has not been completed -- File format may change between versions - -## Contributing - -Contributions are currently limited while the codebase undergoes restructuring. Once the timeline refactor is complete and the code is better organized, the project will be more open to external contributions. - -If you encounter bugs or have feature requests, please open an issue on GitHub. - -## Credits - -Lightningbeam is built with: -- [Tauri](https://tauri.app/) - Desktop application framework -- [FFmpeg](https://ffmpeg.org/) - Video encoding/decoding -- Various JavaScript libraries for drawing, compression, and utilities - -## License - -**License not yet determined.** The author is considering the MIT License for maximum simplicity and adoption. Contributors should await license clarification before submitting code. - ---- - -**Repository**: https://github.com/skykooler/Lightningbeam -**Version**: 0.7.14-alpha -**Status**: Active Development +Create a comprehensive FOSS alternative for 2D-focused multimedia work, integrating animation, audio, and video editing in a unified workflow. \ No newline at end of file diff --git a/daw-backend/Cargo.lock b/daw-backend/Cargo.lock index c30f2ac..58ba99d 100644 --- a/daw-backend/Cargo.lock +++ b/daw-backend/Cargo.lock @@ -457,6 +457,7 @@ dependencies = [ "dasp_rms", "dasp_sample", "dasp_signal", + "hound", "midir", "midly", "pathdiff", @@ -578,6 +579,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + [[package]] name = "indexmap" version = "1.9.3" diff --git a/daw-backend/Cargo.toml b/daw-backend/Cargo.toml index 7a67cc8..4482a83 100644 --- a/daw-backend/Cargo.toml +++ b/daw-backend/Cargo.toml @@ -16,6 +16,11 @@ rand = "0.8" base64 = "0.22" pathdiff = "0.2" +# Audio export +hound = "3.5" +# TODO: Add MP3 support with a different crate +# mp3lame-encoder API is too complex, need to find a better option + # Node-based audio graph dependencies dasp_graph = "0.11" dasp_signal = "0.11" diff --git a/daw-backend/src/audio/clip.rs b/daw-backend/src/audio/clip.rs index b5749a8..6ec1c8b 100644 --- a/daw-backend/src/audio/clip.rs +++ b/daw-backend/src/audio/clip.rs @@ -1,21 +1,68 @@ -/// Clip ID type -pub type ClipId = u32; +/// Audio clip instance ID type +pub type AudioClipInstanceId = u32; -/// Audio clip that references data in the AudioPool +/// Type alias for backwards compatibility +pub type ClipId = AudioClipInstanceId; + +/// Audio clip instance that references content in the AudioClipPool +/// +/// This represents a placed instance of audio content on the timeline. +/// The actual audio data is stored in the AudioClipPool and referenced by `audio_pool_index`. +/// +/// ## Timing Model +/// - `internal_start` / `internal_end`: Define the region of the source audio to play (trimming) +/// - `external_start` / `external_duration`: Define where the clip appears on the timeline and how long +/// +/// ## Looping +/// If `external_duration` is greater than `internal_end - internal_start`, +/// the clip will seamlessly loop back to `internal_start` when it reaches `internal_end`. #[derive(Debug, Clone)] -pub struct Clip { - pub id: ClipId, +pub struct AudioClipInstance { + pub id: AudioClipInstanceId, pub audio_pool_index: usize, - pub start_time: f64, // Position on timeline in seconds - pub duration: f64, // Clip duration in seconds - pub offset: f64, // Offset into audio file in seconds - pub gain: f32, // Clip-level gain + + /// Start position within the audio content (seconds) + pub internal_start: f64, + /// End position within the audio content (seconds) + pub internal_end: f64, + + /// Start position on the timeline (seconds) + pub external_start: f64, + /// Duration on the timeline (seconds) - can be longer than internal duration for looping + pub external_duration: f64, + + /// Clip-level gain + pub gain: f32, } -impl Clip { - /// Create a new clip +/// Type alias for backwards compatibility +pub type Clip = AudioClipInstance; + +impl AudioClipInstance { + /// Create a new audio clip instance pub fn new( - id: ClipId, + id: AudioClipInstanceId, + audio_pool_index: usize, + internal_start: f64, + internal_end: f64, + external_start: f64, + external_duration: f64, + ) -> Self { + Self { + id, + audio_pool_index, + internal_start, + internal_end, + external_start, + external_duration, + gain: 1.0, + } + } + + /// Create a clip instance from legacy parameters (for backwards compatibility) + /// Maps old start_time/duration/offset to new timing model + pub fn from_legacy( + id: AudioClipInstanceId, audio_pool_index: usize, start_time: f64, duration: f64, @@ -24,22 +71,64 @@ impl Clip { Self { id, audio_pool_index, - start_time, - duration, - offset, + internal_start: offset, + internal_end: offset + duration, + external_start: start_time, + external_duration: duration, gain: 1.0, } } - /// Check if this clip is active at a given timeline position + /// Check if this clip instance is active at a given timeline position pub fn is_active_at(&self, time_seconds: f64) -> bool { - let clip_end = self.start_time + self.duration; - time_seconds >= self.start_time && time_seconds < clip_end + time_seconds >= self.external_start && time_seconds < self.external_end() } - /// Get the end time of this clip on the timeline + /// Get the end time of this clip instance on the timeline + pub fn external_end(&self) -> f64 { + self.external_start + self.external_duration + } + + /// Get the end time of this clip instance on the timeline + /// (Alias for external_end(), for backwards compatibility) pub fn end_time(&self) -> f64 { - self.start_time + self.duration + self.external_end() + } + + /// Get the start time on the timeline + /// (Alias for external_start, for backwards compatibility) + pub fn start_time(&self) -> f64 { + self.external_start + } + + /// Get the internal (content) duration + pub fn internal_duration(&self) -> f64 { + self.internal_end - self.internal_start + } + + /// Check if this clip instance loops + pub fn is_looping(&self) -> bool { + self.external_duration > self.internal_duration() + } + + /// Get the position within the audio content for a given timeline position + /// Returns None if the timeline position is outside this clip instance + /// Handles looping automatically + pub fn get_content_position(&self, timeline_pos: f64) -> Option { + if timeline_pos < self.external_start || timeline_pos >= self.external_end() { + return None; + } + + let relative_pos = timeline_pos - self.external_start; + let internal_duration = self.internal_duration(); + + if internal_duration <= 0.0 { + return None; + } + + // Wrap around for looping + let content_offset = relative_pos % internal_duration; + Some(self.internal_start + content_offset) } /// Set clip gain diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index 2133750..fa5639d 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -1,8 +1,9 @@ use crate::audio::buffer_pool::BufferPool; -use crate::audio::clip::ClipId; -use crate::audio::midi::{MidiClip, MidiClipId, MidiEvent}; +use crate::audio::clip::{AudioClipInstance, ClipId}; +use crate::audio::metronome::Metronome; +use crate::audio::midi::{MidiClip, MidiClipId, MidiClipInstance, MidiEvent}; use crate::audio::node_graph::{nodes::*, AudioGraph}; -use crate::audio::pool::AudioPool; +use crate::audio::pool::AudioClipPool; use crate::audio::project::Project; use crate::audio::recording::{MidiRecordingState, RecordingState}; use crate::audio::track::{Track, TrackId, TrackNode}; @@ -15,7 +16,7 @@ use std::sync::Arc; /// Audio engine for Phase 6: hierarchical tracks with groups pub struct Engine { project: Project, - audio_pool: AudioPool, + audio_pool: AudioClipPool, buffer_pool: BufferPool, playhead: u64, // Playhead position in samples sample_rate: u32, @@ -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 { @@ -74,7 +78,7 @@ impl Engine { Self { project: Project::new(sample_rate), - audio_pool: AudioPool::new(), + audio_pool: AudioClipPool::new(), buffer_pool: BufferPool::new(8, buffer_size), // 8 buffers should handle deep nesting playhead: 0, sample_rate, @@ -96,6 +100,7 @@ impl Engine { recording_progress_counter: 0, midi_recording_state: None, midi_input_manager: None, + metronome: Metronome::new(sample_rate), } } @@ -159,12 +164,12 @@ impl Engine { } /// Get mutable reference to audio pool - pub fn audio_pool_mut(&mut self) -> &mut AudioPool { + pub fn audio_pool_mut(&mut self) -> &mut AudioClipPool { &mut self.audio_pool } /// Get reference to audio pool - pub fn audio_pool(&self) -> &AudioPool { + pub fn audio_pool(&self) -> &AudioClipPool { &self.audio_pool } @@ -235,9 +240,15 @@ impl Engine { let playhead_seconds = self.playhead as f64 / self.sample_rate as f64; // Render the entire project hierarchy into the mix buffer + // Note: We need to use a raw pointer to avoid borrow checker issues + // The midi_clip_pool is part of project, so we extract a reference before mutable borrow + let midi_pool_ptr = &self.project.midi_clip_pool as *const _; + // SAFETY: The midi_clip_pool is not mutated during render, only read + let midi_pool_ref = unsafe { &*midi_pool_ptr }; self.project.render( &mut self.mix_buffer, &self.audio_pool, + midi_pool_ref, &mut self.buffer_pool, playhead_seconds, self.sample_rate, @@ -247,6 +258,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; @@ -300,10 +320,12 @@ impl Engine { let clip_id = recording.clip_id; let track_id = recording.track_id; - // Update clip duration in project + // Update clip duration in project as recording progresses if let Some(crate::audio::track::TrackNode::Audio(track)) = self.project.get_track_mut(track_id) { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { - clip.duration = duration; + // Update both internal_end and external_duration as recording progresses + clip.internal_end = clip.internal_start + duration; + clip.external_duration = duration; } } @@ -370,33 +392,58 @@ impl Engine { } } Command::MoveClip(track_id, clip_id, new_start_time) => { + // Moving just changes external_start, external_duration stays the same match self.project.get_track_mut(track_id) { Some(crate::audio::track::TrackNode::Audio(track)) => { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { - clip.start_time = new_start_time; + clip.external_start = new_start_time; } } Some(crate::audio::track::TrackNode::Midi(track)) => { - if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { - clip.start_time = new_start_time; + // Note: clip_id here is the pool clip ID, not instance ID + if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) { + instance.external_start = new_start_time; } } _ => {} } } - Command::TrimClip(track_id, clip_id, new_start_time, new_duration, new_offset) => { + Command::TrimClip(track_id, clip_id, new_internal_start, new_internal_end) => { + // Trim changes which portion of the source content is used + // Also updates external_duration to match internal duration (no looping after trim) match self.project.get_track_mut(track_id) { Some(crate::audio::track::TrackNode::Audio(track)) => { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { - clip.start_time = new_start_time; - clip.duration = new_duration; - clip.offset = new_offset; + clip.internal_start = new_internal_start; + clip.internal_end = new_internal_end; + // By default, trimming sets external_duration to match internal duration + clip.external_duration = new_internal_end - new_internal_start; } } Some(crate::audio::track::TrackNode::Midi(track)) => { + // Note: clip_id here is the pool clip ID, not instance ID + if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) { + instance.internal_start = new_internal_start; + instance.internal_end = new_internal_end; + // By default, trimming sets external_duration to match internal duration + instance.external_duration = new_internal_end - new_internal_start; + } + } + _ => {} + } + } + Command::ExtendClip(track_id, clip_id, new_external_duration) => { + // Extend changes the external duration (enables looping if > internal duration) + match self.project.get_track_mut(track_id) { + Some(crate::audio::track::TrackNode::Audio(track)) => { if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { - clip.start_time = new_start_time; - clip.duration = new_duration; + clip.external_duration = new_external_duration; + } + } + Some(crate::audio::track::TrackNode::Midi(track)) => { + // Note: clip_id here is the pool clip ID, not instance ID + if let Some(instance) = track.clip_instances.iter_mut().find(|c| c.clip_id == clip_id) { + instance.external_duration = new_external_duration; } } _ => {} @@ -461,10 +508,10 @@ impl Engine { pool_index, pool_size); } - // Create a new clip with unique ID + // Create a new clip instance with unique ID using legacy parameters let clip_id = self.next_clip_id; self.next_clip_id += 1; - let clip = crate::audio::clip::Clip::new( + let clip = AudioClipInstance::from_legacy( clip_id, pool_index, start_time, @@ -490,55 +537,74 @@ impl Engine { Command::CreateMidiClip(track_id, start_time, duration) => { // Get the next MIDI clip ID from the atomic counter let clip_id = self.next_midi_clip_id_atomic.fetch_add(1, Ordering::Relaxed); - let clip = MidiClip::new(clip_id, start_time, duration); - let _ = self.project.add_midi_clip(track_id, clip); - // Notify UI about the new clip with its ID + + // Create clip content in the pool + let clip = MidiClip::empty(clip_id, duration, format!("MIDI Clip {}", clip_id)); + self.project.midi_clip_pool.add_existing_clip(clip); + + // Create an instance for this clip on the track + let instance_id = self.project.next_midi_clip_instance_id(); + let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, duration, start_time); + + if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + track.clip_instances.push(instance); + } + + // Notify UI about the new clip with its ID (using clip_id for now) let _ = self.event_tx.push(AudioEvent::ClipAdded(track_id, clip_id)); } Command::AddMidiNote(track_id, clip_id, time_offset, note, velocity, duration) => { - // Add a MIDI note event to the specified clip - if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { - if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { - // Timestamp is now in seconds (sample-rate independent) - let note_on = MidiEvent::note_on(time_offset, 0, note, velocity); - clip.events.push(note_on); + // Add a MIDI note event to the specified clip in the pool + // Note: clip_id here refers to the clip in the pool, not the instance + if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) { + // Timestamp is now in seconds (sample-rate independent) + let note_on = MidiEvent::note_on(time_offset, 0, note, velocity); + clip.add_event(note_on); - // Add note off event - let note_off_time = time_offset + duration; - let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); - clip.events.push(note_off); - - // Sort events by timestamp (using partial_cmp for f64) - clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); + // Add note off event + let note_off_time = time_offset + duration; + let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); + clip.add_event(note_off); + } else { + // Try legacy behavior: look for instance on track and find its clip + if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + if let Some(instance) = track.clip_instances.iter().find(|c| c.clip_id == clip_id) { + let actual_clip_id = instance.clip_id; + if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(actual_clip_id) { + let note_on = MidiEvent::note_on(time_offset, 0, note, velocity); + clip.add_event(note_on); + let note_off_time = time_offset + duration; + let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); + clip.add_event(note_off); + } + } } } } - Command::AddLoadedMidiClip(track_id, clip) => { - // Add a pre-loaded MIDI clip to the track - let _ = self.project.add_midi_clip(track_id, clip); + Command::AddLoadedMidiClip(track_id, clip, start_time) => { + // Add a pre-loaded MIDI clip to the track with the given start time + let _ = self.project.add_midi_clip_at(track_id, clip, start_time); } - Command::UpdateMidiClipNotes(track_id, clip_id, notes) => { - // Update all notes in a MIDI clip - if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { - if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { - // Clear existing events - clip.events.clear(); + Command::UpdateMidiClipNotes(_track_id, clip_id, notes) => { + // Update all notes in a MIDI clip (directly in the pool) + if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) { + // Clear existing events + clip.events.clear(); - // Add new events from the notes array - // Timestamps are now stored in seconds (sample-rate independent) - for (start_time, note, velocity, duration) in notes { - let note_on = MidiEvent::note_on(start_time, 0, note, velocity); - clip.events.push(note_on); + // Add new events from the notes array + // Timestamps are now stored in seconds (sample-rate independent) + for (start_time, note, velocity, duration) in notes { + let note_on = MidiEvent::note_on(start_time, 0, note, velocity); + clip.events.push(note_on); - // Add note off event - let note_off_time = start_time + duration; - let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); - clip.events.push(note_off); - } - - // Sort events by timestamp (using partial_cmp for f64) - clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); + // Add note off event + let note_off_time = start_time + duration; + let note_off = MidiEvent::note_off(note_off_time, 0, note, 64); + clip.events.push(note_off); } + + // Sort events by timestamp (using partial_cmp for f64) + clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); } } Command::RequestBufferPoolStats => { @@ -714,7 +780,7 @@ impl Engine { self.project = Project::new(self.sample_rate); // Clear audio pool - self.audio_pool = AudioPool::new(); + self.audio_pool = AudioClipPool::new(); // Reset buffer pool (recreate with same settings) let buffer_size = 512 * self.channels as usize; @@ -774,6 +840,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); @@ -1096,11 +1166,20 @@ impl Engine { // Write to file if let Ok(json) = preset.to_json() { - if let Err(e) = std::fs::write(&preset_path, json) { - let _ = self.event_tx.push(AudioEvent::GraphConnectionError( - track_id, - format!("Failed to save preset: {}", e) - )); + match std::fs::write(&preset_path, json) { + Ok(_) => { + // Emit success event with path + let _ = self.event_tx.push(AudioEvent::GraphPresetSaved( + track_id, + preset_path.clone() + )); + } + Err(e) => { + let _ = self.event_tx.push(AudioEvent::GraphConnectionError( + track_id, + format!("Failed to save preset: {}", e) + )); + } } } else { let _ = self.event_tx.push(AudioEvent::GraphConnectionError( @@ -1212,7 +1291,7 @@ impl Engine { } } - Command::MultiSamplerAddLayer(track_id, node_id, file_path, key_min, key_max, root_key, velocity_min, velocity_max) => { + Command::MultiSamplerAddLayer(track_id, node_id, file_path, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode) => { use crate::audio::node_graph::nodes::MultiSamplerNode; if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { @@ -1226,7 +1305,7 @@ impl Engine { unsafe { let multi_sampler_node = &mut *node_ptr; - if let Err(e) = multi_sampler_node.load_layer_from_file(&file_path, key_min, key_max, root_key, velocity_min, velocity_max) { + if let Err(e) = multi_sampler_node.load_layer_from_file(&file_path, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode) { eprintln!("Failed to add sample layer: {}", e); } } @@ -1234,7 +1313,7 @@ impl Engine { } } - Command::MultiSamplerUpdateLayer(track_id, node_id, layer_index, key_min, key_max, root_key, velocity_min, velocity_max) => { + Command::MultiSamplerUpdateLayer(track_id, node_id, layer_index, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode) => { use crate::audio::node_graph::nodes::MultiSamplerNode; if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { @@ -1248,7 +1327,7 @@ impl Engine { unsafe { let multi_sampler_node = &mut *node_ptr; - if let Err(e) = multi_sampler_node.update_layer(layer_index, key_min, key_max, root_key, velocity_min, velocity_max) { + if let Err(e) = multi_sampler_node.update_layer(layer_index, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode) { eprintln!("Failed to update sample layer: {}", e); } } @@ -1412,19 +1491,16 @@ impl Engine { ))), } } - Query::GetMidiClip(track_id, clip_id) => { - if let Some(TrackNode::Midi(track)) = self.project.get_track(track_id) { - if let Some(clip) = track.clips.iter().find(|c| c.id == clip_id) { - use crate::command::MidiClipData; - QueryResponse::MidiClipData(Ok(MidiClipData { - duration: clip.duration, - events: clip.events.clone(), - })) - } else { - QueryResponse::MidiClipData(Err(format!("Clip {} not found in track {}", clip_id, track_id))) - } + Query::GetMidiClip(_track_id, clip_id) => { + // Get MIDI clip data from the pool + if let Some(clip) = self.project.midi_clip_pool.get_clip(clip_id) { + use crate::command::MidiClipData; + QueryResponse::MidiClipData(Ok(MidiClipData { + duration: clip.duration, + events: clip.events.clone(), + })) } else { - QueryResponse::MidiClipData(Err(format!("Track {} not found or is not a MIDI track", track_id))) + QueryResponse::MidiClipData(Err(format!("Clip {} not found in pool", clip_id))) } } @@ -1592,6 +1668,17 @@ impl Engine { None => QueryResponse::PoolFileInfo(Err(format!("Pool index {} not found", pool_index))), } } + Query::ExportAudio(settings, output_path) => { + // Perform export directly - this will block the audio thread but that's okay + // since we're exporting and not playing back anyway + // Use raw pointer to get midi_pool reference before mutable borrow of project + let midi_pool_ptr: *const _ = &self.project.midi_clip_pool; + let midi_pool_ref = unsafe { &*midi_pool_ptr }; + match crate::audio::export_audio(&mut self.project, &self.audio_pool, midi_pool_ref, &settings, &output_path) { + Ok(()) => QueryResponse::AudioExported(Ok(())), + Err(e) => QueryResponse::AudioExported(Err(e)), + } + } }; // Send response back @@ -1623,9 +1710,10 @@ impl Engine { let clip = crate::audio::clip::Clip::new( clip_id, 0, // Temporary pool index, will be updated on finalization - start_time, - 0.0, // Duration starts at 0, will be updated during recording - 0.0, + 0.0, // internal_start + 0.0, // internal_end - Duration starts at 0, will be updated during recording + start_time, // external_start (timeline position) + start_time, // external_end - will be updated during recording ); // Add clip to track @@ -1784,42 +1872,47 @@ impl Engine { eprintln!("[MIDI_RECORDING] Stopping MIDI recording for clip_id={}, track_id={}, captured {} notes, duration={:.3}s", clip_id, track_id, note_count, recording_duration); - // Update the MIDI clip using the existing UpdateMidiClipNotes logic - eprintln!("[MIDI_RECORDING] Looking for track {} to update clip", track_id); - if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { - eprintln!("[MIDI_RECORDING] Found MIDI track, looking for clip {}", clip_id); - if let Some(clip) = track.clips.iter_mut().find(|c| c.id == clip_id) { - eprintln!("[MIDI_RECORDING] Found clip, clearing and adding {} notes", note_count); - // Clear existing events - clip.events.clear(); + // Update the MIDI clip in the pool (new model: clips are stored centrally in the pool) + eprintln!("[MIDI_RECORDING] Looking for clip {} in midi_clip_pool", clip_id); + if let Some(clip) = self.project.midi_clip_pool.get_clip_mut(clip_id) { + eprintln!("[MIDI_RECORDING] Found clip in pool, clearing and adding {} notes", note_count); + // Clear existing events + clip.events.clear(); - // Update clip duration to match the actual recording time - clip.duration = recording_duration; + // Update clip duration to match the actual recording time + clip.duration = recording_duration; - // Add new events from the recorded notes - // Timestamps are now stored in seconds (sample-rate independent) - for (start_time, note, velocity, duration) in notes.iter() { - let note_on = MidiEvent::note_on(*start_time, 0, *note, *velocity); + // Add new events from the recorded notes + // Timestamps are now stored in seconds (sample-rate independent) + for (start_time, note, velocity, duration) in notes.iter() { + let note_on = MidiEvent::note_on(*start_time, 0, *note, *velocity); - eprintln!("[MIDI_RECORDING] Note {}: start_time={:.3}s, duration={:.3}s", - note, start_time, duration); + eprintln!("[MIDI_RECORDING] Note {}: start_time={:.3}s, duration={:.3}s", + note, start_time, duration); - clip.events.push(note_on); + clip.events.push(note_on); - // Add note off event - let note_off_time = *start_time + *duration; - let note_off = MidiEvent::note_off(note_off_time, 0, *note, 64); - clip.events.push(note_off); + // Add note off event + let note_off_time = *start_time + *duration; + let note_off = MidiEvent::note_off(note_off_time, 0, *note, 64); + clip.events.push(note_off); + } + + // Sort events by timestamp (using partial_cmp for f64) + clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); + eprintln!("[MIDI_RECORDING] Updated clip {} with {} notes ({} events)", clip_id, note_count, clip.events.len()); + + // Also update the clip instance's internal_end and external_duration to match the recording duration + if let Some(crate::audio::track::TrackNode::Midi(track)) = self.project.get_track_mut(track_id) { + if let Some(instance) = track.clip_instances.iter_mut().find(|i| i.clip_id == clip_id) { + instance.internal_end = recording_duration; + instance.external_duration = recording_duration; + eprintln!("[MIDI_RECORDING] Updated clip instance timing: internal_end={:.3}s, external_duration={:.3}s", + instance.internal_end, instance.external_duration); } - - // Sort events by timestamp (using partial_cmp for f64) - clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); - eprintln!("[MIDI_RECORDING] Updated clip {} with {} notes ({} events)", clip_id, note_count, clip.events.len()); - } else { - eprintln!("[MIDI_RECORDING] ERROR: Clip {} not found on track!", clip_id); } } else { - eprintln!("[MIDI_RECORDING] ERROR: Track {} not found or not a MIDI track!", track_id); + eprintln!("[MIDI_RECORDING] ERROR: Clip {} not found in pool!", clip_id); } // Send event to UI @@ -1906,13 +1999,20 @@ impl EngineController { let _ = self.command_tx.push(Command::SetTrackSolo(track_id, solo)); } - /// Move a clip to a new timeline position + /// Move a clip to a new timeline position (changes external_start) pub fn move_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_start_time: f64) { let _ = self.command_tx.push(Command::MoveClip(track_id, clip_id, new_start_time)); } - pub fn trim_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_start_time: f64, new_duration: f64, new_offset: f64) { - let _ = self.command_tx.push(Command::TrimClip(track_id, clip_id, new_start_time, new_duration, new_offset)); + /// Trim a clip's internal boundaries (changes which portion of source content is used) + /// This also resets external_duration to match internal duration (disables looping) + pub fn trim_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_internal_start: f64, new_internal_end: f64) { + let _ = self.command_tx.push(Command::TrimClip(track_id, clip_id, new_internal_start, new_internal_end)); + } + + /// Extend or shrink a clip's external duration (enables looping if > internal duration) + pub fn extend_clip(&mut self, track_id: TrackId, clip_id: ClipId, new_external_duration: f64) { + let _ = self.command_tx.push(Command::ExtendClip(track_id, clip_id, new_external_duration)); } /// Send a generic command to the audio thread @@ -2036,9 +2136,9 @@ impl EngineController { let _ = self.command_tx.push(Command::AddMidiNote(track_id, clip_id, time_offset, note, velocity, duration)); } - /// Add a pre-loaded MIDI clip to a track - pub fn add_loaded_midi_clip(&mut self, track_id: TrackId, clip: MidiClip) { - let _ = self.command_tx.push(Command::AddLoadedMidiClip(track_id, clip)); + /// Add a pre-loaded MIDI clip to a track at the given timeline position + pub fn add_loaded_midi_clip(&mut self, track_id: TrackId, clip: MidiClip, start_time: f64) { + let _ = self.command_tx.push(Command::AddLoadedMidiClip(track_id, clip, start_time)); } /// Update all notes in a MIDI clip @@ -2165,6 +2265,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 @@ -2231,13 +2336,13 @@ impl EngineController { } /// Add a sample layer to a MultiSampler node - pub fn multi_sampler_add_layer(&mut self, track_id: TrackId, node_id: u32, file_path: String, key_min: u8, key_max: u8, root_key: u8, velocity_min: u8, velocity_max: u8) { - let _ = self.command_tx.push(Command::MultiSamplerAddLayer(track_id, node_id, file_path, key_min, key_max, root_key, velocity_min, velocity_max)); + pub fn multi_sampler_add_layer(&mut self, track_id: TrackId, node_id: u32, file_path: String, key_min: u8, key_max: u8, root_key: u8, velocity_min: u8, velocity_max: u8, loop_start: Option, loop_end: Option, loop_mode: crate::audio::node_graph::nodes::LoopMode) { + let _ = self.command_tx.push(Command::MultiSamplerAddLayer(track_id, node_id, file_path, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode)); } /// Update a MultiSampler layer's configuration - pub fn multi_sampler_update_layer(&mut self, track_id: TrackId, node_id: u32, layer_index: usize, key_min: u8, key_max: u8, root_key: u8, velocity_min: u8, velocity_max: u8) { - let _ = self.command_tx.push(Command::MultiSamplerUpdateLayer(track_id, node_id, layer_index, key_min, key_max, root_key, velocity_min, velocity_max)); + pub fn multi_sampler_update_layer(&mut self, track_id: TrackId, node_id: u32, layer_index: usize, key_min: u8, key_max: u8, root_key: u8, velocity_min: u8, velocity_max: u8, loop_start: Option, loop_end: Option, loop_mode: crate::audio::node_graph::nodes::LoopMode) { + let _ = self.command_tx.push(Command::MultiSamplerUpdateLayer(track_id, node_id, layer_index, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode)); } /// Remove a layer from a MultiSampler node @@ -2524,4 +2629,25 @@ impl EngineController { Err("Query timeout".to_string()) } + + /// Export audio to a file + pub fn export_audio>(&mut self, settings: &crate::audio::ExportSettings, output_path: P) -> Result<(), String> { + // Send export query + if let Err(_) = self.query_tx.push(Query::ExportAudio(settings.clone(), output_path.as_ref().to_path_buf())) { + return Err("Failed to send export query - queue full".to_string()); + } + + // Wait for response (with longer timeout since export can take a while) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(300); // 5 minute timeout for export + + while start.elapsed() < timeout { + if let Ok(QueryResponse::AudioExported(result)) = self.query_response_rx.pop() { + return result; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + + Err("Export timeout".to_string()) + } } diff --git a/daw-backend/src/audio/export.rs b/daw-backend/src/audio/export.rs new file mode 100644 index 0000000..f9535c6 --- /dev/null +++ b/daw-backend/src/audio/export.rs @@ -0,0 +1,266 @@ +use super::buffer_pool::BufferPool; +use super::midi_pool::MidiClipPool; +use super::pool::AudioPool; +use super::project::Project; +use std::path::Path; + +/// Supported export formats +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExportFormat { + Wav, + Flac, + // TODO: Add MP3 support +} + +impl ExportFormat { + /// Get the file extension for this format + pub fn extension(&self) -> &'static str { + match self { + ExportFormat::Wav => "wav", + ExportFormat::Flac => "flac", + } + } +} + +/// Export settings for rendering audio +#[derive(Debug, Clone)] +pub struct ExportSettings { + /// Output format + pub format: ExportFormat, + /// Sample rate for export + pub sample_rate: u32, + /// Number of channels (1 = mono, 2 = stereo) + pub channels: u32, + /// Bit depth (16 or 24) - only for WAV/FLAC + pub bit_depth: u16, + /// MP3 bitrate in kbps (128, 192, 256, 320) + pub mp3_bitrate: u32, + /// Start time in seconds + pub start_time: f64, + /// End time in seconds + pub end_time: f64, +} + +impl Default for ExportSettings { + fn default() -> Self { + Self { + format: ExportFormat::Wav, + sample_rate: 44100, + channels: 2, + bit_depth: 16, + mp3_bitrate: 320, + start_time: 0.0, + end_time: 60.0, + } + } +} + +/// Export the project to an audio file +/// +/// This performs offline rendering, processing the entire timeline +/// in chunks to generate the final audio file. +pub fn export_audio>( + project: &mut Project, + pool: &AudioPool, + midi_pool: &MidiClipPool, + settings: &ExportSettings, + output_path: P, +) -> Result<(), String> { + // Render the project to memory + let samples = render_to_memory(project, pool, midi_pool, settings)?; + + // Write to file based on format + match settings.format { + ExportFormat::Wav => write_wav(&samples, settings, output_path)?, + ExportFormat::Flac => write_flac(&samples, settings, output_path)?, + } + + Ok(()) +} + +/// Render the project to memory +fn render_to_memory( + project: &mut Project, + pool: &AudioPool, + midi_pool: &MidiClipPool, + settings: &ExportSettings, +) -> Result, String> { + // Calculate total number of frames + let duration = settings.end_time - settings.start_time; + let total_frames = (duration * settings.sample_rate as f64).round() as usize; + let total_samples = total_frames * settings.channels as usize; + + println!("Export: duration={:.3}s, total_frames={}, total_samples={}, channels={}", + duration, total_frames, total_samples, settings.channels); + + // Render in chunks to avoid memory issues + const CHUNK_FRAMES: usize = 4096; + let chunk_samples = CHUNK_FRAMES * settings.channels as usize; + + // Create buffer for rendering + let mut render_buffer = vec![0.0f32; chunk_samples]; + let mut buffer_pool = BufferPool::new(16, chunk_samples); + + // Collect all rendered samples + let mut all_samples = Vec::with_capacity(total_samples); + + let mut playhead = settings.start_time; + let chunk_duration = CHUNK_FRAMES as f64 / settings.sample_rate as f64; + + // Render the entire timeline in chunks + while playhead < settings.end_time { + // Clear the render buffer + render_buffer.fill(0.0); + + // Render this chunk + project.render( + &mut render_buffer, + pool, + midi_pool, + &mut buffer_pool, + playhead, + settings.sample_rate, + settings.channels, + ); + + // Calculate how many samples we actually need from this chunk + let remaining_time = settings.end_time - playhead; + let samples_needed = if remaining_time < chunk_duration { + // Calculate frames needed and ensure it's a whole number + let frames_needed = (remaining_time * settings.sample_rate as f64).round() as usize; + let samples = frames_needed * settings.channels as usize; + // Ensure we don't exceed chunk size + samples.min(chunk_samples) + } else { + chunk_samples + }; + + // Append to output + all_samples.extend_from_slice(&render_buffer[..samples_needed]); + + playhead += chunk_duration; + } + + println!("Export: rendered {} samples total", all_samples.len()); + + // Verify the sample count is a multiple of channels + if all_samples.len() % settings.channels as usize != 0 { + return Err(format!( + "Sample count {} is not a multiple of channel count {}", + all_samples.len(), + settings.channels + )); + } + + Ok(all_samples) +} + +/// Write WAV file using hound +fn write_wav>( + samples: &[f32], + settings: &ExportSettings, + output_path: P, +) -> Result<(), String> { + let spec = hound::WavSpec { + channels: settings.channels as u16, + sample_rate: settings.sample_rate, + bits_per_sample: settings.bit_depth, + sample_format: hound::SampleFormat::Int, + }; + + let mut writer = hound::WavWriter::create(output_path, spec) + .map_err(|e| format!("Failed to create WAV file: {}", e))?; + + // Write samples + match settings.bit_depth { + 16 => { + for &sample in samples { + let clamped = sample.max(-1.0).min(1.0); + let pcm_value = (clamped * 32767.0) as i16; + writer.write_sample(pcm_value) + .map_err(|e| format!("Failed to write sample: {}", e))?; + } + } + 24 => { + for &sample in samples { + let clamped = sample.max(-1.0).min(1.0); + let pcm_value = (clamped * 8388607.0) as i32; + writer.write_sample(pcm_value) + .map_err(|e| format!("Failed to write sample: {}", e))?; + } + } + _ => return Err(format!("Unsupported bit depth: {}", settings.bit_depth)), + } + + writer.finalize() + .map_err(|e| format!("Failed to finalize WAV file: {}", e))?; + + Ok(()) +} + +/// Write FLAC file using hound (FLAC is essentially lossless WAV) +fn write_flac>( + samples: &[f32], + settings: &ExportSettings, + output_path: P, +) -> Result<(), String> { + // For now, we'll use hound to write a WAV-like FLAC file + // In the future, we could use a dedicated FLAC encoder + let spec = hound::WavSpec { + channels: settings.channels as u16, + sample_rate: settings.sample_rate, + bits_per_sample: settings.bit_depth, + sample_format: hound::SampleFormat::Int, + }; + + let mut writer = hound::WavWriter::create(output_path, spec) + .map_err(|e| format!("Failed to create FLAC file: {}", e))?; + + // Write samples (same as WAV for now) + match settings.bit_depth { + 16 => { + for &sample in samples { + let clamped = sample.max(-1.0).min(1.0); + let pcm_value = (clamped * 32767.0) as i16; + writer.write_sample(pcm_value) + .map_err(|e| format!("Failed to write sample: {}", e))?; + } + } + 24 => { + for &sample in samples { + let clamped = sample.max(-1.0).min(1.0); + let pcm_value = (clamped * 8388607.0) as i32; + writer.write_sample(pcm_value) + .map_err(|e| format!("Failed to write sample: {}", e))?; + } + } + _ => return Err(format!("Unsupported bit depth: {}", settings.bit_depth)), + } + + writer.finalize() + .map_err(|e| format!("Failed to finalize FLAC file: {}", e))?; + + Ok(()) +} + +// TODO: Add MP3 export support with a better library + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_export_settings_default() { + let settings = ExportSettings::default(); + assert_eq!(settings.format, ExportFormat::Wav); + assert_eq!(settings.sample_rate, 44100); + assert_eq!(settings.channels, 2); + assert_eq!(settings.bit_depth, 16); + } + + #[test] + fn test_format_extension() { + assert_eq!(ExportFormat::Wav.extension(), "wav"); + assert_eq!(ExportFormat::Flac.extension(), "flac"); + } +} diff --git a/daw-backend/src/audio/metronome.rs b/daw-backend/src/audio/metronome.rs new file mode 100644 index 0000000..4612cf4 --- /dev/null +++ b/daw-backend/src/audio/metronome.rs @@ -0,0 +1,169 @@ +/// 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 + + #[allow(dead_code)] + 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/midi.rs b/daw-backend/src/audio/midi.rs index a22c740..7dbaeb0 100644 --- a/daw-backend/src/audio/midi.rs +++ b/daw-backend/src/audio/midi.rs @@ -63,73 +63,216 @@ impl MidiEvent { } } -/// MIDI clip ID type +/// MIDI clip ID type (for clips stored in the pool) pub type MidiClipId = u32; -/// MIDI clip containing a sequence of MIDI events +/// MIDI clip instance ID type (for instances placed on tracks) +pub type MidiClipInstanceId = u32; + +/// MIDI clip content - stores the actual MIDI events +/// +/// This represents the content data stored in the MidiClipPool. +/// Events have timestamps relative to the start of the clip (0.0 = clip beginning). #[derive(Debug, Clone)] pub struct MidiClip { pub id: MidiClipId, pub events: Vec, - pub start_time: f64, // Position on timeline in seconds - pub duration: f64, // Clip duration in seconds - pub loop_enabled: bool, + pub duration: f64, // Total content duration in seconds + pub name: String, } impl MidiClip { - /// Create a new MIDI clip - pub fn new(id: MidiClipId, start_time: f64, duration: f64) -> Self { + /// Create a new MIDI clip with content + pub fn new(id: MidiClipId, events: Vec, duration: f64, name: String) -> Self { + let mut clip = Self { + id, + events, + duration, + name, + }; + // Sort events by timestamp + clip.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); + clip + } + + /// Create an empty MIDI clip + pub fn empty(id: MidiClipId, duration: f64, name: String) -> Self { Self { id, events: Vec::new(), - start_time, duration, - loop_enabled: false, + name, } } /// Add a MIDI event to the clip pub fn add_event(&mut self, event: MidiEvent) { self.events.push(event); - // Keep events sorted by timestamp (using partial_cmp for f64) + // Keep events sorted by timestamp self.events.sort_by(|a, b| a.timestamp.partial_cmp(&b.timestamp).unwrap()); } - /// Get the end time of the clip - pub fn end_time(&self) -> f64 { - self.start_time + self.duration + /// Get events within a time range (relative to clip start) + /// This is used by MidiClipInstance to fetch events for a given portion + pub fn get_events_in_range(&self, start: f64, end: f64) -> Vec { + self.events + .iter() + .filter(|e| e.timestamp >= start && e.timestamp < end) + .copied() + .collect() + } +} + +/// MIDI clip instance - a reference to MidiClip content with timeline positioning +/// +/// ## Timing Model +/// - `internal_start` / `internal_end`: Define the region of the source clip to play (trimming) +/// - `external_start` / `external_duration`: Define where the instance appears on the timeline and how long +/// +/// ## Looping +/// If `external_duration` is greater than `internal_end - internal_start`, +/// the instance will seamlessly loop back to `internal_start` when it reaches `internal_end`. +#[derive(Debug, Clone)] +pub struct MidiClipInstance { + pub id: MidiClipInstanceId, + pub clip_id: MidiClipId, // Reference to MidiClip in pool + + /// Start position within the clip content (seconds) + pub internal_start: f64, + /// End position within the clip content (seconds) + pub internal_end: f64, + + /// Start position on the timeline (seconds) + pub external_start: f64, + /// Duration on the timeline (seconds) - can be longer than internal duration for looping + pub external_duration: f64, +} + +impl MidiClipInstance { + /// Create a new MIDI clip instance + pub fn new( + id: MidiClipInstanceId, + clip_id: MidiClipId, + internal_start: f64, + internal_end: f64, + external_start: f64, + external_duration: f64, + ) -> Self { + Self { + id, + clip_id, + internal_start, + internal_end, + external_start, + external_duration, + } } - /// Get events that should be triggered in a given time range + /// Create an instance that uses the full clip content (no trimming, no looping) + pub fn from_full_clip( + id: MidiClipInstanceId, + clip_id: MidiClipId, + clip_duration: f64, + external_start: f64, + ) -> Self { + Self { + id, + clip_id, + internal_start: 0.0, + internal_end: clip_duration, + external_start, + external_duration: clip_duration, + } + } + + /// Get the internal (content) duration + pub fn internal_duration(&self) -> f64 { + self.internal_end - self.internal_start + } + + /// Get the end time on the timeline + pub fn external_end(&self) -> f64 { + self.external_start + self.external_duration + } + + /// Check if this instance loops + pub fn is_looping(&self) -> bool { + self.external_duration > self.internal_duration() + } + + /// Get the end time on the timeline (for backwards compatibility) + pub fn end_time(&self) -> f64 { + self.external_end() + } + + /// Get the start time on the timeline (for backwards compatibility) + pub fn start_time(&self) -> f64 { + self.external_start + } + + /// Check if this instance overlaps with a time range + pub fn overlaps_range(&self, range_start: f64, range_end: f64) -> bool { + self.external_start < range_end && self.external_end() > range_start + } + + /// Get events that should be triggered in a given timeline range /// - /// Returns events along with their absolute timestamps in samples + /// This handles: + /// - Trimming (internal_start/internal_end) + /// - Looping (when external duration > internal duration) + /// - Time mapping from timeline to clip content + /// + /// Returns events with timestamps adjusted to timeline time (not clip-relative) pub fn get_events_in_range( &self, + clip: &MidiClip, range_start_seconds: f64, range_end_seconds: f64, - _sample_rate: u32, ) -> Vec { let mut result = Vec::new(); - // Check if clip overlaps with the range - if range_start_seconds >= self.end_time() || range_end_seconds <= self.start_time { + // Check if instance overlaps with the range + if !self.overlaps_range(range_start_seconds, range_end_seconds) { return result; } - // Calculate the intersection - let play_start = range_start_seconds.max(self.start_time); - let play_end = range_end_seconds.min(self.end_time()); + let internal_duration = self.internal_duration(); + if internal_duration <= 0.0 { + return result; + } - // Position within the clip - let clip_position_seconds = play_start - self.start_time; - let clip_end_seconds = play_end - self.start_time; + // Calculate how many complete loops fit in the external duration + let num_loops = if self.external_duration > internal_duration { + (self.external_duration / internal_duration).ceil() as usize + } else { + 1 + }; - // Find events in this range - // Note: event.timestamp is now in seconds relative to clip start - // Use half-open interval [start, end) to avoid triggering events twice - for event in &self.events { - if event.timestamp >= clip_position_seconds && event.timestamp < clip_end_seconds { - result.push(*event); + let external_end = self.external_end(); + + for loop_idx in 0..num_loops { + let loop_offset = loop_idx as f64 * internal_duration; + + // Get events from the clip that fall within the internal range + for event in &clip.events { + // Skip events outside the trimmed region + if event.timestamp < self.internal_start || event.timestamp >= self.internal_end { + continue; + } + + // Convert to timeline time + let relative_content_time = event.timestamp - self.internal_start; + let timeline_time = self.external_start + loop_offset + relative_content_time; + + // Check if within current buffer range and instance bounds + if timeline_time >= range_start_seconds + && timeline_time < range_end_seconds + && timeline_time < external_end + { + let mut adjusted_event = *event; + adjusted_event.timestamp = timeline_time; + result.push(adjusted_event); + } } } diff --git a/daw-backend/src/audio/midi_pool.rs b/daw-backend/src/audio/midi_pool.rs new file mode 100644 index 0000000..184333a --- /dev/null +++ b/daw-backend/src/audio/midi_pool.rs @@ -0,0 +1,101 @@ +use std::collections::HashMap; +use super::midi::{MidiClip, MidiClipId, MidiEvent}; + +/// Pool for storing MIDI clip content +/// Similar to AudioClipPool but for MIDI data +pub struct MidiClipPool { + clips: HashMap, + next_id: MidiClipId, +} + +impl MidiClipPool { + /// Create a new empty MIDI clip pool + pub fn new() -> Self { + Self { + clips: HashMap::new(), + next_id: 1, // Start at 1 so 0 can indicate "no clip" + } + } + + /// Add a new clip to the pool with the given events and duration + /// Returns the ID of the newly created clip + pub fn add_clip(&mut self, events: Vec, duration: f64, name: String) -> MidiClipId { + let id = self.next_id; + self.next_id += 1; + + let clip = MidiClip::new(id, events, duration, name); + self.clips.insert(id, clip); + id + } + + /// Add an existing clip to the pool (used when loading projects) + /// The clip's ID is preserved + pub fn add_existing_clip(&mut self, clip: MidiClip) { + // Update next_id to avoid collisions + if clip.id >= self.next_id { + self.next_id = clip.id + 1; + } + self.clips.insert(clip.id, clip); + } + + /// Get a clip by ID + pub fn get_clip(&self, id: MidiClipId) -> Option<&MidiClip> { + self.clips.get(&id) + } + + /// Get a mutable clip by ID + pub fn get_clip_mut(&mut self, id: MidiClipId) -> Option<&mut MidiClip> { + self.clips.get_mut(&id) + } + + /// Remove a clip from the pool + pub fn remove_clip(&mut self, id: MidiClipId) -> Option { + self.clips.remove(&id) + } + + /// Duplicate a clip, returning the new clip's ID + pub fn duplicate_clip(&mut self, id: MidiClipId) -> Option { + let clip = self.clips.get(&id)?; + let new_id = self.next_id; + self.next_id += 1; + + let mut new_clip = clip.clone(); + new_clip.id = new_id; + new_clip.name = format!("{} (copy)", clip.name); + + self.clips.insert(new_id, new_clip); + Some(new_id) + } + + /// Get all clip IDs in the pool + pub fn clip_ids(&self) -> Vec { + self.clips.keys().copied().collect() + } + + /// Get the number of clips in the pool + pub fn len(&self) -> usize { + self.clips.len() + } + + /// Check if the pool is empty + pub fn is_empty(&self) -> bool { + self.clips.is_empty() + } + + /// Clear all clips from the pool + pub fn clear(&mut self) { + self.clips.clear(); + self.next_id = 1; + } + + /// Get an iterator over all clips + pub fn iter(&self) -> impl Iterator { + self.clips.iter() + } +} + +impl Default for MidiClipPool { + fn default() -> Self { + Self::new() + } +} diff --git a/daw-backend/src/audio/mod.rs b/daw-backend/src/audio/mod.rs index c0c0e20..64a7c99 100644 --- a/daw-backend/src/audio/mod.rs +++ b/daw-backend/src/audio/mod.rs @@ -3,7 +3,10 @@ pub mod bpm_detector; pub mod buffer_pool; pub mod clip; pub mod engine; +pub mod export; +pub mod metronome; pub mod midi; +pub mod midi_pool; pub mod node_graph; pub mod pool; pub mod project; @@ -13,10 +16,13 @@ pub mod track; pub use automation::{AutomationLane, AutomationLaneId, AutomationPoint, CurveType, ParameterId}; pub use buffer_pool::BufferPool; -pub use clip::{Clip, ClipId}; +pub use clip::{AudioClipInstance, AudioClipInstanceId, Clip, ClipId}; pub use engine::{Engine, EngineController}; -pub use midi::{MidiClip, MidiClipId, MidiEvent}; -pub use pool::{AudioFile as PoolAudioFile, AudioPool}; +pub use export::{export_audio, ExportFormat, ExportSettings}; +pub use metronome::Metronome; +pub use midi::{MidiClip, MidiClipId, MidiClipInstance, MidiClipInstanceId, MidiEvent}; +pub use midi_pool::MidiClipPool; +pub use pool::{AudioClipPool, AudioFile as PoolAudioFile, AudioPool}; pub use project::Project; pub use recording::RecordingState; pub use sample_loader::{load_audio_file, SampleData}; diff --git a/daw-backend/src/audio/node_graph/graph.rs b/daw-backend/src/audio/node_graph/graph.rs index 24c01a2..7db51bb 100644 --- a/daw-backend/src/audio/node_graph/graph.rs +++ b/daw-backend/src/audio/node_graph/graph.rs @@ -472,7 +472,7 @@ impl AudioGraph { // This is safe because each output buffer is independent let buffer = &mut node.output_buffers[i] as *mut Vec; unsafe { - let slice = &mut (*buffer)[..process_size.min((*buffer).len())]; + let slice = &mut (&mut *buffer)[..process_size.min((*buffer).len())]; output_slices.push(slice); } } @@ -733,6 +733,9 @@ impl AudioGraph { root_key: info.root_key, velocity_min: info.velocity_min, velocity_max: info.velocity_max, + loop_start: info.loop_start, + loop_end: info.loop_end, + loop_mode: info.loop_mode, } }) .collect(); @@ -938,6 +941,9 @@ impl AudioGraph { layer.root_key, layer.velocity_min, layer.velocity_max, + layer.loop_start, + layer.loop_end, + layer.loop_mode, ); } } else if let Some(ref path) = layer.file_path { @@ -950,6 +956,9 @@ impl AudioGraph { layer.root_key, layer.velocity_min, layer.velocity_max, + layer.loop_start, + layer.loop_end, + layer.loop_mode, ) { eprintln!("Failed to load sample layer from {}: {}", resolved_path, e); } diff --git a/daw-backend/src/audio/node_graph/nodes/mod.rs b/daw-backend/src/audio/node_graph/nodes/mod.rs index 60483e0..9a94602 100644 --- a/daw-backend/src/audio/node_graph/nodes/mod.rs +++ b/daw-backend/src/audio/node_graph/nodes/mod.rs @@ -63,7 +63,7 @@ pub use math::MathNode; pub use midi_input::MidiInputNode; pub use midi_to_cv::MidiToCVNode; pub use mixer::MixerNode; -pub use multi_sampler::MultiSamplerNode; +pub use multi_sampler::{MultiSamplerNode, LoopMode}; pub use noise::NoiseGeneratorNode; pub use oscillator::OscillatorNode; pub use oscilloscope::OscilloscopeNode; diff --git a/daw-backend/src/audio/node_graph/nodes/multi_sampler.rs b/daw-backend/src/audio/node_graph/nodes/multi_sampler.rs index 3f4e4c7..a482100 100644 --- a/daw-backend/src/audio/node_graph/nodes/multi_sampler.rs +++ b/daw-backend/src/audio/node_graph/nodes/multi_sampler.rs @@ -7,6 +7,16 @@ const PARAM_ATTACK: u32 = 1; const PARAM_RELEASE: u32 = 2; const PARAM_TRANSPOSE: u32 = 3; +/// Loop playback mode +#[derive(Clone, Copy, Debug, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum LoopMode { + /// Play sample once, no looping + OneShot, + /// Loop continuously between loop_start and loop_end + Continuous, +} + /// Metadata about a loaded sample layer (for preset serialization) #[derive(Clone, Debug)] pub struct LayerInfo { @@ -16,6 +26,9 @@ pub struct LayerInfo { pub root_key: u8, pub velocity_min: u8, pub velocity_max: u8, + pub loop_start: Option, // Loop start point in samples + pub loop_end: Option, // Loop end point in samples + pub loop_mode: LoopMode, } /// Single sample with velocity range and key range @@ -32,6 +45,11 @@ struct SampleLayer { // Velocity range: 0-127 velocity_min: u8, velocity_max: u8, + + // Loop points (in samples) + loop_start: Option, + loop_end: Option, + loop_mode: LoopMode, } impl SampleLayer { @@ -43,6 +61,9 @@ impl SampleLayer { root_key: u8, velocity_min: u8, velocity_max: u8, + loop_start: Option, + loop_end: Option, + loop_mode: LoopMode, ) -> Self { Self { sample_data, @@ -52,6 +73,9 @@ impl SampleLayer { root_key, velocity_min, velocity_max, + loop_start, + loop_end, + loop_mode, } } @@ -62,6 +86,114 @@ impl SampleLayer { && velocity >= self.velocity_min && velocity <= self.velocity_max } + + /// Auto-detect loop points using autocorrelation to find a good loop region + /// Returns (loop_start, loop_end) in samples + fn detect_loop_points(sample_data: &[f32], sample_rate: f32) -> Option<(usize, usize)> { + if sample_data.len() < (sample_rate * 0.5) as usize { + return None; // Need at least 0.5 seconds of audio + } + + // Look for loop in the sustain region (skip attack/decay, avoid release) + // For sustained instruments, look in the middle 50% of the sample + let search_start = (sample_data.len() as f32 * 0.25) as usize; + let search_end = (sample_data.len() as f32 * 0.75) as usize; + + if search_end <= search_start { + return None; + } + + // Find the best loop point using autocorrelation + // For sustained instruments like brass/woodwind, we want longer loops + let min_loop_length = (sample_rate * 0.1) as usize; // Min 0.1s loop (more stable) + let max_loop_length = (sample_rate * 10.0) as usize; // Max 10 second loop + + let mut best_correlation = -1.0; + let mut best_loop_start = search_start; + let mut best_loop_end = search_end; + + // Try different loop lengths from LONGEST to SHORTEST + // This way we prefer longer loops and stop early if we find a good one + let length_step = ((sample_rate * 0.05) as usize).max(512); // 50ms steps + let actual_max_length = max_loop_length.min(search_end - search_start); + + // Manually iterate backwards since step_by().rev() doesn't work on RangeInclusive + let mut loop_length = actual_max_length; + while loop_length >= min_loop_length { + // Try different starting points in the sustain region (finer steps) + let start_step = ((sample_rate * 0.02) as usize).max(256); // 20ms steps + for start in (search_start..search_end - loop_length).step_by(start_step) { + let end = start + loop_length; + if end > search_end { + break; + } + + // Calculate correlation between loop end and loop start + let correlation = Self::calculate_loop_correlation(sample_data, start, end); + + if correlation > best_correlation { + best_correlation = correlation; + best_loop_start = start; + best_loop_end = end; + } + } + + // If we found a good enough loop, stop searching shorter ones + if best_correlation > 0.8 { + break; + } + + // Decrement loop_length, with underflow protection + if loop_length < length_step { + break; + } + loop_length -= length_step; + } + + // Lower threshold since longer loops are harder to match perfectly + if best_correlation > 0.6 { + Some((best_loop_start, best_loop_end)) + } else { + // Fallback: use a reasonable chunk of the sustain region + let fallback_length = ((search_end - search_start) / 2).max(min_loop_length); + Some((search_start, search_start + fallback_length)) + } + } + + /// Calculate how well the audio loops at the given points + /// Returns correlation value between -1.0 and 1.0 (higher is better) + fn calculate_loop_correlation(sample_data: &[f32], loop_start: usize, loop_end: usize) -> f32 { + let loop_length = loop_end - loop_start; + let window_size = (loop_length / 10).max(128).min(2048); // Compare last 10% of loop + + if loop_end + window_size >= sample_data.len() { + return -1.0; + } + + // Compare the end of the loop region with the beginning + let region1_start = loop_end - window_size; + let region2_start = loop_start; + + let mut sum_xy = 0.0; + let mut sum_x2 = 0.0; + let mut sum_y2 = 0.0; + + for i in 0..window_size { + let x = sample_data[region1_start + i]; + let y = sample_data[region2_start + i]; + sum_xy += x * y; + sum_x2 += x * x; + sum_y2 += y * y; + } + + // Pearson correlation coefficient + let denominator = (sum_x2 * sum_y2).sqrt(); + if denominator > 0.0 { + sum_xy / denominator + } else { + -1.0 + } + } } /// Active voice playing a sample @@ -75,6 +207,10 @@ struct Voice { // Envelope envelope_phase: EnvelopePhase, envelope_value: f32, + + // Loop crossfade state + crossfade_buffer: Vec, // Stores samples from before loop_start for crossfading + crossfade_length: usize, // Length of crossfade in samples (e.g., 100 samples = ~2ms @ 48kHz) } #[derive(Debug, Clone, Copy, PartialEq)] @@ -94,6 +230,8 @@ impl Voice { is_active: true, envelope_phase: EnvelopePhase::Attack, envelope_value: 0.0, + crossfade_buffer: Vec::new(), + crossfade_length: 1000, // ~20ms at 48kHz (longer for smoother loops) } } } @@ -166,6 +304,9 @@ impl MultiSamplerNode { root_key: u8, velocity_min: u8, velocity_max: u8, + loop_start: Option, + loop_end: Option, + loop_mode: LoopMode, ) { let layer = SampleLayer::new( sample_data, @@ -175,6 +316,9 @@ impl MultiSamplerNode { root_key, velocity_min, velocity_max, + loop_start, + loop_end, + loop_mode, ); self.layers.push(layer); } @@ -188,10 +332,25 @@ impl MultiSamplerNode { root_key: u8, velocity_min: u8, velocity_max: u8, + loop_start: Option, + loop_end: Option, + loop_mode: LoopMode, ) -> Result<(), String> { use crate::audio::sample_loader::load_audio_file; let sample_data = load_audio_file(path)?; + + // Auto-detect loop points if not provided and mode is Continuous + let (final_loop_start, final_loop_end) = if loop_mode == LoopMode::Continuous && loop_start.is_none() && loop_end.is_none() { + if let Some((start, end)) = SampleLayer::detect_loop_points(&sample_data.samples, sample_data.sample_rate as f32) { + (Some(start), Some(end)) + } else { + (None, None) + } + } else { + (loop_start, loop_end) + }; + self.add_layer( sample_data.samples, sample_data.sample_rate as f32, @@ -200,6 +359,9 @@ impl MultiSamplerNode { root_key, velocity_min, velocity_max, + final_loop_start, + final_loop_end, + loop_mode, ); // Store layer metadata for preset serialization @@ -210,6 +372,9 @@ impl MultiSamplerNode { root_key, velocity_min, velocity_max, + loop_start: final_loop_start, + loop_end: final_loop_end, + loop_mode, }); Ok(()) @@ -236,6 +401,9 @@ impl MultiSamplerNode { root_key: u8, velocity_min: u8, velocity_max: u8, + loop_start: Option, + loop_end: Option, + loop_mode: LoopMode, ) -> Result<(), String> { if layer_index >= self.layers.len() { return Err("Layer index out of bounds".to_string()); @@ -247,6 +415,9 @@ impl MultiSamplerNode { self.layers[layer_index].root_key = root_key; self.layers[layer_index].velocity_min = velocity_min; self.layers[layer_index].velocity_max = velocity_max; + self.layers[layer_index].loop_start = loop_start; + self.layers[layer_index].loop_end = loop_end; + self.layers[layer_index].loop_mode = loop_mode; // Update the layer info if layer_index < self.layer_infos.len() { @@ -255,6 +426,9 @@ impl MultiSamplerNode { self.layer_infos[layer_index].root_key = root_key; self.layer_infos[layer_index].velocity_min = velocity_min; self.layer_infos[layer_index].velocity_max = velocity_max; + self.layer_infos[layer_index].loop_start = loop_start; + self.layer_infos[layer_index].loop_end = loop_end; + self.layer_infos[layer_index].loop_mode = loop_mode; } Ok(()) @@ -429,25 +603,109 @@ impl AudioNode for MultiSamplerNode { let speed_adjusted = speed * (layer.sample_rate / sample_rate as f32); for frame in 0..frames { - // Read sample with linear interpolation + // Read sample with linear interpolation and loop handling let playhead = voice.playhead; - let sample = if !layer.sample_data.is_empty() && playhead >= 0.0 { + let mut sample = 0.0; + + if !layer.sample_data.is_empty() && playhead >= 0.0 { let index = playhead.floor() as usize; - if index < layer.sample_data.len() { - let frac = playhead - playhead.floor(); - let sample1 = layer.sample_data[index]; - let sample2 = if index + 1 < layer.sample_data.len() { - layer.sample_data[index + 1] + + // Check if we need to handle looping + if layer.loop_mode == LoopMode::Continuous { + if let (Some(loop_start), Some(loop_end)) = (layer.loop_start, layer.loop_end) { + // Validate loop points + if loop_start < loop_end && loop_end <= layer.sample_data.len() { + // Fill crossfade buffer on first loop with samples just before loop_start + // These will be crossfaded with the beginning of the loop for seamless looping + if voice.crossfade_buffer.is_empty() && loop_start >= voice.crossfade_length { + let crossfade_start = loop_start.saturating_sub(voice.crossfade_length); + voice.crossfade_buffer = layer.sample_data[crossfade_start..loop_start].to_vec(); + } + + // Check if we've reached the loop end + if index >= loop_end { + // Wrap around to loop start + let loop_length = loop_end - loop_start; + let offset_from_end = index - loop_end; + let wrapped_index = loop_start + (offset_from_end % loop_length); + voice.playhead = wrapped_index as f32 + (playhead - playhead.floor()); + } + + // Read sample at current position + let current_index = voice.playhead.floor() as usize; + if current_index < layer.sample_data.len() { + let frac = voice.playhead - voice.playhead.floor(); + let sample1 = layer.sample_data[current_index]; + let sample2 = if current_index + 1 < layer.sample_data.len() { + layer.sample_data[current_index + 1] + } else { + layer.sample_data[loop_start] // Wrap to loop start for interpolation + }; + sample = sample1 + (sample2 - sample1) * frac; + + // Apply crossfade only at the END of loop + // Crossfade the end of loop with samples BEFORE loop_start + if current_index >= loop_start && current_index < loop_end { + if !voice.crossfade_buffer.is_empty() { + let crossfade_len = voice.crossfade_length.min(voice.crossfade_buffer.len()); + + // Only crossfade at loop end (last crossfade_length samples) + // This blends end samples (i,j,k) with pre-loop samples (a,b,c) + if current_index >= loop_end - crossfade_len && current_index < loop_end { + let crossfade_pos = current_index - (loop_end - crossfade_len); + if crossfade_pos < voice.crossfade_buffer.len() { + let end_sample = sample; // Current sample at end of loop (i, j, or k) + let pre_loop_sample = voice.crossfade_buffer[crossfade_pos]; // Corresponding pre-loop sample (a, b, or c) + // Equal-power crossfade: fade out end, fade in pre-loop + let fade_ratio = crossfade_pos as f32 / crossfade_len as f32; + let fade_out = (1.0 - fade_ratio).sqrt(); + let fade_in = fade_ratio.sqrt(); + sample = end_sample * fade_out + pre_loop_sample * fade_in; + } + } + } + } + } + } else { + // Invalid loop points, play normally + if index < layer.sample_data.len() { + let frac = playhead - playhead.floor(); + let sample1 = layer.sample_data[index]; + let sample2 = if index + 1 < layer.sample_data.len() { + layer.sample_data[index + 1] + } else { + 0.0 + }; + sample = sample1 + (sample2 - sample1) * frac; + } + } } else { - 0.0 - }; - sample1 + (sample2 - sample1) * frac + // No loop points defined, play normally + if index < layer.sample_data.len() { + let frac = playhead - playhead.floor(); + let sample1 = layer.sample_data[index]; + let sample2 = if index + 1 < layer.sample_data.len() { + layer.sample_data[index + 1] + } else { + 0.0 + }; + sample = sample1 + (sample2 - sample1) * frac; + } + } } else { - 0.0 + // OneShot mode - play normally without looping + if index < layer.sample_data.len() { + let frac = playhead - playhead.floor(); + let sample1 = layer.sample_data[index]; + let sample2 = if index + 1 < layer.sample_data.len() { + layer.sample_data[index + 1] + } else { + 0.0 + }; + sample = sample1 + (sample2 - sample1) * frac; + } } - } else { - 0.0 - }; + } // Process envelope match voice.envelope_phase { @@ -484,10 +742,12 @@ impl AudioNode for MultiSamplerNode { // Advance playhead voice.playhead += speed_adjusted; - // Stop if we've reached the end - if voice.playhead >= layer.sample_data.len() as f32 { - voice.is_active = false; - break; + // Stop if we've reached the end (only for OneShot mode) + if layer.loop_mode == LoopMode::OneShot { + if voice.playhead >= layer.sample_data.len() as f32 { + voice.is_active = false; + break; + } } } } diff --git a/daw-backend/src/audio/node_graph/preset.rs b/daw-backend/src/audio/node_graph/preset.rs index 45a3545..9a67125 100644 --- a/daw-backend/src/audio/node_graph/preset.rs +++ b/daw-backend/src/audio/node_graph/preset.rs @@ -1,5 +1,6 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use super::nodes::LoopMode; /// Sample data for preset serialization #[derive(Debug, Clone, Serialize, Deserialize)] @@ -37,6 +38,16 @@ pub struct LayerData { pub root_key: u8, pub velocity_min: u8, pub velocity_max: u8, + #[serde(skip_serializing_if = "Option::is_none")] + pub loop_start: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub loop_end: Option, + #[serde(default = "default_loop_mode")] + pub loop_mode: LoopMode, +} + +fn default_loop_mode() -> LoopMode { + LoopMode::OneShot } /// Serializable representation of a node graph preset diff --git a/daw-backend/src/audio/pool.rs b/daw-backend/src/audio/pool.rs index 621aea1..c780f60 100644 --- a/daw-backend/src/audio/pool.rs +++ b/daw-backend/src/audio/pool.rs @@ -119,13 +119,16 @@ impl AudioFile { } } -/// Pool of shared audio files -pub struct AudioPool { +/// Pool of shared audio files (audio clip content) +pub struct AudioClipPool { files: Vec, } -impl AudioPool { - /// Create a new empty audio pool +/// Type alias for backwards compatibility +pub type AudioPool = AudioClipPool; + +impl AudioClipPool { + /// Create a new empty audio clip pool pub fn new() -> Self { Self { files: Vec::new(), @@ -301,7 +304,7 @@ impl AudioPool { } } -impl Default for AudioPool { +impl Default for AudioClipPool { fn default() -> Self { Self::new() } @@ -335,8 +338,8 @@ pub struct AudioPoolEntry { pub embedded_data: Option, } -impl AudioPool { - /// Serialize the audio pool for project saving +impl AudioClipPool { + /// Serialize the audio clip pool for project saving /// /// Files smaller than 10MB are embedded as base64. /// Larger files are stored as relative paths to the project file. diff --git a/daw-backend/src/audio/project.rs b/daw-backend/src/audio/project.rs index 76acef4..4937280 100644 --- a/daw-backend/src/audio/project.rs +++ b/daw-backend/src/audio/project.rs @@ -1,19 +1,27 @@ use super::buffer_pool::BufferPool; use super::clip::Clip; -use super::midi::{MidiClip, MidiEvent}; -use super::pool::AudioPool; +use super::midi::{MidiClip, MidiClipId, MidiClipInstance, MidiClipInstanceId, MidiEvent}; +use super::midi_pool::MidiClipPool; +use super::pool::AudioClipPool; use super::track::{AudioTrack, Metatrack, MidiTrack, RenderContext, TrackId, TrackNode}; use std::collections::HashMap; -/// Project manages the hierarchical track structure +/// Project manages the hierarchical track structure and clip pools /// /// Tracks are stored in a flat HashMap but can be organized into groups, /// forming a tree structure. Groups render their children recursively. +/// +/// Clip content is stored in pools (MidiClipPool), while tracks store +/// clip instances that reference the pool content. pub struct Project { tracks: HashMap, next_track_id: TrackId, root_tracks: Vec, // Top-level tracks (not in any group) sample_rate: u32, // System sample rate + /// Pool for MIDI clip content + pub midi_clip_pool: MidiClipPool, + /// Next MIDI clip instance ID (for generating unique IDs) + next_midi_clip_instance_id: MidiClipInstanceId, } impl Project { @@ -24,6 +32,8 @@ impl Project { next_track_id: 0, root_tracks: Vec::new(), sample_rate, + midi_clip_pool: MidiClipPool::new(), + next_midi_clip_instance_id: 1, } } @@ -241,21 +251,81 @@ impl Project { } } - /// Add a MIDI clip to a MIDI track - pub fn add_midi_clip(&mut self, track_id: TrackId, clip: MidiClip) -> Result<(), &'static str> { + /// Add a MIDI clip instance to a MIDI track + /// The clip content should already exist in the midi_clip_pool + pub fn add_midi_clip_instance(&mut self, track_id: TrackId, instance: MidiClipInstance) -> Result<(), &'static str> { if let Some(TrackNode::Midi(track)) = self.tracks.get_mut(&track_id) { - track.add_clip(clip); + track.add_clip_instance(instance); Ok(()) } else { Err("Track not found or is not a MIDI track") } } + /// Create a new MIDI clip in the pool and add an instance to a track + /// Returns (clip_id, instance_id) on success + pub fn create_midi_clip_with_instance( + &mut self, + track_id: TrackId, + events: Vec, + duration: f64, + name: String, + external_start: f64, + ) -> Result<(MidiClipId, MidiClipInstanceId), &'static str> { + // Verify track exists and is a MIDI track + if !matches!(self.tracks.get(&track_id), Some(TrackNode::Midi(_))) { + return Err("Track not found or is not a MIDI track"); + } + + // Create clip in pool + let clip_id = self.midi_clip_pool.add_clip(events, duration, name); + + // Create instance + let instance_id = self.next_midi_clip_instance_id; + self.next_midi_clip_instance_id += 1; + + let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, duration, external_start); + + // Add instance to track + if let Some(TrackNode::Midi(track)) = self.tracks.get_mut(&track_id) { + track.add_clip_instance(instance); + } + + Ok((clip_id, instance_id)) + } + + /// Generate a new unique MIDI clip instance ID + pub fn next_midi_clip_instance_id(&mut self) -> MidiClipInstanceId { + let id = self.next_midi_clip_instance_id; + self.next_midi_clip_instance_id += 1; + id + } + + /// Legacy method for backwards compatibility - creates clip and instance from old MidiClip format + pub fn add_midi_clip(&mut self, track_id: TrackId, clip: MidiClip) -> Result<(), &'static str> { + self.add_midi_clip_at(track_id, clip, 0.0) + } + + /// Add a MIDI clip to the pool and create an instance at the given timeline position + pub fn add_midi_clip_at(&mut self, track_id: TrackId, clip: MidiClip, start_time: f64) -> Result<(), &'static str> { + // Add the clip to the pool (it already has events and duration) + let duration = clip.duration; + let clip_id = clip.id; + self.midi_clip_pool.add_existing_clip(clip); + + // Create an instance that uses the full clip at the given position + let instance_id = self.next_midi_clip_instance_id(); + let instance = MidiClipInstance::from_full_clip(instance_id, clip_id, duration, start_time); + + self.add_midi_clip_instance(track_id, instance) + } + /// Render all root tracks into the output buffer pub fn render( &mut self, output: &mut [f32], - pool: &AudioPool, + audio_pool: &AudioClipPool, + midi_pool: &MidiClipPool, buffer_pool: &mut BufferPool, playhead_seconds: f64, sample_rate: u32, @@ -278,7 +348,8 @@ impl Project { self.render_track( track_id, output, - pool, + audio_pool, + midi_pool, buffer_pool, ctx, any_solo, @@ -292,7 +363,8 @@ impl Project { &mut self, track_id: TrackId, output: &mut [f32], - pool: &AudioPool, + audio_pool: &AudioClipPool, + midi_pool: &MidiClipPool, buffer_pool: &mut BufferPool, ctx: RenderContext, any_solo: bool, @@ -336,11 +408,11 @@ impl Project { match self.tracks.get_mut(&track_id) { Some(TrackNode::Audio(track)) => { // Render audio track directly into output - track.render(output, pool, ctx.playhead_seconds, ctx.sample_rate, ctx.channels); + track.render(output, audio_pool, ctx.playhead_seconds, ctx.sample_rate, ctx.channels); } Some(TrackNode::Midi(track)) => { // Render MIDI track directly into output - track.render(output, ctx.playhead_seconds, ctx.sample_rate, ctx.channels); + track.render(output, midi_pool, ctx.playhead_seconds, ctx.sample_rate, ctx.channels); } Some(TrackNode::Group(group)) => { // Get children IDs, check if this group is soloed, and transform context @@ -360,7 +432,8 @@ impl Project { self.render_track( child_id, &mut group_buffer, - pool, + audio_pool, + midi_pool, buffer_pool, child_ctx, any_solo, diff --git a/daw-backend/src/audio/track.rs b/daw-backend/src/audio/track.rs index b7581aa..cc8bd4e 100644 --- a/daw-backend/src/audio/track.rs +++ b/daw-backend/src/audio/track.rs @@ -1,9 +1,10 @@ use super::automation::{AutomationLane, AutomationLaneId, ParameterId}; -use super::clip::Clip; -use super::midi::{MidiClip, MidiEvent}; +use super::clip::AudioClipInstance; +use super::midi::{MidiClipInstance, MidiEvent}; +use super::midi_pool::MidiClipPool; use super::node_graph::AudioGraph; use super::node_graph::nodes::{AudioInputNode, AudioOutputNode}; -use super::pool::AudioPool; +use super::pool::AudioClipPool; use std::collections::HashMap; /// Track ID type @@ -285,11 +286,12 @@ impl Metatrack { } } -/// MIDI track with MIDI clips and a node-based instrument +/// MIDI track with MIDI clip instances and a node-based instrument pub struct MidiTrack { pub id: TrackId, pub name: String, - pub clips: Vec, + /// Clip instances placed on this track (reference clips in the MidiClipPool) + pub clip_instances: Vec, pub instrument_graph: AudioGraph, pub volume: f32, pub muted: bool, @@ -310,7 +312,7 @@ impl MidiTrack { Self { id, name, - clips: Vec::new(), + clip_instances: Vec::new(), instrument_graph: AudioGraph::new(sample_rate, default_buffer_size), volume: 1.0, muted: false, @@ -346,9 +348,9 @@ impl MidiTrack { self.automation_lanes.remove(&lane_id).is_some() } - /// Add a MIDI clip to this track - pub fn add_clip(&mut self, clip: MidiClip) { - self.clips.push(clip); + /// Add a MIDI clip instance to this track + pub fn add_clip_instance(&mut self, instance: MidiClipInstance) { + self.clip_instances.push(instance); } /// Set track volume @@ -420,6 +422,7 @@ impl MidiTrack { pub fn render( &mut self, output: &mut [f32], + midi_pool: &MidiClipPool, playhead_seconds: f64, sample_rate: u32, channels: u32, @@ -427,17 +430,18 @@ impl MidiTrack { let buffer_duration_seconds = output.len() as f64 / (sample_rate as f64 * channels as f64); let buffer_end_seconds = playhead_seconds + buffer_duration_seconds; - // Collect MIDI events from all clips that overlap with current time range + // Collect MIDI events from all clip instances that overlap with current time range let mut midi_events = Vec::new(); - for clip in &self.clips { - let events = clip.get_events_in_range( - playhead_seconds, - buffer_end_seconds, - sample_rate, - ); - - // Events now have timestamps in seconds relative to clip start - midi_events.extend(events); + for instance in &self.clip_instances { + // Get the clip content from the pool + if let Some(clip) = midi_pool.get_clip(instance.clip_id) { + let events = instance.get_events_in_range( + clip, + playhead_seconds, + buffer_end_seconds, + ); + midi_events.extend(events); + } } // Add live MIDI events (from virtual keyboard or MIDI controllers) @@ -480,11 +484,12 @@ impl MidiTrack { } } -/// Audio track with clips +/// Audio track with audio clip instances pub struct AudioTrack { pub id: TrackId, pub name: String, - pub clips: Vec, + /// Audio clip instances (reference content in the AudioClipPool) + pub clips: Vec, pub volume: f32, pub muted: bool, pub solo: bool, @@ -560,8 +565,8 @@ impl AudioTrack { self.automation_lanes.remove(&lane_id).is_some() } - /// Add a clip to this track - pub fn add_clip(&mut self, clip: Clip) { + /// Add an audio clip instance to this track + pub fn add_clip(&mut self, clip: AudioClipInstance) { self.clips.push(clip); } @@ -590,7 +595,7 @@ impl AudioTrack { pub fn render( &mut self, output: &mut [f32], - pool: &AudioPool, + pool: &AudioClipPool, playhead_seconds: f64, sample_rate: u32, channels: u32, @@ -602,10 +607,10 @@ impl AudioTrack { let mut clip_buffer = vec![0.0f32; output.len()]; let mut rendered = 0; - // Render all active clips into the temporary buffer + // Render all active clip instances into the temporary buffer for clip in &self.clips { // Check if clip overlaps with current buffer time range - if clip.start_time < buffer_end_seconds && clip.end_time() > playhead_seconds { + if clip.external_start < buffer_end_seconds && clip.external_end() > playhead_seconds { rendered += self.render_clip( clip, &mut clip_buffer, @@ -667,12 +672,13 @@ impl AudioTrack { volume } - /// Render a single clip into the output buffer + /// Render a single audio clip instance into the output buffer + /// Handles looping when external_duration > internal_duration fn render_clip( &self, - clip: &Clip, + clip: &AudioClipInstance, output: &mut [f32], - pool: &AudioPool, + pool: &AudioClipPool, playhead_seconds: f64, sample_rate: u32, channels: u32, @@ -680,46 +686,94 @@ impl AudioTrack { let buffer_duration_seconds = output.len() as f64 / (sample_rate as f64 * channels as f64); let buffer_end_seconds = playhead_seconds + buffer_duration_seconds; - // Determine the time range we need to render (intersection of buffer and clip) - let render_start_seconds = playhead_seconds.max(clip.start_time); - let render_end_seconds = buffer_end_seconds.min(clip.end_time()); + // Determine the time range we need to render (intersection of buffer and clip external bounds) + let render_start_seconds = playhead_seconds.max(clip.external_start); + let render_end_seconds = buffer_end_seconds.min(clip.external_end()); // If no overlap, return early if render_start_seconds >= render_end_seconds { return 0; } - // Calculate offset into the output buffer (in interleaved samples) - let output_offset_seconds = render_start_seconds - playhead_seconds; - let output_offset_samples = (output_offset_seconds * sample_rate as f64 * channels as f64) as usize; - - // Calculate position within the clip's audio file (in seconds) - let clip_position_seconds = render_start_seconds - clip.start_time + clip.offset; - - // Calculate how many samples to render in the output - let render_duration_seconds = render_end_seconds - render_start_seconds; - let samples_to_render = (render_duration_seconds * sample_rate as f64 * channels as f64) as usize; - let samples_to_render = samples_to_render.min(output.len() - output_offset_samples); - - // Get the slice of output buffer to write to - if output_offset_samples + samples_to_render > output.len() { + let internal_duration = clip.internal_duration(); + if internal_duration <= 0.0 { return 0; } - let output_slice = &mut output[output_offset_samples..output_offset_samples + samples_to_render]; - // Calculate combined gain let combined_gain = clip.gain * self.volume; - // Render from pool with sample rate conversion - // Pass the time position in seconds, let the pool handle sample rate conversion - pool.render_from_file( - clip.audio_pool_index, - output_slice, - clip_position_seconds, - combined_gain, - sample_rate, - channels, - ) + let mut total_rendered = 0; + + // Process the render range sample by sample (or in chunks for efficiency) + // For looping clips, we need to handle wrap-around at the loop boundary + let samples_per_second = sample_rate as f64 * channels as f64; + + // For now, render in a simpler way - iterate through the timeline range + // and use get_content_position for each sample position + let output_start_offset = ((render_start_seconds - playhead_seconds) * samples_per_second) as usize; + let output_end_offset = ((render_end_seconds - playhead_seconds) * samples_per_second) as usize; + + if output_end_offset > output.len() || output_start_offset > output.len() { + return 0; + } + + // If not looping, we can render in one chunk (more efficient) + if !clip.is_looping() { + // Simple case: no looping + let content_start = clip.get_content_position(render_start_seconds).unwrap_or(clip.internal_start); + let output_len = output.len(); + let output_slice = &mut output[output_start_offset..output_end_offset.min(output_len)]; + + total_rendered = pool.render_from_file( + clip.audio_pool_index, + output_slice, + content_start, + combined_gain, + sample_rate, + channels, + ); + } else { + // Looping case: need to handle wrap-around at loop boundaries + // Render in segments, one per loop iteration + let mut timeline_pos = render_start_seconds; + let mut output_offset = output_start_offset; + + while timeline_pos < render_end_seconds && output_offset < output.len() { + // Calculate position within the loop + let relative_pos = timeline_pos - clip.external_start; + let loop_offset = relative_pos % internal_duration; + let content_pos = clip.internal_start + loop_offset; + + // Calculate how much we can render before hitting the loop boundary + let time_to_loop_end = internal_duration - loop_offset; + let time_to_render_end = render_end_seconds - timeline_pos; + let chunk_duration = time_to_loop_end.min(time_to_render_end); + + let chunk_samples = (chunk_duration * samples_per_second) as usize; + let chunk_samples = chunk_samples.min(output.len() - output_offset); + + if chunk_samples == 0 { + break; + } + + let output_slice = &mut output[output_offset..output_offset + chunk_samples]; + + let rendered = pool.render_from_file( + clip.audio_pool_index, + output_slice, + content_pos, + combined_gain, + sample_rate, + channels, + ); + + total_rendered += rendered; + output_offset += chunk_samples; + timeline_pos += chunk_duration; + } + } + + total_rendered } } diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index 7846c98..b116ee2 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -3,6 +3,7 @@ use crate::audio::{ TrackId, }; use crate::audio::buffer_pool::BufferPoolStats; +use crate::audio::node_graph::nodes::LoopMode; use crate::io::WaveformPeak; /// Commands sent from UI/control thread to audio thread @@ -27,10 +28,14 @@ pub enum Command { SetTrackSolo(TrackId, bool), // Clip management commands - /// Move a clip to a new timeline position + /// Move a clip to a new timeline position (track_id, clip_id, new_external_start) MoveClip(TrackId, ClipId, f64), - /// Trim a clip (track_id, clip_id, new_start_time, new_duration, new_offset) - TrimClip(TrackId, ClipId, f64, f64, f64), + /// Trim a clip's internal boundaries (track_id, clip_id, new_internal_start, new_internal_end) + /// This changes which portion of the source content is used + TrimClip(TrackId, ClipId, f64, f64), + /// Extend/shrink a clip's external duration (track_id, clip_id, new_external_duration) + /// If duration > internal duration, the clip will loop + ExtendClip(TrackId, ClipId, f64), // Metatrack management commands /// Create a new metatrack with a name @@ -66,8 +71,8 @@ pub enum Command { CreateMidiClip(TrackId, f64, f64), /// Add a MIDI note to a clip (track_id, clip_id, time_offset, note, velocity, duration) AddMidiNote(TrackId, MidiClipId, f64, u8, u8, f64), - /// Add a pre-loaded MIDI clip to a track - AddLoadedMidiClip(TrackId, MidiClip), + /// Add a pre-loaded MIDI clip to a track (track_id, clip, start_time) + AddLoadedMidiClip(TrackId, MidiClip, f64), /// Update MIDI clip notes (track_id, clip_id, notes: Vec<(start_time, note, velocity, duration)>) /// NOTE: May need to switch to individual note operations if this becomes slow on clips with many notes UpdateMidiClipNotes(TrackId, MidiClipId, Vec<(f64, u8, u8, f64)>), @@ -118,6 +123,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), @@ -147,10 +156,10 @@ pub enum Command { /// Load a sample into a SimpleSampler node (track_id, node_id, file_path) SamplerLoadSample(TrackId, u32, String), - /// Add a sample layer to a MultiSampler node (track_id, node_id, file_path, key_min, key_max, root_key, velocity_min, velocity_max) - MultiSamplerAddLayer(TrackId, u32, String, u8, u8, u8, u8, u8), - /// Update a MultiSampler layer's configuration (track_id, node_id, layer_index, key_min, key_max, root_key, velocity_min, velocity_max) - MultiSamplerUpdateLayer(TrackId, u32, usize, u8, u8, u8, u8, u8), + /// Add a sample layer to a MultiSampler node (track_id, node_id, file_path, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode) + MultiSamplerAddLayer(TrackId, u32, String, u8, u8, u8, u8, u8, Option, Option, LoopMode), + /// Update a MultiSampler layer's configuration (track_id, node_id, layer_index, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode) + MultiSamplerUpdateLayer(TrackId, u32, usize, u8, u8, u8, u8, u8, Option, Option, LoopMode), /// Remove a layer from a MultiSampler node (track_id, node_id, layer_index) MultiSamplerRemoveLayer(TrackId, u32, usize), @@ -211,6 +220,8 @@ pub enum AudioEvent { GraphStateChanged(TrackId), /// Preset fully loaded (track_id) - emitted after all nodes and samples are loaded GraphPresetLoaded(TrackId), + /// Preset has been saved to file (track_id, preset_path) + GraphPresetSaved(TrackId, String), } /// Synchronous queries sent from UI thread to audio thread @@ -246,6 +257,8 @@ pub enum Query { GetPoolWaveform(usize, usize), /// Get file info from audio pool (pool_index) - returns (duration, sample_rate, channels) GetPoolFileInfo(usize), + /// Export audio to file (settings, output_path) + ExportAudio(crate::audio::ExportSettings, std::path::PathBuf), } /// Oscilloscope data from a node @@ -303,4 +316,6 @@ pub enum QueryResponse { PoolWaveform(Result, String>), /// Pool file info (duration, sample_rate, channels) PoolFileInfo(Result<(f64, u32, u32), String>), + /// Audio exported + AudioExported(Result<(), String>), } diff --git a/daw-backend/src/io/midi_file.rs b/daw-backend/src/io/midi_file.rs index 51f5ea3..1dd2305 100644 --- a/daw-backend/src/io/midi_file.rs +++ b/daw-backend/src/io/midi_file.rs @@ -157,9 +157,8 @@ pub fn load_midi_file>( (final_delta_ticks as f64 / ticks_per_beat) * (microseconds_per_beat / 1_000_000.0); let duration_seconds = accumulated_time + final_delta_time; - // Create the MIDI clip - let mut clip = MidiClip::new(clip_id, 0.0, duration_seconds); - clip.events = events; + // Create the MIDI clip (content only, positioning happens when creating instance) + let clip = MidiClip::new(clip_id, events, duration_seconds, "Imported MIDI".to_string()); Ok(clip) } diff --git a/daw-backend/src/io/midi_input.rs b/daw-backend/src/io/midi_input.rs index d2498b2..c3fed06 100644 --- a/daw-backend/src/io/midi_input.rs +++ b/daw-backend/src/io/midi_input.rs @@ -9,6 +9,7 @@ use std::time::Duration; pub struct MidiInputManager { connections: Arc>>, active_track_id: Arc>>, + #[allow(dead_code)] command_tx: Arc>>, } @@ -74,6 +75,26 @@ impl MidiInputManager { // Get all available MIDI input ports let ports = midi_in.ports(); + // Get list of currently available device names + let mut available_devices = Vec::new(); + for port in &ports { + if let Ok(port_name) = midi_in.port_name(port) { + available_devices.push(port_name); + } + } + + // Remove disconnected devices from our connections list + { + let mut conns = connections.lock().unwrap(); + let before_count = conns.len(); + conns.retain(|conn| available_devices.contains(&conn.device_name)); + let after_count = conns.len(); + + if before_count != after_count { + println!("MIDI: Removed {} disconnected device(s)", before_count - after_count); + } + } + // Get list of already connected device names let connected_devices: Vec = { let conns = connections.lock().unwrap(); @@ -125,16 +146,9 @@ impl MidiInputManager { connection, }); println!("MIDI: Connected to: {}", port_name); - - // Need to recreate MidiInput for next iteration - midi_in = MidiInput::new("Lightningbeam") - .map_err(|e| format!("Failed to recreate MIDI input: {}", e))?; } Err(e) => { eprintln!("MIDI: Failed to connect to {}: {}", port_name, e); - // Recreate MidiInput to continue with other ports - midi_in = MidiInput::new("Lightningbeam") - .map_err(|e| format!("Failed to recreate MIDI input: {}", e))?; } } } diff --git a/daw-backend/src/tui/mod.rs b/daw-backend/src/tui/mod.rs index fe72a97..20c64a7 100644 --- a/daw-backend/src/tui/mod.rs +++ b/daw-backend/src/tui/mod.rs @@ -847,8 +847,7 @@ fn execute_command( // Load the MIDI file match load_midi_file(file_path, app.next_clip_id, 48000) { - Ok(mut midi_clip) => { - midi_clip.start_time = start_time; + Ok(midi_clip) => { let clip_id = midi_clip.id; let duration = midi_clip.duration; let event_count = midi_clip.events.len(); @@ -882,8 +881,8 @@ fn execute_command( app.add_clip(track_id, clip_id, start_time, duration, file_path.to_string(), notes); app.next_clip_id += 1; - // Send to audio engine - controller.add_loaded_midi_clip(track_id, midi_clip); + // Send to audio engine with the start_time (clip content is separate from timeline position) + controller.add_loaded_midi_clip(track_id, midi_clip, start_time); app.set_status(format!("Loaded {} ({} events, {:.2}s) to track {} at {:.2}s", file_path, event_count, duration, track_id, start_time)); diff --git a/screenshots/animation.png b/screenshots/animation.png new file mode 100644 index 0000000..db429db Binary files /dev/null and b/screenshots/animation.png differ diff --git a/screenshots/music.png b/screenshots/music.png new file mode 100644 index 0000000..d10f5c3 Binary files /dev/null and b/screenshots/music.png differ diff --git a/screenshots/video.png b/screenshots/video.png new file mode 100644 index 0000000..6c14b07 Binary files /dev/null and b/screenshots/video.png differ diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3edcb4b..11da83c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2,22 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "ab_glyph" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" -dependencies = [ - "ab_glyph_rasterizer", - "owned_ttf_parser", -] - -[[package]] -name = "ab_glyph_rasterizer" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" - [[package]] name = "addr2line" version = "0.24.2" @@ -35,15 +19,13 @@ checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" [[package]] name = "ahash" -version = "0.8.12" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" dependencies = [ - "cfg-if", - "getrandom 0.3.4", + "getrandom 0.2.15", "once_cell", "version_check", - "zerocopy 0.8.27", ] [[package]] @@ -110,39 +92,29 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "android-activity" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee91c0c2905bae44f84bfa4e044536541df26b7703fd0888deeb9060fcc44289" -dependencies = [ - "android-properties", - "bitflags 2.8.0", - "cc", - "cesu8", - "jni", - "jni-sys", - "libc", - "log", - "ndk 0.8.0", - "ndk-context", - "ndk-sys 0.5.0+25.2.9519653", - "num_enum", - "thiserror 1.0.69", -] - -[[package]] -name = "android-properties" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" - [[package]] name = "android-tzdata" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" +[[package]] +name = "android_log-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ecc8056bf6ab9892dcd53216c83d1597487d7dacac16c8df6b877d127df9937" + +[[package]] +name = "android_logger" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b07e8e73d720a1f2e4b6014766e6039fd2e96a4fa44e2a78d0e1fa2ff49826" +dependencies = [ + "android_log-sys", + "env_filter", + "log", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -152,68 +124,12 @@ dependencies = [ "libc", ] -[[package]] -name = "anstream" -version = "0.6.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" - -[[package]] -name = "anstyle-parse" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" -dependencies = [ - "windows-sys 0.60.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.60.2", -] - [[package]] name = "anyhow" version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" -[[package]] -name = "arrayref" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" - [[package]] name = "arrayvec" version = "0.7.6" @@ -221,19 +137,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] -name = "as-raw-xcb-connection" -version = "1.0.1" +name = "ascii" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" - -[[package]] -name = "ash" -version = "0.37.3+1.3.251" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39e9c3835d686b0a6084ab4234fcd1b07dbf6e4767dce60874b12356a25ecd4a" -dependencies = [ - "libloading 0.7.4", -] +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" [[package]] name = "ashpd" @@ -252,7 +159,7 @@ dependencies = [ "url", "wayland-backend", "wayland-client", - "wayland-protocols 0.32.5", + "wayland-protocols", "zbus", ] @@ -276,7 +183,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -287,7 +194,7 @@ checksum = "3f934833b4b7233644e5848f235df3f57ed8c80f1528a26c3dfa13d2147fa056" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -313,12 +220,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - [[package]] name = "autocfg" version = "1.4.0" @@ -367,7 +268,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -385,30 +286,9 @@ dependencies = [ "regex", "rustc-hash 2.1.1", "shlex", - "syn 2.0.109", + "syn 2.0.96", ] -[[package]] -name = "bit-set" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" -dependencies = [ - "bit-vec", -] - -[[package]] -name = "bit-vec" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" - -[[package]] -name = "bit_field" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" - [[package]] name = "bitflags" version = "1.3.2" @@ -424,6 +304,18 @@ dependencies = [ "serde", ] +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + [[package]] name = "block" version = "0.1.6" @@ -439,32 +331,36 @@ dependencies = [ "generic-array", ] -[[package]] -name = "block-sys" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae85a0696e7ea3b835a453750bf002770776609115e6d25c6d2ff28a8200f7e7" -dependencies = [ - "objc-sys", -] - -[[package]] -name = "block2" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b55663a85f33501257357e6421bb33e769d5c9ffb5ba0921c975a123e35e68" -dependencies = [ - "block-sys", - "objc2 0.4.1", -] - [[package]] name = "block2" version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" dependencies = [ - "objc2 0.5.2", + "objc2", +] + +[[package]] +name = "borsh" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb65153674e51d3a42c8f27b05b9508cea85edfaade8aa46bc8fc18cecdfef3" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a396e17ad94059c650db3d253bb6e25927f1eb462eede7e7a153bb6e75dce0a7" +dependencies = [ + "once_cell", + "proc-macro-crate 3.2.0", + "proc-macro2", + "quote", + "syn 2.0.96", ] [[package]] @@ -494,25 +390,44 @@ version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +[[package]] +name = "byte-unit" +version = "5.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cd29c3c585209b0cbc7309bfe3ed7efd8c84c21b7af29c8bfae908f8777174" +dependencies = [ + "rust_decimal", + "serde", + "utf8-width", +] + +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "bytemuck" version = "1.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef657dfab802224e671f5818e9a4935f9b1957ed18e58292690cc39e7a4092a3" -dependencies = [ - "bytemuck_derive", -] - -[[package]] -name = "bytemuck_derive" -version = "1.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.109", -] [[package]] name = "byteorder" @@ -554,32 +469,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "calloop" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fba7adb4dd5aa98e5553510223000e7148f621165ec5f9acd7113f6ca4995298" -dependencies = [ - "bitflags 2.8.0", - "log", - "polling", - "rustix 0.38.43", - "slab", - "thiserror 1.0.69", -] - -[[package]] -name = "calloop-wayland-source" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f0ea9b9476c7fad82841a8dbb380e2eae480c21910feba80725b46931ed8f02" -dependencies = [ - "calloop", - "rustix 0.38.43", - "wayland-backend", - "wayland-client", -] - [[package]] name = "camino" version = "1.1.9" @@ -690,12 +579,6 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" -[[package]] -name = "cfg_aliases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" - [[package]] name = "cfg_aliases" version = "0.2.1" @@ -710,11 +593,19 @@ checksum = "7e36cc9d416881d2e24f9a963be5fb1cd90966419ac844274161d10488b3e825" dependencies = [ "android-tzdata", "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-targets 0.52.6", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "clang-sys" version = "1.8.1" @@ -736,7 +627,7 @@ dependencies = [ "block", "cocoa-foundation", "core-foundation 0.10.0", - "core-graphics 0.24.0", + "core-graphics", "foreign-types", "libc", "objc", @@ -751,64 +642,17 @@ dependencies = [ "bitflags 2.8.0", "block", "core-foundation 0.10.0", - "core-graphics-types 0.2.0", + "core-graphics-types", "libc", "objc", ] -[[package]] -name = "codespan-reporting" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" -dependencies = [ - "termcolor", - "unicode-width", -] - [[package]] name = "color_quant" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" -[[package]] -name = "colorchoice" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" - -[[package]] -name = "com" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e17887fd17353b65b1b2ef1c526c83e26cd72e74f598a8dc1bee13a48f3d9f6" -dependencies = [ - "com_macros", -] - -[[package]] -name = "com_macros" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d375883580a668c7481ea6631fc1a8863e33cc335bf56bfad8d7e6d4b04b13a5" -dependencies = [ - "com_macros_support", - "proc-macro2", - "syn 1.0.109", -] - -[[package]] -name = "com_macros_support" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad899a1087a9296d5644792d7cb72b8e34c1bec8e7d4fbc002230169a6e8710c" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "combine" version = "4.6.7" @@ -883,19 +727,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core-graphics" -version = "0.23.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "core-graphics-types 0.1.3", - "foreign-types", - "libc", -] - [[package]] name = "core-graphics" version = "0.24.0" @@ -904,22 +735,11 @@ checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.8.0", "core-foundation 0.10.0", - "core-graphics-types 0.2.0", + "core-graphics-types", "foreign-types", "libc", ] -[[package]] -name = "core-graphics-types" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "libc", -] - [[package]] name = "core-graphics-types" version = "0.2.0" @@ -1071,12 +891,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "crunchy" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" - [[package]] name = "crypto-common" version = "0.1.6" @@ -1111,7 +925,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -1121,24 +935,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", - "syn 2.0.109", -] - -[[package]] -name = "cursor-icon" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" - -[[package]] -name = "d3d12" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e3d747f100290a1ca24b752186f61f6637e1deffe3bf6320de6fcb29510a307" -dependencies = [ - "bitflags 2.8.0", - "libloading 0.8.6", - "winapi", + "syn 2.0.96", ] [[package]] @@ -1162,7 +959,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -1173,7 +970,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -1290,12 +1087,6 @@ dependencies = [ "dasp_sample", ] -[[package]] -name = "data-encoding" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" - [[package]] name = "daw-backend" version = "0.1.0" @@ -1311,6 +1102,7 @@ dependencies = [ "dasp_rms", "dasp_sample", "dasp_signal", + "hound", "midir", "midly", "pathdiff", @@ -1343,7 +1135,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -1391,7 +1183,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -1423,7 +1215,7 @@ checksum = "f2b99bf03862d7f545ebc28ddd33a665b50865f4dfd84031a393823879bd4c54" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -1527,7 +1319,7 @@ checksum = "fc4caf64a58d7a6d65ab00639b046ff54399a39f5f2554728895ace4b297cd79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -1540,19 +1332,6 @@ dependencies = [ "regex", ] -[[package]] -name = "env_logger" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" -dependencies = [ - "anstream", - "anstyle", - "env_filter", - "jiff", - "log", -] - [[package]] name = "equivalent" version = "1.0.1" @@ -1600,21 +1379,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "exr" -version = "1.74.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" -dependencies = [ - "bit_field", - "half", - "lebe", - "miniz_oxide", - "rayon-core", - "smallvec", - "zune-inflate", -] - [[package]] name = "extended" version = "0.1.0" @@ -1636,6 +1400,15 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fern" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29" +dependencies = [ + "log", +] + [[package]] name = "ffmpeg-next" version = "7.1.0" @@ -1723,7 +1496,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -1741,6 +1514,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futf" version = "0.1.5" @@ -1791,7 +1570,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -1941,16 +1720,6 @@ dependencies = [ "version_check", ] -[[package]] -name = "gethostname" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" -dependencies = [ - "rustix 1.1.2", - "windows-link", -] - [[package]] name = "getrandom" version = "0.1.16" @@ -1973,28 +1742,6 @@ dependencies = [ "wasi 0.11.0+wasi-snapshot-preview1", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - -[[package]] -name = "gif" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" -dependencies = [ - "color_quant", - "weezl", -] - [[package]] name = "gimli" version = "0.31.1" @@ -2033,17 +1780,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "gl_generator" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" -dependencies = [ - "khronos_api", - "log", - "xml-rs", -] - [[package]] name = "glib" version = "0.18.5" @@ -2078,7 +1814,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -2097,27 +1833,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" -[[package]] -name = "glow" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd348e04c43b32574f2de31c8bb397d96c9fcfa1371bd4ca6d8bdc464ab121b1" -dependencies = [ - "js-sys", - "slotmap", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "glutin_wgl_sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c8098adac955faa2d31079b65dc48841251f69efd3ac25477903fc424362ead" -dependencies = [ - "gl_generator", -] - [[package]] name = "gobject-sys" version = "0.18.0" @@ -2129,58 +1844,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "gpu-alloc" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" -dependencies = [ - "bitflags 2.8.0", - "gpu-alloc-types", -] - -[[package]] -name = "gpu-alloc-types" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" -dependencies = [ - "bitflags 2.8.0", -] - -[[package]] -name = "gpu-allocator" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f56f6318968d03c18e1bcf4857ff88c61157e9da8e47c5f29055d60e1228884" -dependencies = [ - "log", - "presser", - "thiserror 1.0.69", - "winapi", - "windows 0.52.0", -] - -[[package]] -name = "gpu-descriptor" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc11df1ace8e7e564511f53af41f3e42ddc95b56fd07b3f4445d2a6048bc682c" -dependencies = [ - "bitflags 2.8.0", - "gpu-descriptor-types", - "hashbrown 0.14.5", -] - -[[package]] -name = "gpu-descriptor-types" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bf0b36e6f090b7e1d8a4b49c0cb81c1f8376f72198c65dd3ad9ff3556b8b78c" -dependencies = [ - "bitflags 2.8.0", -] - [[package]] name = "gtk" version = "0.18.2" @@ -2230,18 +1893,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.109", -] - -[[package]] -name = "half" -version = "2.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" -dependencies = [ - "cfg-if", - "crunchy", - "zerocopy 0.8.27", + "syn 2.0.96", ] [[package]] @@ -2249,15 +1901,8 @@ name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ "ahash", - "allocator-api2", ] [[package]] @@ -2271,21 +1916,6 @@ dependencies = [ "foldhash", ] -[[package]] -name = "hassle-rs" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890" -dependencies = [ - "bitflags 2.8.0", - "com", - "libc", - "libloading 0.8.6", - "thiserror 1.0.69", - "widestring", - "winapi", -] - [[package]] name = "heck" version = "0.4.1" @@ -2311,10 +1941,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "hexf-parse" -version = "0.2.1" +name = "hound" +version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" [[package]] name = "html5ever" @@ -2330,17 +1960,6 @@ dependencies = [ "syn 1.0.109", ] -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa 1.0.14", -] - [[package]] name = "http" version = "1.2.0" @@ -2359,7 +1978,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.2.0", + "http", ] [[package]] @@ -2370,7 +1989,7 @@ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", "futures-util", - "http 1.2.0", + "http", "http-body", "pin-project-lite", ] @@ -2387,6 +2006,12 @@ version = "1.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.5.2" @@ -2396,7 +2021,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.2.0", + "http", "http-body", "httparse", "itoa 1.0.14", @@ -2415,7 +2040,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.2.0", + "http", "http-body", "hyper", "pin-project-lite", @@ -2458,17 +2083,6 @@ dependencies = [ "png", ] -[[package]] -name = "icrate" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d3aaff8a54577104bafdf686ff18565c3b6903ca5782a2026ef06e2c7aa319" -dependencies = [ - "block2 0.3.0", - "dispatch", - "objc2 0.4.1", -] - [[package]] name = "icu_collections" version = "1.5.0" @@ -2584,7 +2198,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -2623,13 +2237,8 @@ dependencies = [ "bytemuck", "byteorder", "color_quant", - "exr", - "gif", "jpeg-decoder", "num-traits", - "png", - "qoi", - "tiff", ] [[package]] @@ -2688,12 +2297,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - [[package]] name = "itertools" version = "0.12.1" @@ -2747,30 +2350,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "jiff" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1f93b8b1eb69c77f24bbb0afdf66f54b632ee39af40ca21c4365a1d7347e49" -dependencies = [ - "jiff-static", - "log", - "portable-atomic", - "portable-atomic-util", - "serde", -] - -[[package]] -name = "jiff-static" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03343451ff899767262ec32146f6d559dd759fdadf42ff0e227c7c48f72594b4" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.109", -] - [[package]] name = "jni" version = "0.21.1" @@ -2807,9 +2386,6 @@ name = "jpeg-decoder" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" -dependencies = [ - "rayon", -] [[package]] name = "js-sys" @@ -2854,23 +2430,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "khronos-egl" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" -dependencies = [ - "libc", - "libloading 0.8.6", - "pkg-config", -] - -[[package]] -name = "khronos_api" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" - [[package]] name = "kuchikiki" version = "0.8.2" @@ -2890,12 +2449,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "lebe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" - [[package]] name = "libappindicator" version = "0.9.0" @@ -2922,9 +2475,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.177" +version = "0.2.169" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "b5aba8db14291edd000dfcc4d620c7ebfb122c613afb886ca8803fa4e128a20a" [[package]] name = "libloading" @@ -2954,23 +2507,19 @@ checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" dependencies = [ "bitflags 2.8.0", "libc", - "redox_syscall 0.5.8", ] [[package]] name = "lightningbeam" version = "0.1.0" dependencies = [ - "bytemuck", + "chrono", "cpal", "daw-backend", - "env_logger", "ffmpeg-next", "image", "log", "lru", - "pollster", - "raw-window-handle", "rtrb", "serde", "serde_json", @@ -2978,10 +2527,12 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-fs", + "tauri-plugin-log", "tauri-plugin-shell", - "tungstenite", - "wgpu", - "winit", + "tiny_http", + "tokio", + "tracing", + "tracing-subscriber", ] [[package]] @@ -2990,12 +2541,6 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" -[[package]] -name = "linux-raw-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - [[package]] name = "litemap" version = "0.7.4" @@ -3017,6 +2562,9 @@ name = "log" version = "0.4.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" +dependencies = [ + "value-bag", +] [[package]] name = "lru" @@ -3065,6 +2613,15 @@ dependencies = [ "tendril", ] +[[package]] +name = "matchers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8263075bb86c5a1b1427b5ae862e8889656f126e9f77c484496e8b47cf5c5558" +dependencies = [ + "regex-automata 0.1.10", +] + [[package]] name = "matches" version = "0.1.10" @@ -3077,15 +2634,6 @@ version = "2.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" -[[package]] -name = "memmap2" -version = "0.9.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" -dependencies = [ - "libc", -] - [[package]] name = "memoffset" version = "0.9.1" @@ -3095,21 +2643,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "metal" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c43f73953f8cbe511f021b58f18c3ce1c3d1ae13fe953293e13345bf83217f25" -dependencies = [ - "bitflags 2.8.0", - "block", - "core-graphics-types 0.1.3", - "foreign-types", - "log", - "objc", - "paste", -] - [[package]] name = "midir" version = "0.9.1" @@ -3190,7 +2723,7 @@ dependencies = [ "dpi", "gtk", "keyboard-types", - "objc2 0.5.2", + "objc2", "objc2-app-kit", "objc2-foundation", "once_cell", @@ -3200,26 +2733,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "naga" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50e3524642f53d9af419ab5e8dd29d3ba155708267667c2f3f06c88c9e130843" -dependencies = [ - "bit-set", - "bitflags 2.8.0", - "codespan-reporting", - "hexf-parse", - "indexmap 2.7.0", - "log", - "num-traits", - "rustc-hash 1.1.0", - "spirv", - "termcolor", - "thiserror 1.0.69", - "unicode-xid", -] - [[package]] name = "ndk" version = "0.8.0" @@ -3231,7 +2744,6 @@ dependencies = [ "log", "ndk-sys 0.5.0+25.2.9519653", "num_enum", - "raw-window-handle", "thiserror 1.0.69", ] @@ -3299,7 +2811,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.8.0", "cfg-if", - "cfg_aliases 0.2.1", + "cfg_aliases", "libc", "memoffset", ] @@ -3320,6 +2832,16 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -3334,7 +2856,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -3374,7 +2896,16 @@ dependencies = [ "proc-macro-crate 3.2.0", "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", ] [[package]] @@ -3384,7 +2915,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" dependencies = [ "malloc_buf", - "objc_exception", ] [[package]] @@ -3396,16 +2926,6 @@ dependencies = [ "cc", ] -[[package]] -name = "objc2" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "559c5a40fdd30eb5e344fbceacf7595a81e242529fb4e21cf5f43fb4f11ff98d" -dependencies = [ - "objc-sys", - "objc2-encode 3.0.0", -] - [[package]] name = "objc2" version = "0.5.2" @@ -3413,7 +2933,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" dependencies = [ "objc-sys", - "objc2-encode 4.0.3", + "objc2-encode", ] [[package]] @@ -3423,9 +2943,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ "bitflags 2.8.0", - "block2 0.5.1", + "block2", "libc", - "objc2 0.5.2", + "objc2", "objc2-core-data", "objc2-core-image", "objc2-foundation", @@ -3439,8 +2959,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ "bitflags 2.8.0", - "block2 0.5.1", - "objc2 0.5.2", + "block2", + "objc2", "objc2-core-location", "objc2-foundation", ] @@ -3451,8 +2971,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", + "block2", + "objc2", "objc2-foundation", ] @@ -3463,8 +2983,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ "bitflags 2.8.0", - "block2 0.5.1", - "objc2 0.5.2", + "block2", + "objc2", "objc2-foundation", ] @@ -3474,8 +2994,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", + "block2", + "objc2", "objc2-foundation", "objc2-metal", ] @@ -3486,18 +3006,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", + "block2", + "objc2", "objc2-contacts", "objc2-foundation", ] -[[package]] -name = "objc2-encode" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d079845b37af429bfe5dfa76e6d087d788031045b25cfc6fd898486fd9847666" - [[package]] name = "objc2-encode" version = "4.0.3" @@ -3511,10 +3025,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ "bitflags 2.8.0", - "block2 0.5.1", + "block2", "dispatch", "libc", - "objc2 0.5.2", + "objc2", ] [[package]] @@ -3523,8 +3037,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", + "block2", + "objc2", "objc2-app-kit", "objc2-foundation", ] @@ -3536,8 +3050,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ "bitflags 2.8.0", - "block2 0.5.1", - "objc2 0.5.2", + "block2", + "objc2", "objc2-foundation", ] @@ -3548,8 +3062,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ "bitflags 2.8.0", - "block2 0.5.1", - "objc2 0.5.2", + "block2", + "objc2", "objc2-foundation", "objc2-metal", ] @@ -3560,7 +3074,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" dependencies = [ - "objc2 0.5.2", + "objc2", "objc2-foundation", ] @@ -3571,8 +3085,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ "bitflags 2.8.0", - "block2 0.5.1", - "objc2 0.5.2", + "block2", + "objc2", "objc2-cloud-kit", "objc2-core-data", "objc2-core-image", @@ -3591,8 +3105,8 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ - "block2 0.5.1", - "objc2 0.5.2", + "block2", + "objc2", "objc2-foundation", ] @@ -3603,8 +3117,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ "bitflags 2.8.0", - "block2 0.5.1", - "objc2 0.5.2", + "block2", + "objc2", "objc2-core-location", "objc2-foundation", ] @@ -3616,21 +3130,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68bc69301064cebefc6c4c90ce9cba69225239e4b8ff99d445a2b5563797da65" dependencies = [ "bitflags 2.8.0", - "block2 0.5.1", - "objc2 0.5.2", + "block2", + "objc2", "objc2-app-kit", "objc2-foundation", ] -[[package]] -name = "objc_exception" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" -dependencies = [ - "cc", -] - [[package]] name = "object" version = "0.36.7" @@ -3669,12 +3174,6 @@ version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - [[package]] name = "open" version = "5.3.2" @@ -3693,15 +3192,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" -[[package]] -name = "orbclient" -version = "0.3.49" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "247ad146e19b9437f8604c21f8652423595cf710ad108af40e77d3ae6e96b827" -dependencies = [ - "libredox", -] - [[package]] name = "ordered-stream" version = "0.2.0" @@ -3723,13 +3213,10 @@ dependencies = [ ] [[package]] -name = "owned_ttf_parser" -version = "0.25.1" +name = "overload" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" -dependencies = [ - "ttf-parser", -] +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" [[package]] name = "pango" @@ -3780,7 +3267,7 @@ checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.8", + "redox_syscall", "smallvec", "windows-targets 0.52.6", ] @@ -3927,7 +3414,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -4001,41 +3488,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix 1.1.2", - "windows-sys 0.61.2", -] - -[[package]] -name = "pollster" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" - -[[package]] -name = "portable-atomic" -version = "1.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" - -[[package]] -name = "portable-atomic-util" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" -dependencies = [ - "portable-atomic", -] - [[package]] name = "powerfmt" version = "0.2.0" @@ -4048,7 +3500,7 @@ version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77957b295656769bb8ad2b6a6b09d897d94f05c41b069aede1fcdaa675eaea04" dependencies = [ - "zerocopy 0.7.35", + "zerocopy", ] [[package]] @@ -4057,12 +3509,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" -[[package]] -name = "presser" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" - [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -4131,18 +3577,23 @@ dependencies = [ ] [[package]] -name = "profiling" -version = "1.0.17" +name = "ptr_meta" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] [[package]] -name = "qoi" -version = "0.4.1" +name = "ptr_meta_derive" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" dependencies = [ - "bytemuck", + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] @@ -4173,10 +3624,10 @@ dependencies = [ ] [[package]] -name = "r-efi" -version = "5.3.0" +name = "radium" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" @@ -4259,12 +3710,6 @@ dependencies = [ "rand_core 0.5.1", ] -[[package]] -name = "range-alloc" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde" - [[package]] name = "ratatui" version = "0.26.3" @@ -4311,15 +3756,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "redox_syscall" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "567664f262709473930a4bf9e51bf2ebf3348f2e748ccc50dea20646858f8f29" -dependencies = [ - "bitflags 1.3.2", -] - [[package]] name = "redox_syscall" version = "0.5.8" @@ -4348,8 +3784,17 @@ checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191" dependencies = [ "aho-corasick", "memchr", - "regex-automata", - "regex-syntax", + "regex-automata 0.4.9", + "regex-syntax 0.8.5", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" +dependencies = [ + "regex-syntax 0.6.29", ] [[package]] @@ -4360,9 +3805,15 @@ checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", - "regex-syntax", + "regex-syntax 0.8.5", ] +[[package]] +name = "regex-syntax" +version = "0.6.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f162c6dd7b008981e4d40210aca20b4bd0f9b60ca9271061b07f78537722f2e1" + [[package]] name = "regex-syntax" version = "0.8.5" @@ -4370,10 +3821,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] -name = "renderdoc-sys" -version = "1.1.0" +name = "rend" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] [[package]] name = "reqwest" @@ -4385,7 +3839,7 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http 1.2.0", + "http", "http-body", "http-body-util", "hyper", @@ -4420,7 +3874,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a24763657bff09769a8ccf12c8b8a50416fb035fe199263b4c5071e4e3f006f" dependencies = [ "ashpd", - "block2 0.5.1", + "block2", "core-foundation 0.10.0", "core-foundation-sys", "glib-sys", @@ -4428,7 +3882,7 @@ dependencies = [ "gtk-sys", "js-sys", "log", - "objc2 0.5.2", + "objc2", "objc2-app-kit", "objc2-foundation", "raw-window-handle", @@ -4438,12 +3892,57 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "rkyv" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9008cd6385b9e161d8229e1f6549dd23c3d022f132a2ea37ac3a10ac4935779b" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503d1d27590a2b0a3a4ca4c94755aa2875657196ecbf401a42eff41d7de532c0" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "rtrb" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad8388ea1a9e0ea807e442e8263a699e7edcb320ecbcd21b4fa8ff859acce3ba" +[[package]] +name = "rust_decimal" +version = "1.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b082d80e3e3cc52b2ed634388d436fe1f4de6af5786cc2de9ba9737527bdf555" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -4480,23 +3979,10 @@ dependencies = [ "bitflags 2.8.0", "errno", "libc", - "linux-raw-sys 0.4.15", + "linux-raw-sys", "windows-sys 0.59.0", ] -[[package]] -name = "rustix" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" -dependencies = [ - "bitflags 2.8.0", - "errno", - "libc", - "linux-raw-sys 0.11.0", - "windows-sys 0.61.2", -] - [[package]] name = "rustversion" version = "1.0.19" @@ -4542,7 +4028,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -4558,17 +4044,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] -name = "sctk-adwaita" -version = "0.8.3" +name = "seahash" +version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70b31447ca297092c5a9916fc3b955203157b37c19ca8edde4f52e9843e602c7" -dependencies = [ - "ab_glyph", - "log", - "memmap2", - "smithay-client-toolkit", - "tiny-skia", -] +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" [[package]] name = "selectors" @@ -4637,7 +4116,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -4648,7 +4127,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -4671,7 +4150,7 @@ checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -4722,7 +4201,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -4757,17 +4236,6 @@ dependencies = [ "stable_deref_trait", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "sha2" version = "0.10.8" @@ -4779,6 +4247,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shared_child" version = "1.0.1" @@ -4831,6 +4308,12 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "0.3.11" @@ -4852,55 +4335,12 @@ dependencies = [ "autocfg", ] -[[package]] -name = "slotmap" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbff4acf519f630b3a3ddcfaea6c06b42174d9a44bc70c620e9ed1649d58b82a" -dependencies = [ - "version_check", -] - [[package]] name = "smallvec" version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" -[[package]] -name = "smithay-client-toolkit" -version = "0.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "922fd3eeab3bd820d76537ce8f582b1cf951eceb5475c28500c7457d9d17f53a" -dependencies = [ - "bitflags 2.8.0", - "calloop", - "calloop-wayland-source", - "cursor-icon", - "libc", - "log", - "memmap2", - "rustix 0.38.43", - "thiserror 1.0.69", - "wayland-backend", - "wayland-client", - "wayland-csd-frame", - "wayland-cursor", - "wayland-protocols 0.31.2", - "wayland-protocols-wlr", - "wayland-scanner", - "xkeysym", -] - -[[package]] -name = "smol_str" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" -dependencies = [ - "serde", -] - [[package]] name = "socket2" version = "0.5.8" @@ -4918,16 +4358,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "18051cdd562e792cad055119e0cdb2cfc137e44e3987532e0f9659a77931bb08" dependencies = [ "bytemuck", - "cfg_aliases 0.2.1", - "core-graphics 0.24.0", + "cfg_aliases", + "core-graphics", "foreign-types", "js-sys", "log", - "objc2 0.5.2", + "objc2", "objc2-foundation", "objc2-quartz-core", "raw-window-handle", - "redox_syscall 0.5.8", + "redox_syscall", "wasm-bindgen", "web-sys", "windows-sys 0.59.0", @@ -4959,15 +4399,6 @@ dependencies = [ "system-deps", ] -[[package]] -name = "spirv" -version = "0.3.0+sdk-1.3.268.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" -dependencies = [ - "bitflags 2.8.0", -] - [[package]] name = "stability" version = "0.2.1" @@ -4975,7 +4406,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" dependencies = [ "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -4990,12 +4421,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "strict-num" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" - [[package]] name = "string_cache" version = "0.8.7" @@ -5047,7 +4472,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -5269,9 +4694,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.109" +version = "2.0.96" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f17c7e013e88258aa9543dcbe81aca68a667a9ac37cd69c9fbc07858bfe0e2f" +checksum = "d5d0adab1ae378d7f53bdebc67a39f1f151407ef230f0ce2883572f5d8985c80" dependencies = [ "proc-macro2", "quote", @@ -5295,7 +4720,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -5320,7 +4745,7 @@ dependencies = [ "bitflags 2.8.0", "cocoa", "core-foundation 0.10.0", - "core-graphics 0.24.0", + "core-graphics", "crossbeam-channel", "dispatch", "dlopen2", @@ -5357,9 +4782,15 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "target-lexicon" version = "0.12.16" @@ -5382,14 +4813,14 @@ dependencies = [ "glob", "gtk", "heck 0.5.0", - "http 1.2.0", + "http", "http-range", "jni", "libc", "log", "mime", "muda", - "objc2 0.5.2", + "objc2", "objc2-app-kit", "objc2-foundation", "percent-encoding", @@ -5457,7 +4888,7 @@ dependencies = [ "serde", "serde_json", "sha2", - "syn 2.0.109", + "syn 2.0.96", "tauri-utils", "thiserror 2.0.11", "time", @@ -5475,7 +4906,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", "tauri-codegen", "tauri-utils", ] @@ -5538,6 +4969,28 @@ dependencies = [ "uuid", ] +[[package]] +name = "tauri-plugin-log" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd784c138c08a43954bc3e735402e6b2b2ee8d8c254a7391f4e77c01273dd5" +dependencies = [ + "android_logger", + "byte-unit", + "cocoa", + "fern", + "log", + "objc", + "serde", + "serde_json", + "serde_repr", + "swift-rs", + "tauri", + "tauri-plugin", + "thiserror 2.0.11", + "time", +] + [[package]] name = "tauri-plugin-shell" version = "2.2.0" @@ -5567,7 +5020,7 @@ checksum = "2274ef891ccc0a8d318deffa9d70053f947664d12d58b9c0d1ae5e89237e01f7" dependencies = [ "dpi", "gtk", - "http 1.2.0", + "http", "jni", "raw-window-handle", "serde", @@ -5585,10 +5038,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3707b40711d3b9f6519150869e358ffbde7c57567fb9b5a8b51150606939b2a0" dependencies = [ "gtk", - "http 1.2.0", + "http", "jni", "log", - "objc2 0.5.2", + "objc2", "objc2-app-kit", "objc2-foundation", "percent-encoding", @@ -5616,7 +5069,7 @@ dependencies = [ "dunce", "glob", "html5ever", - "http 1.2.0", + "http", "infer", "json-patch", "kuchikiki", @@ -5661,7 +5114,7 @@ dependencies = [ "fastrand", "getrandom 0.2.15", "once_cell", - "rustix 0.38.43", + "rustix", "windows-sys 0.59.0", ] @@ -5676,15 +5129,6 @@ dependencies = [ "utf-8", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "thin-slice" version = "0.1.1" @@ -5717,7 +5161,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -5728,18 +5172,17 @@ checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] -name = "tiff" -version = "0.9.1" +name = "thread_local" +version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +checksum = "8b9ef9bad013ada3808854ceac7b46812a6465ba368859a37e2100283d2d719c" dependencies = [ - "flate2", - "jpeg-decoder", - "weezl", + "cfg-if", + "once_cell", ] [[package]] @@ -5750,7 +5193,9 @@ checksum = "35e7868883861bd0e56d9ac6efcaaca0d6d5d82a2a7ec8209ff492c07cf37b21" dependencies = [ "deranged", "itoa 1.0.14", + "libc", "num-conv", + "num_threads", "powerfmt", "serde", "time-core", @@ -5774,28 +5219,15 @@ dependencies = [ ] [[package]] -name = "tiny-skia" -version = "0.11.4" +name = "tiny_http" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" dependencies = [ - "arrayref", - "arrayvec", - "bytemuck", - "cfg-if", + "ascii", + "chunked_transfer", + "httpdate", "log", - "tiny-skia-path", -] - -[[package]] -name = "tiny-skia-path" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" -dependencies = [ - "arrayref", - "bytemuck", - "strict-num", ] [[package]] @@ -5808,6 +5240,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "022db8904dfa342efe721985167e9fcd16c29b226db4397ed752a761cfce81e8" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.43.0" @@ -5954,7 +5401,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -5964,6 +5411,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -5972,12 +5449,12 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d48a05076dd272615d03033bf04f480199f7d1b66a8ac64d75c625fc4a70c06b" dependencies = [ - "core-graphics 0.24.0", + "core-graphics", "crossbeam-channel", "dirs", "libappindicator", "muda", - "objc2 0.5.2", + "objc2", "objc2-app-kit", "objc2-foundation", "once_cell", @@ -5993,31 +5470,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "ttf-parser" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" - -[[package]] -name = "tungstenite" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e3dac10fd62eaf6617d3a904ae222845979aec67c615d1c842b4002c7666fb9" -dependencies = [ - "byteorder", - "bytes", - "data-encoding", - "http 0.2.12", - "httparse", - "log", - "rand 0.8.5", - "sha1", - "thiserror 1.0.69", - "url", - "utf-8", -] - [[package]] name = "typeid" version = "1.0.2" @@ -6111,12 +5563,6 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "url" version = "2.5.4" @@ -6153,18 +5599,18 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" +[[package]] +name = "utf8-width" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86bd8d4e895da8537e5315b8254664e6b769c4ff3db18321b297a1e7004392e3" + [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - [[package]] name = "uuid" version = "1.12.0" @@ -6175,6 +5621,18 @@ dependencies = [ "serde", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "value-bag" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ef4c4aa54d5d05a279399bfa921ec387b7aba77caf7a682ae8d86785b8fdad2" + [[package]] name = "vcpkg" version = "0.2.15" @@ -6244,15 +5702,6 @@ version = "0.11.0+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" -[[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.100" @@ -6275,7 +5724,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", "wasm-bindgen-shared", ] @@ -6310,7 +5759,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -6345,7 +5794,7 @@ checksum = "056535ced7a150d45159d3a8dc30f91a2e2d588ca0b23f70e56033622b8016f6" dependencies = [ "cc", "downcast-rs", - "rustix 0.38.43", + "rustix", "scoped-tls", "smallvec", "wayland-sys", @@ -6358,45 +5807,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b66249d3fc69f76fd74c82cc319300faa554e9d865dab1f7cd66cc20db10b280" dependencies = [ "bitflags 2.8.0", - "rustix 0.38.43", + "rustix", "wayland-backend", "wayland-scanner", ] -[[package]] -name = "wayland-csd-frame" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" -dependencies = [ - "bitflags 2.8.0", - "cursor-icon", - "wayland-backend", -] - -[[package]] -name = "wayland-cursor" -version = "0.31.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32b08bc3aafdb0035e7fe0fdf17ba0c09c268732707dca4ae098f60cb28c9e4c" -dependencies = [ - "rustix 0.38.43", - "wayland-client", - "xcursor", -] - -[[package]] -name = "wayland-protocols" -version = "0.31.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f81f365b8b4a97f422ac0e8737c438024b5951734506b0e1d775c73030561f4" -dependencies = [ - "bitflags 2.8.0", - "wayland-backend", - "wayland-client", - "wayland-scanner", -] - [[package]] name = "wayland-protocols" version = "0.32.5" @@ -6409,32 +5824,6 @@ dependencies = [ "wayland-scanner", ] -[[package]] -name = "wayland-protocols-plasma" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23803551115ff9ea9bce586860c5c5a971e360825a0309264102a9495a5ff479" -dependencies = [ - "bitflags 2.8.0", - "wayland-backend", - "wayland-client", - "wayland-protocols 0.31.2", - "wayland-scanner", -] - -[[package]] -name = "wayland-protocols-wlr" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad1f61b76b6c2d8742e10f9ba5c3737f6530b4c243132c2a2ccc8aa96fe25cd6" -dependencies = [ - "bitflags 2.8.0", - "wayland-backend", - "wayland-client", - "wayland-protocols 0.31.2", - "wayland-scanner", -] - [[package]] name = "wayland-scanner" version = "0.31.5" @@ -6454,7 +5843,6 @@ checksum = "efa8ac0d8e8ed3e3b5c9fc92c7881406a268e11555abe36493efabe649a29e09" dependencies = [ "dlib", "log", - "once_cell", "pkg-config", ] @@ -6468,16 +5856,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "web-time" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa30049b1c872b72c89866d458eae9f20380ab280ffd1b1e18df2d3e2d98cfe0" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - [[package]] name = "webkit2gtk" version = "2.0.1" @@ -6544,7 +5922,7 @@ checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -6558,125 +5936,6 @@ dependencies = [ "windows-core 0.58.0", ] -[[package]] -name = "weezl" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "009936b22a61d342859b5f0ea64681cbb35a358ab548e2a44a8cf0dac2d980b8" - -[[package]] -name = "wgpu" -version = "0.19.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbd7311dbd2abcfebaabf1841a2824ed7c8be443a0f29166e5d3c6a53a762c01" -dependencies = [ - "arrayvec", - "cfg-if", - "cfg_aliases 0.1.1", - "js-sys", - "log", - "naga", - "parking_lot", - "profiling", - "raw-window-handle", - "smallvec", - "static_assertions", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "wgpu-core", - "wgpu-hal", - "wgpu-types", -] - -[[package]] -name = "wgpu-core" -version = "0.19.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28b94525fc99ba9e5c9a9e24764f2bc29bad0911a7446c12f446a8277369bf3a" -dependencies = [ - "arrayvec", - "bit-vec", - "bitflags 2.8.0", - "cfg_aliases 0.1.1", - "codespan-reporting", - "indexmap 2.7.0", - "log", - "naga", - "once_cell", - "parking_lot", - "profiling", - "raw-window-handle", - "rustc-hash 1.1.0", - "smallvec", - "thiserror 1.0.69", - "web-sys", - "wgpu-hal", - "wgpu-types", -] - -[[package]] -name = "wgpu-hal" -version = "0.19.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfabcfc55fd86611a855816326b2d54c3b2fd7972c27ce414291562650552703" -dependencies = [ - "android_system_properties", - "arrayvec", - "ash", - "bit-set", - "bitflags 2.8.0", - "block", - "cfg_aliases 0.1.1", - "core-graphics-types 0.1.3", - "d3d12", - "glow", - "glutin_wgl_sys", - "gpu-alloc", - "gpu-allocator", - "gpu-descriptor", - "hassle-rs", - "js-sys", - "khronos-egl", - "libc", - "libloading 0.8.6", - "log", - "metal", - "naga", - "ndk-sys 0.5.0+25.2.9519653", - "objc", - "once_cell", - "parking_lot", - "profiling", - "range-alloc", - "raw-window-handle", - "renderdoc-sys", - "rustc-hash 1.1.0", - "smallvec", - "thiserror 1.0.69", - "wasm-bindgen", - "web-sys", - "wgpu-types", - "winapi", -] - -[[package]] -name = "wgpu-types" -version = "0.19.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b671ff9fb03f78b46ff176494ee1ebe7d603393f42664be55b64dc8d53969805" -dependencies = [ - "bitflags 2.8.0", - "js-sys", - "web-sys", -] - -[[package]] -name = "widestring" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" - [[package]] name = "winapi" version = "0.3.9" @@ -6714,7 +5973,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ea403deff7b51fff19e261330f71608ff2cdef5721d72b64180bb95be7c4150" dependencies = [ - "objc2 0.5.2", + "objc2", "objc2-app-kit", "objc2-foundation", "raw-window-handle", @@ -6737,16 +5996,6 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] -[[package]] -name = "windows" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" -dependencies = [ - "windows-core 0.52.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.54.0" @@ -6807,7 +6056,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -6818,15 +6067,9 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", ] -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - [[package]] name = "windows-registry" version = "0.2.0" @@ -6902,24 +6145,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - [[package]] name = "windows-targets" version = "0.42.2" @@ -6968,11 +6193,10 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.5" +version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" dependencies = [ - "windows-link", "windows_aarch64_gnullvm 0.53.0", "windows_aarch64_msvc 0.53.0", "windows_i686_gnu 0.53.0", @@ -6989,7 +6213,7 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c12476c23a74725c539b24eae8bfc0dac4029c39cdb561d9f23616accd4ae26d" dependencies = [ - "windows-targets 0.53.5", + "windows-targets 0.53.0", ] [[package]] @@ -7172,54 +6396,6 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" -[[package]] -name = "winit" -version = "0.29.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d59ad965a635657faf09c8f062badd885748428933dad8e8bdd64064d92e5ca" -dependencies = [ - "ahash", - "android-activity", - "atomic-waker", - "bitflags 2.8.0", - "bytemuck", - "calloop", - "cfg_aliases 0.1.1", - "core-foundation 0.9.4", - "core-graphics 0.23.2", - "cursor-icon", - "icrate", - "js-sys", - "libc", - "log", - "memmap2", - "ndk 0.8.0", - "ndk-sys 0.5.0+25.2.9519653", - "objc2 0.4.1", - "once_cell", - "orbclient", - "percent-encoding", - "raw-window-handle", - "redox_syscall 0.3.5", - "rustix 0.38.43", - "sctk-adwaita", - "smithay-client-toolkit", - "smol_str", - "unicode-segmentation", - "wasm-bindgen", - "wasm-bindgen-futures", - "wayland-backend", - "wayland-client", - "wayland-protocols 0.31.2", - "wayland-protocols-plasma", - "web-sys", - "web-time", - "windows-sys 0.48.0", - "x11-dl", - "x11rb", - "xkbcommon-dl", -] - [[package]] name = "winnow" version = "0.5.40" @@ -7248,12 +6424,6 @@ dependencies = [ "windows-sys 0.48.0", ] -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - [[package]] name = "write16" version = "1.0.0" @@ -7273,7 +6443,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2e33c08b174442ff80d5c791020696f9f8b4e4a87b8cfc7494aad6167ec44e1" dependencies = [ "base64 0.22.1", - "block2 0.5.1", + "block2", "cookie", "crossbeam-channel", "dpi", @@ -7281,13 +6451,13 @@ dependencies = [ "gdkx11", "gtk", "html5ever", - "http 1.2.0", + "http", "javascriptcore-rs", "jni", "kuchikiki", "libc", "ndk 0.9.0", - "objc2 0.5.2", + "objc2", "objc2-app-kit", "objc2-foundation", "objc2-ui-kit", @@ -7309,6 +6479,15 @@ dependencies = [ "x11-dl", ] +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11" version = "2.21.0" @@ -7330,33 +6509,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "x11rb" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" -dependencies = [ - "as-raw-xcb-connection", - "gethostname", - "libc", - "libloading 0.8.6", - "once_cell", - "rustix 1.1.2", - "x11rb-protocol", -] - -[[package]] -name = "x11rb-protocol" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" - -[[package]] -name = "xcursor" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" - [[package]] name = "xdg-home" version = "1.3.0" @@ -7367,31 +6519,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "xkbcommon-dl" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" -dependencies = [ - "bitflags 2.8.0", - "dlib", - "log", - "once_cell", - "xkeysym", -] - -[[package]] -name = "xkeysym" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" - -[[package]] -name = "xml-rs" -version = "0.8.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" - [[package]] name = "yoke" version = "0.7.5" @@ -7412,7 +6539,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", "synstructure", ] @@ -7455,7 +6582,7 @@ dependencies = [ "proc-macro-crate 3.2.0", "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", "zbus_names", "zvariant", "zvariant_utils", @@ -7480,16 +6607,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" dependencies = [ "byteorder", - "zerocopy-derive 0.7.35", -] - -[[package]] -name = "zerocopy" -version = "0.8.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" -dependencies = [ - "zerocopy-derive 0.8.27", + "zerocopy-derive", ] [[package]] @@ -7500,18 +6618,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.109", + "syn 2.0.96", ] [[package]] @@ -7531,7 +6638,7 @@ checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", "synstructure", ] @@ -7554,16 +6661,7 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.109", -] - -[[package]] -name = "zune-inflate" -version = "0.2.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", + "syn 2.0.96", ] [[package]] @@ -7591,7 +6689,7 @@ dependencies = [ "proc-macro-crate 3.2.0", "proc-macro2", "quote", - "syn 2.0.109", + "syn 2.0.96", "zvariant_utils", ] @@ -7605,6 +6703,6 @@ dependencies = [ "quote", "serde", "static_assertions", - "syn 2.0.109", + "syn 2.0.96", "winnow 0.6.24", ] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 543d1d9..2f78fea 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,9 +31,9 @@ env_logger = "0.11" daw-backend = { path = "../daw-backend" } cpal = "0.15" rtrb = "0.3" +tokio = { version = "1", features = ["sync", "time"] } # Video decoding -ffmpeg-next = "7.0" lru = "0.12" # WebSocket for frame streaming (disable default features to remove tracing, but keep handshake) @@ -47,6 +47,13 @@ bytemuck = { version = "1.14", features = ["derive"] } raw-window-handle = "0.6" image = "0.24" +[target.'cfg(target_os = "macos")'.dependencies] +ffmpeg-next = { version = "7.0", features = ["build"] } + +[target.'cfg(not(target_os = "macos"))'.dependencies] +ffmpeg-next = "7.0" + + [profile.dev] opt-level = 1 # Enable basic optimizations in debug mode for audio decoding performance diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index c34d49a..9f4e456 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -1,9 +1,11 @@ use daw_backend::{AudioEvent, AudioSystem, EngineController, EventEmitter, WaveformPeak}; use daw_backend::audio::pool::AudioPoolEntry; +use ffmpeg_next::ffi::FF_LOSS_COLORQUANT; use std::sync::{Arc, Mutex}; use std::collections::HashMap; use std::path::Path; use tauri::{Emitter, Manager}; +use tokio::sync::oneshot; #[derive(serde::Serialize)] pub struct AudioFileMetadata { @@ -39,6 +41,8 @@ pub struct AudioState { pub(crate) next_graph_node_id: u32, // Track next node ID for each VoiceAllocator template (VoiceAllocator backend ID -> next template node ID) pub(crate) template_node_counters: HashMap, + // Pending preset save notifications (preset_path -> oneshot sender) + pub(crate) preset_save_waiters: Arc>>>, } impl Default for AudioState { @@ -51,6 +55,7 @@ impl Default for AudioState { next_track_id: 0, next_pool_index: 0, next_graph_node_id: 0, + preset_save_waiters: Arc::new(Mutex::new(HashMap::new())), template_node_counters: HashMap::new(), } } @@ -59,10 +64,20 @@ impl Default for AudioState { /// Implementation of EventEmitter that uses Tauri's event system struct TauriEventEmitter { app_handle: tauri::AppHandle, + preset_save_waiters: Arc>>>, } impl EventEmitter for TauriEventEmitter { fn emit(&self, event: AudioEvent) { + // Handle preset save notifications + if let AudioEvent::GraphPresetSaved(_, ref preset_path) = event { + if let Ok(mut waiters) = self.preset_save_waiters.lock() { + if let Some(sender) = waiters.remove(preset_path) { + let _ = sender.send(()); + } + } + } + // Serialize the event to the format expected by the frontend let serialized_event = match event { AudioEvent::PlaybackPosition(time) => { @@ -98,6 +113,9 @@ impl EventEmitter for TauriEventEmitter { AudioEvent::GraphPresetLoaded(track_id) => { SerializedAudioEvent::GraphPresetLoaded { track_id } } + AudioEvent::GraphPresetSaved(track_id, preset_path) => { + SerializedAudioEvent::GraphPresetSaved { track_id, preset_path } + } AudioEvent::MidiRecordingStopped(track_id, clip_id, note_count) => { SerializedAudioEvent::MidiRecordingStopped { track_id, clip_id, note_count } } @@ -140,7 +158,10 @@ pub async fn audio_init( } // Create TauriEventEmitter - let emitter = Arc::new(TauriEventEmitter { app_handle }); + let emitter = Arc::new(TauriEventEmitter { + app_handle, + preset_save_waiters: audio_state.preset_save_waiters.clone(), + }); // Get buffer size from audio_state (default is 256) let buffer_size = audio_state.buffer_size; @@ -201,6 +222,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(); @@ -372,13 +407,28 @@ pub async fn audio_trim_clip( state: tauri::State<'_, Arc>>, track_id: u32, clip_id: u32, - new_start_time: f64, - new_duration: f64, - new_offset: f64, + internal_start: f64, + internal_end: f64, ) -> Result<(), String> { let mut audio_state = state.lock().unwrap(); if let Some(controller) = &mut audio_state.controller { - controller.trim_clip(track_id, clip_id, new_start_time, new_duration, new_offset); + controller.trim_clip(track_id, clip_id, internal_start, internal_end); + Ok(()) + } else { + Err("Audio not initialized".to_string()) + } +} + +#[tauri::command] +pub async fn audio_extend_clip( + state: tauri::State<'_, Arc>>, + track_id: u32, + clip_id: u32, + new_external_duration: f64, +) -> Result<(), String> { + let mut audio_state = state.lock().unwrap(); + if let Some(controller) = &mut audio_state.controller { + controller.extend_clip(track_id, clip_id, new_external_duration); Ok(()) } else { Err("Audio not initialized".to_string()) @@ -567,11 +617,8 @@ pub async fn audio_load_midi_file( let sample_rate = audio_state.sample_rate; if let Some(controller) = &mut audio_state.controller { - // Load and parse the MIDI file - let mut clip = daw_backend::load_midi_file(&path, 0, sample_rate)?; - - // Set the start time - clip.start_time = start_time; + // Load and parse the MIDI file (clip content only, no positioning) + let clip = daw_backend::load_midi_file(&path, 0, sample_rate)?; let duration = clip.duration; // Extract note data from MIDI events @@ -597,8 +644,8 @@ pub async fn audio_load_midi_file( } } - // Add the loaded MIDI clip to the track - controller.add_loaded_midi_clip(track_id, clip); + // Add the loaded MIDI clip to the track at the specified start_time + controller.add_loaded_midi_clip(track_id, clip, start_time); Ok(MidiFileMetadata { duration, @@ -925,6 +972,49 @@ pub async fn graph_load_preset( } } +#[tauri::command] +pub async fn graph_load_preset_from_json( + state: tauri::State<'_, Arc>>, + track_id: u32, + preset_json: String, +) -> Result<(), String> { + use daw_backend::GraphPreset; + use std::io::Write; + + let mut audio_state = state.lock().unwrap(); + + // Parse the preset JSON to count nodes + let preset = GraphPreset::from_json(&preset_json) + .map_err(|e| format!("Failed to parse preset: {}", e))?; + + // Update the node ID counter to account for nodes in the preset + let node_count = preset.nodes.len() as u32; + audio_state.next_graph_node_id = node_count; + + if let Some(controller) = &mut audio_state.controller { + // Write JSON to a temporary file + let temp_path = std::env::temp_dir().join(format!("lb_temp_preset_{}.json", track_id)); + let mut file = std::fs::File::create(&temp_path) + .map_err(|e| format!("Failed to create temp file: {}", e))?; + file.write_all(preset_json.as_bytes()) + .map_err(|e| format!("Failed to write temp file: {}", e))?; + drop(file); + + // Load from the temp file + controller.graph_load_preset(track_id, temp_path.to_string_lossy().to_string()); + + // Clean up temp file (after a delay to allow loading) + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(500)); + let _ = std::fs::remove_file(temp_path); + }); + + Ok(()) + } else { + Err("Audio not initialized".to_string()) + } +} + #[derive(serde::Serialize)] pub struct PresetInfo { pub name: String, @@ -1122,9 +1212,21 @@ pub async fn multi_sampler_add_layer( root_key: u8, velocity_min: u8, velocity_max: u8, + loop_start: Option, + loop_end: Option, + loop_mode: Option, ) -> Result<(), String> { + use daw_backend::audio::node_graph::nodes::LoopMode; + let mut audio_state = state.lock().unwrap(); + // Parse loop mode string to enum + let loop_mode_enum = match loop_mode.as_deref() { + Some("continuous") => LoopMode::Continuous, + Some("oneshot") | Some("one-shot") => LoopMode::OneShot, + _ => LoopMode::OneShot, // Default + }; + if let Some(controller) = &mut audio_state.controller { controller.multi_sampler_add_layer( track_id, @@ -1135,6 +1237,9 @@ pub async fn multi_sampler_add_layer( root_key, velocity_min, velocity_max, + loop_start, + loop_end, + loop_mode_enum, ); Ok(()) } else { @@ -1150,6 +1255,9 @@ pub struct LayerInfo { pub root_key: u8, pub velocity_min: u8, pub velocity_max: u8, + pub loop_start: Option, + pub loop_end: Option, + pub loop_mode: String, } #[tauri::command] @@ -1161,32 +1269,70 @@ pub async fn multi_sampler_get_layers( eprintln!("[multi_sampler_get_layers] FUNCTION CALLED with track_id: {}, node_id: {}", track_id, node_id); use daw_backend::GraphPreset; - let mut audio_state = state.lock().unwrap(); - if let Some(controller) = &mut audio_state.controller { - // Use preset serialization to get node data including layers - // Use timestamp to ensure unique temp file for each query to avoid conflicts - let timestamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_path = std::env::temp_dir().join(format!("temp_layers_query_{}_{}_{}.json", track_id, node_id, timestamp)); - let temp_path_str = temp_path.to_string_lossy().to_string(); - eprintln!("[multi_sampler_get_layers] Temp path: {}", temp_path_str); + // Set up oneshot channel to wait for preset save completion + let (tx, rx) = oneshot::channel(); - controller.graph_save_preset( - track_id, - temp_path_str.clone(), - "temp".to_string(), - "".to_string(), - vec![] - ); + let (temp_path_str, preset_save_waiters) = { + let mut audio_state = state.lock().unwrap(); - // Give the audio thread time to process - std::thread::sleep(std::time::Duration::from_millis(50)); + // Clone preset_save_waiters first before any mutable borrows + let preset_save_waiters = audio_state.preset_save_waiters.clone(); - // Read the temp file and parse it - eprintln!("[multi_sampler_get_layers] Reading temp file..."); - match std::fs::read_to_string(&temp_path) { + if let Some(controller) = &mut audio_state.controller { + // Use preset serialization to get node data including layers + // Use timestamp to ensure unique temp file for each query to avoid conflicts + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_path = std::env::temp_dir().join(format!("temp_layers_query_{}_{}_{}.json", track_id, node_id, timestamp)); + let temp_path_str = temp_path.to_string_lossy().to_string(); + eprintln!("[multi_sampler_get_layers] Temp path: {}", temp_path_str); + + // Register waiter for this preset path + { + let mut waiters = preset_save_waiters.lock().unwrap(); + waiters.insert(temp_path_str.clone(), tx); + } + + controller.graph_save_preset( + track_id, + temp_path_str.clone(), + "temp".to_string(), + "".to_string(), + vec![] + ); + + (temp_path_str, preset_save_waiters) + } else { + eprintln!("[multi_sampler_get_layers] Audio not initialized"); + return Err("Audio not initialized".to_string()); + } + }; + + // Wait for preset save event with timeout + eprintln!("[multi_sampler_get_layers] Waiting for preset save completion..."); + match tokio::time::timeout(std::time::Duration::from_secs(5), rx).await { + Ok(Ok(())) => { + eprintln!("[multi_sampler_get_layers] Preset save complete, reading file..."); + } + Ok(Err(_)) => { + eprintln!("[multi_sampler_get_layers] Preset save channel closed"); + return Ok(Vec::new()); + } + Err(_) => { + eprintln!("[multi_sampler_get_layers] Timeout waiting for preset save"); + // Clean up waiter + let mut waiters = preset_save_waiters.lock().unwrap(); + waiters.remove(&temp_path_str); + return Ok(Vec::new()); + } + } + + let temp_path = std::path::PathBuf::from(&temp_path_str); + // Read the temp file and parse it + eprintln!("[multi_sampler_get_layers] Reading temp file..."); + match std::fs::read_to_string(&temp_path) { Ok(json) => { // Clean up temp file let _ = std::fs::remove_file(&temp_path); @@ -1208,13 +1354,22 @@ pub async fn multi_sampler_get_layers( // Check if it's a MultiSampler if let daw_backend::audio::node_graph::preset::SampleData::MultiSampler { layers } = sample_data { eprintln!("[multi_sampler_get_layers] Returning {} layers", layers.len()); - return Ok(layers.iter().map(|layer| LayerInfo { - file_path: layer.file_path.clone().unwrap_or_default(), - key_min: layer.key_min, - key_max: layer.key_max, - root_key: layer.root_key, - velocity_min: layer.velocity_min, - velocity_max: layer.velocity_max, + return Ok(layers.iter().map(|layer| { + let loop_mode_str = match layer.loop_mode { + daw_backend::audio::node_graph::nodes::LoopMode::Continuous => "continuous", + daw_backend::audio::node_graph::nodes::LoopMode::OneShot => "oneshot", + }; + LayerInfo { + file_path: layer.file_path.clone().unwrap_or_default(), + key_min: layer.key_min, + key_max: layer.key_max, + root_key: layer.root_key, + velocity_min: layer.velocity_min, + velocity_max: layer.velocity_max, + loop_start: layer.loop_start, + loop_end: layer.loop_end, + loop_mode: loop_mode_str.to_string(), + } }).collect()); } else { eprintln!("[multi_sampler_get_layers] sample_data is not MultiSampler type"); @@ -1233,10 +1388,6 @@ pub async fn multi_sampler_get_layers( Ok(Vec::new()) // Return empty list if file doesn't exist } } - } else { - eprintln!("[multi_sampler_get_layers] Audio not initialized"); - Err("Audio not initialized".to_string()) - } } #[tauri::command] @@ -1250,9 +1401,21 @@ pub async fn multi_sampler_update_layer( root_key: u8, velocity_min: u8, velocity_max: u8, + loop_start: Option, + loop_end: Option, + loop_mode: Option, ) -> Result<(), String> { + use daw_backend::audio::node_graph::nodes::LoopMode; + let mut audio_state = state.lock().unwrap(); + // Parse loop mode string to enum + let loop_mode_enum = match loop_mode.as_deref() { + Some("continuous") => LoopMode::Continuous, + Some("oneshot") | Some("one-shot") => LoopMode::OneShot, + _ => LoopMode::OneShot, // Default + }; + if let Some(controller) = &mut audio_state.controller { controller.multi_sampler_update_layer( track_id, @@ -1263,6 +1426,9 @@ pub async fn multi_sampler_update_layer( root_key, velocity_min, velocity_max, + loop_start, + loop_end, + loop_mode_enum, ); Ok(()) } else { @@ -1418,6 +1584,7 @@ pub enum SerializedAudioEvent { GraphConnectionError { track_id: u32, message: String }, GraphStateChanged { track_id: u32 }, GraphPresetLoaded { track_id: u32 }, + GraphPresetSaved { track_id: u32, preset_path: String }, } // audio_get_events command removed - events are now pushed via Tauri event system @@ -1502,3 +1669,45 @@ pub async fn audio_load_track_graph( Err("Audio not initialized".to_string()) } } + +#[tauri::command] +pub async fn audio_export( + state: tauri::State<'_, Arc>>, + output_path: String, + format: String, + sample_rate: u32, + channels: u32, + bit_depth: u16, + mp3_bitrate: u32, + start_time: f64, + end_time: f64, +) -> Result<(), String> { + let mut audio_state = state.lock().unwrap(); + + if let Some(controller) = &mut audio_state.controller { + // Parse format + let export_format = match format.as_str() { + "wav" => daw_backend::audio::ExportFormat::Wav, + "flac" => daw_backend::audio::ExportFormat::Flac, + _ => return Err(format!("Unsupported format: {}", format)), + }; + + // Create export settings + let settings = daw_backend::audio::ExportSettings { + format: export_format, + sample_rate, + channels, + bit_depth, + mp3_bitrate, + start_time, + end_time, + }; + + // Call export through controller + controller.export_audio(&settings, &output_path)?; + + Ok(()) + } else { + Err("Audio not initialized".to_string()) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2c53548..f08cf88 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,13 +1,14 @@ use std::{path::PathBuf, sync::{Arc, Mutex}}; +use tauri_plugin_log::{Target, TargetKind}; use log::{trace, info, debug, warn, error}; +use tracing_subscriber::EnvFilter; +use chrono::Local; use tauri::{AppHandle, Manager, Url, WebviewUrl, WebviewWindowBuilder}; mod audio; mod video; -mod frame_streamer; -mod renderer; -mod render_window; +mod video_server; #[derive(Default)] @@ -15,12 +16,6 @@ struct AppState { counter: u32, } -struct RenderWindowState { - handle: Option, - canvas_offset: (i32, i32), // Canvas position relative to window - canvas_size: (u32, u32), -} - // Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ #[tauri::command] fn greet(name: &str) -> String { @@ -49,170 +44,44 @@ fn error(msg: String) { } #[tauri::command] -fn get_frame_streamer_port( - frame_streamer: tauri::State<'_, Arc>>, -) -> u16 { - let streamer = frame_streamer.lock().unwrap(); - streamer.port() +async fn open_folder_dialog(app: AppHandle, title: String) -> Result, String> { + use tauri_plugin_dialog::DialogExt; + + let folder = app.dialog() + .file() + .set_title(&title) + .blocking_pick_folder(); + + Ok(folder.map(|path| path.to_string())) } -// Render window commands #[tauri::command] -fn render_window_create( - x: i32, - y: i32, - width: u32, - height: u32, - canvas_offset_x: i32, - canvas_offset_y: i32, - app: tauri::AppHandle, - state: tauri::State<'_, Arc>>, -) -> Result<(), String> { - let mut render_state = state.lock().unwrap(); +async fn read_folder_files(path: String) -> Result, String> { + use std::fs; - if render_state.handle.is_some() { - return Err("Render window already exists".to_string()); - } + let entries = fs::read_dir(&path) + .map_err(|e| format!("Failed to read directory: {}", e))?; - let handle = render_window::spawn_render_window(x, y, width, height)?; - render_state.handle = Some(handle); - render_state.canvas_offset = (canvas_offset_x, canvas_offset_y); - render_state.canvas_size = (width, height); + let audio_extensions = vec!["wav", "aif", "aiff", "flac", "mp3", "ogg"]; - // Start a background thread to poll main window position - let state_clone = state.inner().clone(); - let app_clone = app.clone(); - std::thread::spawn(move || { - let mut last_pos: Option<(i32, i32)> = None; + let mut files = Vec::new(); + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?; + let path = entry.path(); - loop { - std::thread::sleep(std::time::Duration::from_millis(50)); - - if let Some(main_window) = app_clone.get_webview_window("main") { - if let Ok(pos) = main_window.outer_position() { - let current_pos = (pos.x, pos.y); - - // Only update if position actually changed - if last_pos != Some(current_pos) { - eprintln!("[WindowSync] Main window position: {:?}", current_pos); - - let render_state = state_clone.lock().unwrap(); - if let Some(handle) = &render_state.handle { - let new_x = pos.x + render_state.canvas_offset.0; - let new_y = pos.y + render_state.canvas_offset.1; - handle.set_position(new_x, new_y); - last_pos = Some(current_pos); - } else { - break; // No handle, exit thread - } + if path.is_file() { + if let Some(ext) = path.extension() { + let ext_str = ext.to_string_lossy().to_lowercase(); + if audio_extensions.contains(&ext_str.as_str()) { + if let Some(filename) = path.file_name() { + files.push(filename.to_string_lossy().to_string()); } - } else { - break; // Window closed, exit thread } - } else { - break; // Main window gone, exit thread } } - }); - - Ok(()) -} - -#[tauri::command] -fn render_window_update_gradient( - top_r: f32, - top_g: f32, - top_b: f32, - top_a: f32, - bottom_r: f32, - bottom_g: f32, - bottom_b: f32, - bottom_a: f32, - state: tauri::State<'_, Arc>>, -) -> Result<(), String> { - let render_state = state.lock().unwrap(); - - if let Some(handle) = &render_state.handle { - handle.update_gradient( - [top_r, top_g, top_b, top_a], - [bottom_r, bottom_g, bottom_b, bottom_a], - ); - Ok(()) - } else { - Err("Render window not created".to_string()) } -} -#[tauri::command] -fn render_window_set_position( - x: i32, - y: i32, - state: tauri::State<'_, Arc>>, -) -> Result<(), String> { - let render_state = state.lock().unwrap(); - - if let Some(handle) = &render_state.handle { - handle.set_position(x, y); - Ok(()) - } else { - Err("Render window not created".to_string()) - } -} - -#[tauri::command] -fn render_window_sync_position( - app: tauri::AppHandle, - state: tauri::State<'_, Arc>>, -) -> Result<(), String> { - let render_state = state.lock().unwrap(); - - if let Some(main_window) = app.get_webview_window("main") { - if let Ok(pos) = main_window.outer_position() { - if let Some(handle) = &render_state.handle { - let new_x = pos.x + render_state.canvas_offset.0; - let new_y = pos.y + render_state.canvas_offset.1; - eprintln!("[Manual Sync] Updating to ({}, {})", new_x, new_y); - handle.set_position(new_x, new_y); - Ok(()) - } else { - Err("Render window not created".to_string()) - } - } else { - Err("Could not get window position".to_string()) - } - } else { - Err("Main window not found".to_string()) - } -} - -#[tauri::command] -fn render_window_set_size( - width: u32, - height: u32, - state: tauri::State<'_, Arc>>, -) -> Result<(), String> { - let render_state = state.lock().unwrap(); - - if let Some(handle) = &render_state.handle { - handle.set_size(width, height); - Ok(()) - } else { - Err("Render window not created".to_string()) - } -} - -#[tauri::command] -fn render_window_close( - state: tauri::State<'_, Arc>>, -) -> Result<(), String> { - let mut render_state = state.lock().unwrap(); - - if let Some(handle) = render_state.handle.take() { - handle.close(); - Ok(()) - } else { - Err("Render window not created".to_string()) - } + Ok(files) } use tauri::PhysicalSize; @@ -300,26 +169,17 @@ fn handle_file_associations(app: AppHandle, files: Vec) { #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - // Initialize env_logger with Error level only - env_logger::Builder::from_default_env() - .filter_level(log::LevelFilter::Error) - .init(); - - // Initialize WebSocket frame streamer - let frame_streamer = frame_streamer::FrameStreamer::new() - .expect("Failed to start frame streamer"); - eprintln!("[App] Frame streamer started on port {}", frame_streamer.port()); + let pkg_name = env!("CARGO_PKG_NAME").to_string(); + // Initialize video HTTP server + let video_server = video_server::VideoServer::new() + .expect("Failed to start video server"); + eprintln!("[App] Video server started on port {}", video_server.port()); tauri::Builder::default() .manage(Mutex::new(AppState::default())) .manage(Arc::new(Mutex::new(audio::AudioState::default()))) .manage(Arc::new(Mutex::new(video::VideoState::default()))) - .manage(Arc::new(Mutex::new(frame_streamer))) - .manage(Arc::new(Mutex::new(RenderWindowState { - handle: None, - canvas_offset: (0, 0), - canvas_size: (0, 0), - }))) + .manage(Arc::new(Mutex::new(video_server))) .setup(|app| { #[cfg(any(windows, target_os = "linux"))] // Windows/Linux needs different handling from macOS { @@ -355,48 +215,39 @@ pub fn run() { } Ok(()) }) - // .plugin( - // tauri_plugin_log::Builder::new() - // .filter(|metadata| { - // // ONLY allow Error-level logs, block everything else - // metadata.level() == log::Level::Error - // }) - // .timezone_strategy(tauri_plugin_log::TimezoneStrategy::UseLocal) - // .format(|out, message, record| { - // let date = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); - // out.finish(format_args!( - // "{}[{}] {}", - // date, - // record.level(), - // message - // )) - // }) - // .targets([ - // Target::new(TargetKind::Stdout), - // // LogDir locations: - // // Linux: /home/user/.local/share/org.lightningbeam.core/logs - // // macOS: /Users/user/Library/Logs/org.lightningbeam.core/logs - // // Windows: C:\Users\user\AppData\Local\org.lightningbeam.core\logs - // Target::new(TargetKind::LogDir { file_name: Some("logs".to_string()) }), - // Target::new(TargetKind::Webview), - // ]) - // .build() - // ) + .plugin( + tauri_plugin_log::Builder::new() + .timezone_strategy(tauri_plugin_log::TimezoneStrategy::UseLocal) + .format(|out, message, record| { + let date = Local::now().format("%Y-%m-%d %H:%M:%S").to_string(); + out.finish(format_args!( + "{}[{}] {}", + date, + record.level(), + message + )) + }) + .targets([ + Target::new(TargetKind::Stdout), + // LogDir locations: + // Linux: /home/user/.local/share/org.lightningbeam.core/logs + // macOS: /Users/user/Library/Logs/org.lightningbeam.core/logs + // Windows: C:\Users\user\AppData\Local\org.lightningbeam.core\logs + Target::new(TargetKind::LogDir { file_name: Some("logs".to_string()) }), + Target::new(TargetKind::Webview), + ]) + .build() + ) .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_shell::init()) .invoke_handler(tauri::generate_handler![ - greet, trace, debug, info, warn, error, create_window, get_frame_streamer_port, - render_window_create, - render_window_update_gradient, - render_window_set_position, - render_window_set_size, - render_window_sync_position, - render_window_close, + greet, trace, debug, info, warn, error, create_window, audio::audio_init, 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, @@ -405,6 +256,7 @@ pub fn run() { audio::audio_add_clip, audio::audio_move_clip, audio::audio_trim_clip, + audio::audio_extend_clip, audio::audio_start_recording, audio::audio_stop_recording, audio::audio_pause_recording, @@ -431,6 +283,7 @@ pub fn run() { audio::graph_set_output_node, audio::graph_save_preset, audio::graph_load_preset, + audio::graph_load_preset_from_json, audio::graph_list_presets, audio::graph_delete_preset, audio::graph_get_state, @@ -451,10 +304,17 @@ pub fn run() { audio::audio_resolve_missing_file, audio::audio_serialize_track_graph, audio::audio_load_track_graph, + audio::audio_export, video::video_load_file, - video::video_stream_frame, + video::video_get_frame, + video::video_get_frames_batch, video::video_set_cache_size, + open_folder_dialog, + read_folder_files, video::video_get_pool_info, + video::video_ipc_benchmark, + video::video_get_transcode_status, + video::video_allow_asset, ]) // .manage(window_counter) .build(tauri::generate_context!()) @@ -482,4 +342,5 @@ pub fn run() { } }, ); + tracing_subscriber::fmt().with_env_filter(EnvFilter::new(format!("{}=trace", pkg_name))).init(); } diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 2eda233..2622f53 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lightningbeam", - "version": "0.7.14-alpha", + "version": "0.8.1-alpha", "identifier": "org.lightningbeam.core", "build": { "frontendDist": "../src" diff --git a/src/actions/index.js b/src/actions/index.js index 5f671f1..311d6e7 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -2612,4 +2612,56 @@ export const actions = { } }, }, + + clearNodeGraph: { + execute: async (action) => { + // Get the current graph state to find all node IDs + const graphStateJson = await invoke('graph_get_state', { trackId: action.trackId }); + const graphState = JSON.parse(graphStateJson); + + // Remove all nodes from backend + for (const node of graphState.nodes) { + try { + await invoke("graph_remove_node", { + trackId: action.trackId, + nodeId: node.id, + }); + } catch (e) { + console.error(`Failed to remove node ${node.id}:`, e); + } + } + + // Reload the graph from backend + if (context.reloadNodeEditor) { + await context.reloadNodeEditor(); + } + + // Update minimap + if (context.updateMinimap) { + setTimeout(() => context.updateMinimap(), 100); + } + }, + rollback: async (action) => { + // Restore the entire graph from the saved preset JSON + try { + await invoke("graph_load_preset_from_json", { + trackId: action.trackId, + presetJson: action.savedGraphJson, + }); + + // Reload the graph editor to show the restored nodes + if (context.reloadNodeEditor) { + await context.reloadNodeEditor(); + } + + // Update minimap + if (context.updateMinimap) { + setTimeout(() => context.updateMinimap(), 100); + } + } catch (e) { + console.error('Failed to restore graph:', e); + alert('Failed to restore graph: ' + e); + } + }, + }, }; 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/layoutmanager.js b/src/layoutmanager.js index 7ab1e91..3752aac 100644 --- a/src/layoutmanager.js +++ b/src/layoutmanager.js @@ -172,7 +172,7 @@ function serializeLayoutNode(element, depth = 0) { // that matches the panes object keys, not the name property const dataName = element.getAttribute("data-pane-name"); - // Convert kebab-case to camelCase (e.g., "timeline-v2" -> "timelineV2") + // Convert kebab-case to camelCase (e.g., "preset-browser" -> "presetBrowser") const camelCaseName = dataName.replace(/-([a-z0-9])/g, (g) => g[1].toUpperCase()); console.log(`${indent} -> Found pane: ${camelCaseName}`); diff --git a/src/layouts.js b/src/layouts.js index 3102495..1e4cbdf 100644 --- a/src/layouts.js +++ b/src/layouts.js @@ -32,7 +32,7 @@ export const defaultLayouts = { type: "vertical-grid", percent: 30, children: [ - { type: "pane", name: "timelineV2" }, + { type: "pane", name: "timeline" }, { type: "pane", name: "stage" } ] }, @@ -63,7 +63,7 @@ export const defaultLayouts = { { type: "pane", name: "infopanel" } ] }, - { type: "pane", name: "timelineV2" } + { type: "pane", name: "timeline" } ] } ] @@ -81,7 +81,7 @@ export const defaultLayouts = { type: "vertical-grid", percent: 50, children: [ - { type: "pane", name: "timelineV2" }, + { type: "pane", name: "timeline" }, { type: "pane", name: "nodeEditor"} ] }, @@ -107,7 +107,7 @@ export const defaultLayouts = { percent: 50, children: [ { type: "pane", name: "stage" }, - { type: "pane", name: "timelineV2" } + { type: "pane", name: "timeline" } ] }, { @@ -142,7 +142,7 @@ export const defaultLayouts = { percent: 50, children: [ { type: "pane", name: "infopanel" }, - { type: "pane", name: "timelineV2" } + { type: "pane", name: "timeline" } ] } ] @@ -168,7 +168,7 @@ export const defaultLayouts = { percent: 70, children: [ { type: "pane", name: "stage" }, - { type: "pane", name: "timelineV2" } + { type: "pane", name: "timeline" } ] }, { type: "pane", name: "infopanel" } @@ -196,7 +196,7 @@ export const defaultLayouts = { percent: 70, children: [ { type: "pane", name: "infopanel" }, - { type: "pane", name: "timelineV2" } + { type: "pane", name: "timeline" } ] } ] @@ -223,7 +223,7 @@ export const defaultLayouts = { percent: 60, children: [ { type: "pane", name: "infopanel" }, - { type: "pane", name: "timelineV2" } + { type: "pane", name: "timeline" } ] } ] diff --git a/src/main.js b/src/main.js index 7e9db0a..181832a 100644 --- a/src/main.js +++ b/src/main.js @@ -793,7 +793,7 @@ window.addEventListener("DOMContentLoaded", () => { rootPane, 10, true, - createPane(panes.timelineV2), + createPane(panes.timeline), ); let [stageAndTimeline, _infopanel] = splitPane( panel, @@ -1406,6 +1406,28 @@ async function handleAudioEvent(event) { context.pianoRedraw(); } } + // Update MIDI activity timestamp + context.lastMidiInputTime = Date.now(); + console.log('[NoteOn] Set lastMidiInputTime to:', context.lastMidiInputTime); + + // Start animation loop to keep redrawing the MIDI indicator + if (!context.midiIndicatorAnimating) { + context.midiIndicatorAnimating = true; + const animateMidiIndicator = () => { + if (context.timelineWidget && context.timelineWidget.requestRedraw) { + context.timelineWidget.requestRedraw(); + } + + // Keep animating for 1 second after last MIDI input + const elapsed = Date.now() - context.lastMidiInputTime; + if (elapsed < 1000) { + requestAnimationFrame(animateMidiIndicator); + } else { + context.midiIndicatorAnimating = false; + } + }; + requestAnimationFrame(animateMidiIndicator); + } break; case 'NoteOff': @@ -1600,6 +1622,7 @@ async function toggleRecording() { name: 'Recording...', startTime: startTime, duration: clipDuration, + offset: 0, notes: [], loading: true }); @@ -1686,6 +1709,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 = ''; + } } } @@ -1792,12 +1819,28 @@ async function _save(path) { // Serialize current layout structure (panes, splits, sizes) const serializedLayout = serializeLayout(rootPane); + // Serialize timeline state + let timelineState = null; + if (context.timelineWidget?.timelineState) { + const ts = context.timelineWidget.timelineState; + timelineState = { + timeFormat: ts.timeFormat, + framerate: ts.framerate, + bpm: ts.bpm, + timeSignature: ts.timeSignature, + pixelsPerSecond: ts.pixelsPerSecond, + viewportStartTime: ts.viewportStartTime, + snapToFrames: ts.snapToFrames, + }; + } + const fileData = { version: "2.0.0", width: config.fileWidth, height: config.fileHeight, fps: config.framerate, layoutState: serializedLayout, // Save current layout structure + timelineState: timelineState, // Save timeline settings actions: undoStack, json: root.toJSON(), // Audio pool at the end for human readability @@ -2249,6 +2292,44 @@ async function _open(path, returnJson = false) { console.log('[JS] Skipping layout restoration'); } + // Restore timeline state if saved + if (file.timelineState && context.timelineWidget?.timelineState) { + const ts = context.timelineWidget.timelineState; + const saved = file.timelineState; + console.log('[JS] Restoring timeline state:', saved); + + if (saved.timeFormat) ts.timeFormat = saved.timeFormat; + if (saved.framerate) ts.framerate = saved.framerate; + if (saved.bpm) ts.bpm = saved.bpm; + if (saved.timeSignature) ts.timeSignature = saved.timeSignature; + if (saved.pixelsPerSecond) ts.pixelsPerSecond = saved.pixelsPerSecond; + if (saved.viewportStartTime !== undefined) ts.viewportStartTime = saved.viewportStartTime; + if (saved.snapToFrames !== undefined) ts.snapToFrames = saved.snapToFrames; + + // Update metronome button visibility based on restored time format + if (context.metronomeGroup) { + context.metronomeGroup.style.display = ts.timeFormat === 'measures' ? '' : 'none'; + } + + // Update time display + if (context.updateTimeDisplay) { + context.updateTimeDisplay(); + } + + // Update snap checkbox if it exists + const snapCheckbox = document.getElementById('snap-checkbox'); + if (snapCheckbox) { + snapCheckbox.checked = ts.snapToFrames; + } + + // Trigger timeline redraw + if (context.timelineWidget.requestRedraw) { + context.timelineWidget.requestRedraw(); + } + + console.log('[JS] Timeline state restored successfully'); + } + // Restore audio tracks and clips to the Rust backend // The fromJSON method only creates JavaScript objects, // but doesn't initialize them in the audio engine @@ -3186,6 +3267,124 @@ async function render() { document.querySelector("body").style.cursor = "default"; } +async function exportAudio() { + // Get the project duration from context + const duration = context.activeObject.duration || 60; + + // Show a simple dialog to get export settings + const dialog = document.createElement('div'); + dialog.style.cssText = ` + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--bg-color, #2a2a2a); + border: 1px solid var(--border-color, #555); + padding: 20px; + border-radius: 8px; + z-index: 10000; + color: var(--text-color, #eee); + min-width: 400px; + `; + + dialog.innerHTML = ` + +

Export Audio

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ `; + + document.body.appendChild(dialog); + + return new Promise((resolve) => { + dialog.querySelector('#export-cancel').addEventListener('click', () => { + document.body.removeChild(dialog); + resolve(null); + }); + + dialog.querySelector('#export-ok').addEventListener('click', async () => { + const format = dialog.querySelector('#export-format').value; + const sampleRate = parseInt(dialog.querySelector('#export-sample-rate').value); + const bitDepth = parseInt(dialog.querySelector('#export-bit-depth').value); + const endTime = parseFloat(dialog.querySelector('#export-end-time').value); + + document.body.removeChild(dialog); + + // Show file save dialog + const path = await saveFileDialog({ + filters: [ + { + name: format.toUpperCase() + " files", + extensions: [format], + }, + ], + defaultPath: await join(await documentDir(), `export.${format}`), + }); + + if (path) { + try { + document.querySelector("body").style.cursor = "wait"; + + await invoke('audio_export', { + outputPath: path, + format: format, + sampleRate: sampleRate, + channels: 2, + bitDepth: bitDepth, + mp3Bitrate: 320, + startTime: 0.0, + endTime: endTime, + }); + + document.querySelector("body").style.cursor = "default"; + alert('Audio exported successfully!'); + } catch (error) { + document.querySelector("body").style.cursor = "default"; + console.error('Export failed:', error); + alert('Export failed: ' + error); + } + } + + resolve(); + }); + }); +} + function updateScrollPosition(zoomFactor) { if (context.mousePos) { for (let canvas of canvases) { @@ -4262,9 +4461,9 @@ function toolbar() { return tools_scroller; } -function timeline() { +function timelineDeprecated() { let timeline_cvs = document.createElement("canvas"); - timeline_cvs.className = "timeline"; + timeline_cvs.className = "timeline-deprecated"; // Start building widget hierarchy timeline_cvs.timelinewindow = new TimelineWindow(0, 0, context) @@ -4532,9 +4731,9 @@ function timeline() { return timeline_cvs; } -function timelineV2() { +function timeline() { let canvas = document.createElement("canvas"); - canvas.className = "timeline-v2"; + canvas.className = "timeline"; // Create TimelineWindowV2 widget const timelineWidget = new TimelineWindowV2(0, 0, context); @@ -4623,6 +4822,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"; @@ -4697,6 +4944,10 @@ function timelineV2() { timelineWidget.toggleTimeFormat(); updateTimeDisplay(); updateCanvasSize(); + // Update metronome button visibility + if (context.metronomeGroup) { + context.metronomeGroup.style.display = timelineWidget.timelineState.timeFormat === 'measures' ? '' : 'none'; + } return; } @@ -4707,6 +4958,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'); @@ -4874,6 +5129,35 @@ function timelineV2() { controls.push(timeDisplay); + // Snap checkbox + const snapGroup = document.createElement("div"); + snapGroup.className = "playback-controls-group"; + snapGroup.style.display = "flex"; + snapGroup.style.alignItems = "center"; + snapGroup.style.gap = "4px"; + + const snapCheckbox = document.createElement("input"); + snapCheckbox.type = "checkbox"; + snapCheckbox.id = "snap-checkbox"; + snapCheckbox.checked = timelineWidget.timelineState.snapToFrames; + snapCheckbox.style.cursor = "pointer"; + snapCheckbox.addEventListener("change", () => { + timelineWidget.timelineState.snapToFrames = snapCheckbox.checked; + console.log('Snapping', snapCheckbox.checked ? 'enabled' : 'disabled'); + }); + + const snapLabel = document.createElement("label"); + snapLabel.htmlFor = "snap-checkbox"; + snapLabel.textContent = "Snap"; + snapLabel.style.cursor = "pointer"; + snapLabel.style.fontSize = "12px"; + snapLabel.style.color = "var(--text-secondary)"; + + snapGroup.appendChild(snapCheckbox); + snapGroup.appendChild(snapLabel); + + controls.push(snapGroup); + return controls; }; @@ -5188,6 +5472,83 @@ async function startup() { startup(); +// Track maximized pane state +let maximizedPane = null; +let savedPaneParent = null; +let savedRootPaneChildren = []; +let savedRootPaneClasses = null; + +function toggleMaximizePane(paneDiv) { + if (maximizedPane === paneDiv) { + // Restore layout + if (savedPaneParent && savedRootPaneChildren.length > 0) { + // Remove pane from root + rootPane.removeChild(paneDiv); + + // Restore all root pane children + while (rootPane.firstChild) { + rootPane.removeChild(rootPane.firstChild); + } + for (const child of savedRootPaneChildren) { + rootPane.appendChild(child); + } + + // Put pane back in its original parent + savedPaneParent.appendChild(paneDiv); + + // Restore root pane classes + if (savedRootPaneClasses) { + rootPane.className = savedRootPaneClasses; + } + + savedPaneParent = null; + savedRootPaneChildren = []; + savedRootPaneClasses = null; + } + maximizedPane = null; + + // Update button + const btn = paneDiv.querySelector('.maximize-btn'); + if (btn) { + btn.innerHTML = "⛶"; + btn.title = "Maximize Pane"; + } + + // Trigger updates + updateAll(); + } else { + // Maximize pane + // Save pane's current parent + savedPaneParent = paneDiv.parentElement; + + // Save all root pane children + savedRootPaneChildren = Array.from(rootPane.children); + savedRootPaneClasses = rootPane.className; + + // Remove pane from its parent + savedPaneParent.removeChild(paneDiv); + + // Clear root pane + while (rootPane.firstChild) { + rootPane.removeChild(rootPane.firstChild); + } + + // Add only the maximized pane to root + rootPane.appendChild(paneDiv); + maximizedPane = paneDiv; + + // Update button + const btn = paneDiv.querySelector('.maximize-btn'); + if (btn) { + btn.innerHTML = "⛶"; // Could use different icon for restore + btn.title = "Restore Layout"; + } + + // Trigger updates + updateAll(); + } +} + function createPaneMenu(div) { const menuItems = ["Item 1", "Item 2", "Item 3"]; // The items for the menu @@ -5200,6 +5561,11 @@ function createPaneMenu(div) { // Loop through the menuItems array and create a
  • for each item for (let pane in panes) { + // Skip deprecated panes + if (pane === 'timelineDeprecated') { + continue; + } + const li = document.createElement("li"); // Create the element for the icon const img = document.createElement("img"); @@ -5299,6 +5665,16 @@ function createPane(paneType = undefined, div = undefined) { } } + // Add maximize/restore button in top right + const maximizeBtn = document.createElement("button"); + maximizeBtn.className = "maximize-btn"; + maximizeBtn.title = "Maximize Pane"; + maximizeBtn.innerHTML = "⛶"; // Maximize icon + maximizeBtn.addEventListener("click", () => { + toggleMaximizePane(div); + }); + header.appendChild(maximizeBtn); + div.className = "vertical-grid pane"; div.setAttribute("data-pane-name", paneType.name); header.style.height = "calc( 2 * var(--lineheight))"; @@ -5766,7 +6142,7 @@ function renderLayers() { context.timelineWidget.requestRedraw(); } - for (let canvas of document.querySelectorAll(".timeline")) { + for (let canvas of document.querySelectorAll(".timeline-deprecated")) { const width = canvas.width; const height = canvas.height; const ctx = canvas.getContext("2d"); @@ -6552,11 +6928,16 @@ async function renderMenu() { accelerator: getShortcut("import"), }, { - text: "Export...", + text: "Export Video...", enabled: true, action: render, accelerator: getShortcut("export"), }, + { + text: "Export Audio...", + enabled: true, + action: exportAudio, + }, { text: "Quit", enabled: true, @@ -6930,9 +7311,50 @@ function nodeEditor() { const header = document.createElement("div"); header.className = "node-editor-header"; // Initial header will be updated by updateBreadcrumb() after track info is available - header.innerHTML = '
    Node Graph
    '; + header.innerHTML = ` +
    Node Graph
    + + `; container.appendChild(header); + // Add clear button handler + const clearBtn = header.querySelector('.node-graph-clear-btn'); + clearBtn.addEventListener('click', async () => { + try { + // Get current track + const trackInfo = getCurrentTrack(); + if (trackInfo === null) { + console.error('No track selected'); + alert('Please select a track first'); + return; + } + const trackId = trackInfo.trackId; + + // Get the full backend graph state as JSON + const graphStateJson = await invoke('graph_get_state', { trackId }); + const graphState = JSON.parse(graphStateJson); + + if (!graphState.nodes || graphState.nodes.length === 0) { + return; // Nothing to clear + } + + // Create and execute the action + redoStack.length = 0; // Clear redo stack + const action = { + trackId, + savedGraphJson: graphStateJson // Save the entire graph state as JSON + }; + undoStack.push({ name: 'clearNodeGraph', action }); + await actions.clearNodeGraph.execute(action); + updateMenu(); + + console.log('Cleared node graph (undoable)'); + } catch (e) { + console.error('Failed to clear node graph:', e); + alert('Failed to clear node graph: ' + e); + } + }); + // Create the Drawflow canvas const editorDiv = document.createElement("div"); editorDiv.id = "drawflow"; @@ -7355,6 +7777,9 @@ function nodeEditor() { // Update minimap on pan/zoom drawflowDiv.addEventListener('wheel', () => setTimeout(updateMinimap, 10)); + // Store updateMinimap in context so it can be called from actions + context.updateMinimap = updateMinimap; + // Initial minimap render setTimeout(updateMinimap, 200); @@ -8621,7 +9046,10 @@ function nodeEditor() { keyMax: layerConfig.keyMax, rootKey: layerConfig.rootKey, velocityMin: layerConfig.velocityMin, - velocityMax: layerConfig.velocityMax + velocityMax: layerConfig.velocityMax, + loopStart: layerConfig.loopStart, + loopEnd: layerConfig.loopEnd, + loopMode: layerConfig.loopMode }); // Wait a bit for the audio thread to process the add command @@ -8637,6 +9065,35 @@ function nodeEditor() { } }); } + + // Handle Import Folder button for MultiSampler + const importFolderBtn = nodeElement.querySelector(".import-folder-btn"); + if (importFolderBtn) { + importFolderBtn.addEventListener("mousedown", (e) => e.stopPropagation()); + importFolderBtn.addEventListener("pointerdown", (e) => e.stopPropagation()); + importFolderBtn.addEventListener("click", async (e) => { + e.stopPropagation(); + + const nodeData = editor.getNodeFromId(nodeId); + if (!nodeData || nodeData.data.backendId === null) { + showError("Node not yet created on backend"); + return; + } + + const currentTrackId = getCurrentMidiTrack(); + if (currentTrackId === null) { + showError("No MIDI track selected"); + return; + } + + try { + await showFolderImportDialog(currentTrackId, nodeData.data.backendId, nodeId); + } catch (err) { + console.error("Failed to import folder:", err); + showError(`Failed to import folder: ${err}`); + } + }); + } }, 100); } @@ -8683,8 +9140,12 @@ function nodeEditor() { nodeId: nodeData.data.backendId }); - const layersList = document.querySelector(`#sample-layers-list-${nodeId}`); - const layersContainer = document.querySelector(`#sample-layers-container-${nodeId}`); + // Find the node element and query within it for the layers list + const nodeElement = document.querySelector(`#node-${nodeId}`); + if (!nodeElement) return; + + const layersList = nodeElement.querySelector('[id^="sample-layers-list-"]'); + const layersContainer = nodeElement.querySelector('[id^="sample-layers-container-"]'); if (!layersList) return; @@ -8735,7 +9196,10 @@ function nodeEditor() { keyMax: layer.key_max, rootKey: layer.root_key, velocityMin: layer.velocity_min, - velocityMax: layer.velocity_max + velocityMax: layer.velocity_max, + loopStart: layer.loop_start, + loopEnd: layer.loop_end, + loopMode: layer.loop_mode }); if (layerConfig) { @@ -8748,7 +9212,10 @@ function nodeEditor() { keyMax: layerConfig.keyMax, rootKey: layerConfig.rootKey, velocityMin: layerConfig.velocityMin, - velocityMax: layerConfig.velocityMax + velocityMax: layerConfig.velocityMax, + loopStart: layerConfig.loopStart, + loopEnd: layerConfig.loopEnd, + loopMode: layerConfig.loopMode }); // Refresh the list @@ -9785,7 +10252,10 @@ function nodeEditor() { keyMax: layerConfig.keyMax, rootKey: layerConfig.rootKey, velocityMin: layerConfig.velocityMin, - velocityMax: layerConfig.velocityMax + velocityMax: layerConfig.velocityMax, + loopStart: layerConfig.loopStart, + loopEnd: layerConfig.loopEnd, + loopMode: layerConfig.loopMode }); // Wait a bit for the audio thread to process the add command @@ -9812,10 +10282,10 @@ function nodeEditor() { } if (nodeType === 'MultiSampler' && serializedNode.sample_data && serializedNode.sample_data.type === 'multi_sampler') { - console.log(`[reloadGraph] Condition met for node ${drawflowId}, looking for layers list element with backend ID ${serializedNode.id}`); - // Use backend ID (serializedNode.id) since that's what was used in getHTML - const layersList = nodeElement.querySelector(`#sample-layers-list-${serializedNode.id}`); - const layersContainer = nodeElement.querySelector(`#sample-layers-container-${serializedNode.id}`); + console.log(`[reloadGraph] Condition met for node ${drawflowId}, looking for layers list element`); + // Query for elements by prefix to avoid ID mismatch issues + const layersList = nodeElement.querySelector('[id^="sample-layers-list-"]'); + const layersContainer = nodeElement.querySelector('[id^="sample-layers-container-"]'); console.log(`[reloadGraph] layersList:`, layersList); console.log(`[reloadGraph] layersContainer:`, layersContainer); @@ -9863,7 +10333,43 @@ function nodeEditor() { const drawflowNodeId = parseInt(btn.dataset.drawflowNode); const layerIndex = parseInt(btn.dataset.index); const layer = layers[layerIndex]; - await showLayerEditDialog(drawflowNodeId, layerIndex, layer); + + // Show dialog with current layer settings + const layerConfig = await showLayerConfigDialog(layer.file_path, { + keyMin: layer.key_min, + keyMax: layer.key_max, + rootKey: layer.root_key, + velocityMin: layer.velocity_min, + velocityMax: layer.velocity_max, + loopStart: layer.loop_start, + loopEnd: layer.loop_end, + loopMode: layer.loop_mode + }); + + if (layerConfig) { + const nodeData = editor.getNodeFromId(drawflowNodeId); + const currentTrackId = getCurrentMidiTrack(); + if (nodeData && currentTrackId !== null) { + try { + await invoke("multi_sampler_update_layer", { + trackId: currentTrackId, + nodeId: nodeData.data.backendId, + layerIndex: layerIndex, + keyMin: layerConfig.keyMin, + keyMax: layerConfig.keyMax, + rootKey: layerConfig.rootKey, + velocityMin: layerConfig.velocityMin, + velocityMax: layerConfig.velocityMax, + loopStart: layerConfig.loopStart, + loopEnd: layerConfig.loopEnd, + loopMode: layerConfig.loopMode + }); + await refreshSampleLayersList(drawflowNodeId); + } catch (err) { + showError(`Failed to update layer: ${err}`); + } + } + } }); }); @@ -10086,11 +10592,108 @@ function piano() { } function pianoRoll() { + // Create container for piano roll and properties panel + let container = document.createElement("div"); + container.className = "piano-roll-container"; + container.style.position = "relative"; + container.style.width = "100%"; + container.style.height = "100%"; + container.style.display = "flex"; + let canvas = document.createElement("canvas"); canvas.className = "piano-roll"; + canvas.style.flex = "1"; + + // Create properties panel + let propertiesPanel = document.createElement("div"); + propertiesPanel.className = "piano-roll-properties"; + propertiesPanel.style.display = "flex"; + propertiesPanel.style.gap = "15px"; + propertiesPanel.style.padding = "10px"; + propertiesPanel.style.backgroundColor = "#1e1e1e"; + propertiesPanel.style.borderLeft = "1px solid #333"; + propertiesPanel.style.alignItems = "center"; + propertiesPanel.style.fontSize = "12px"; + propertiesPanel.style.color = "#ccc"; + + // Create property sections + const createPropertySection = (label, isEditable = false) => { + const section = document.createElement("div"); + section.style.display = "flex"; + section.style.flexDirection = "column"; + section.style.gap = "5px"; + + const labelEl = document.createElement("label"); + labelEl.textContent = label; + labelEl.style.fontSize = "11px"; + labelEl.style.color = "#999"; + section.appendChild(labelEl); + + if (isEditable) { + const inputContainer = document.createElement("div"); + inputContainer.style.display = "flex"; + inputContainer.style.gap = "5px"; + inputContainer.style.alignItems = "center"; + + const input = document.createElement("input"); + input.type = "number"; + input.style.width = "45px"; + input.style.padding = "3px"; + input.style.backgroundColor = "#2a2a2a"; + input.style.border = "1px solid #444"; + input.style.borderRadius = "3px"; + input.style.color = "#ccc"; + input.style.fontSize = "12px"; + input.style.boxSizing = "border-box"; + inputContainer.appendChild(input); + + const slider = document.createElement("input"); + slider.type = "range"; + slider.style.flex = "1"; + slider.style.minWidth = "80px"; + inputContainer.appendChild(slider); + + section.appendChild(inputContainer); + return { section, input, slider }; + } else { + const value = document.createElement("span"); + value.style.color = "#fff"; + value.textContent = "-"; + section.appendChild(value); + return { section, value }; + } + }; + + const pitchSection = createPropertySection("Pitch"); + const velocitySection = createPropertySection("Velocity", true); + const modulationSection = createPropertySection("Modulation", true); + + // Configure velocity slider + velocitySection.input.min = 1; + velocitySection.input.max = 127; + velocitySection.slider.min = 1; + velocitySection.slider.max = 127; + + // Configure modulation slider + modulationSection.input.min = 0; + modulationSection.input.max = 127; + modulationSection.slider.min = 0; + modulationSection.slider.max = 127; + + propertiesPanel.appendChild(pitchSection.section); + propertiesPanel.appendChild(velocitySection.section); + propertiesPanel.appendChild(modulationSection.section); + + container.appendChild(canvas); + container.appendChild(propertiesPanel); // Create the piano roll editor widget canvas.pianoRollEditor = new PianoRollEditor(0, 0, 0, 0); + canvas.pianoRollEditor.propertiesPanel = { + pitch: pitchSection.value, + velocity: { input: velocitySection.input, slider: velocitySection.slider }, + modulation: { input: modulationSection.input, slider: modulationSection.slider } + }; function updateCanvasSize() { const canvasStyles = window.getComputedStyle(canvas); @@ -10111,6 +10714,30 @@ function pianoRoll() { // Render the piano roll canvas.pianoRollEditor.draw(ctx); + + // Update properties panel layout based on aspect ratio + const containerWidth = container.offsetWidth; + const containerHeight = container.offsetHeight; + const isWide = containerWidth > containerHeight; + + if (isWide) { + // Side layout + container.style.flexDirection = "row"; + propertiesPanel.style.flexDirection = "column"; + propertiesPanel.style.width = "240px"; + propertiesPanel.style.height = "auto"; + propertiesPanel.style.borderLeft = "1px solid #333"; + propertiesPanel.style.borderTop = "none"; + propertiesPanel.style.alignItems = "stretch"; + } else { + // Bottom layout + container.style.flexDirection = "column"; + propertiesPanel.style.flexDirection = "row"; + propertiesPanel.style.width = "auto"; + propertiesPanel.style.height = "60px"; + propertiesPanel.style.borderLeft = "none"; + propertiesPanel.style.borderTop = "1px solid #333"; + } } // Store references in context for global access and playback updates @@ -10121,7 +10748,7 @@ function pianoRoll() { const resizeObserver = new ResizeObserver(() => { updateCanvasSize(); }); - resizeObserver.observe(canvas); + resizeObserver.observe(container); // Pointer event handlers (works with mouse and touch) canvas.addEventListener("pointerdown", (e) => { @@ -10163,7 +10790,69 @@ function pianoRoll() { // Prevent text selection canvas.addEventListener("selectstart", (e) => e.preventDefault()); - return canvas; + // Add event handlers for velocity and modulation inputs/sliders + const syncInputSlider = (input, slider) => { + input.addEventListener("input", () => { + const value = parseInt(input.value); + if (!isNaN(value)) { + slider.value = value; + } + }); + slider.addEventListener("input", () => { + input.value = slider.value; + }); + }; + + syncInputSlider(velocitySection.input, velocitySection.slider); + syncInputSlider(modulationSection.input, modulationSection.slider); + + // Handle property changes + const updateNoteProperty = (property, value) => { + const clipData = canvas.pianoRollEditor.getSelectedClip(); + if (!clipData || !clipData.clip || !clipData.clip.notes) return; + + if (canvas.pianoRollEditor.selectedNotes.size === 0) return; + + for (const noteIndex of canvas.pianoRollEditor.selectedNotes) { + if (noteIndex >= 0 && noteIndex < clipData.clip.notes.length) { + const note = clipData.clip.notes[noteIndex]; + if (property === "velocity") { + note.velocity = value; + } else if (property === "modulation") { + note.modulation = value; + } + } + } + + canvas.pianoRollEditor.syncNotesToBackend(clipData); + updateCanvasSize(); + }; + + velocitySection.input.addEventListener("change", (e) => { + const value = parseInt(e.target.value); + if (!isNaN(value) && value >= 1 && value <= 127) { + updateNoteProperty("velocity", value); + } + }); + + velocitySection.slider.addEventListener("change", (e) => { + const value = parseInt(e.target.value); + updateNoteProperty("velocity", value); + }); + + modulationSection.input.addEventListener("change", (e) => { + const value = parseInt(e.target.value); + if (!isNaN(value) && value >= 0 && value <= 127) { + updateNoteProperty("modulation", value); + } + }); + + modulationSection.slider.addEventListener("change", (e) => { + const value = parseInt(e.target.value); + updateNoteProperty("modulation", value); + }); + + return container; } function presetBrowser() { @@ -10578,6 +11267,368 @@ function midiToNoteName(midiNote) { return `${noteName}${octave}`; } +// Parse note name from string (e.g., "A#3" -> 58) +function noteNameToMidi(noteName) { + const noteMap = { + 'C': 0, 'C#': 1, 'Db': 1, + 'D': 2, 'D#': 3, 'Eb': 3, + 'E': 4, + 'F': 5, 'F#': 6, 'Gb': 6, + 'G': 7, 'G#': 8, 'Ab': 8, + 'A': 9, 'A#': 10, 'Bb': 10, + 'B': 11 + }; + + // Match note + optional accidental + octave + const match = noteName.match(/^([A-G][#b]?)(-?\d+)$/i); + if (!match) return null; + + const note = match[1].toUpperCase(); + const octave = parseInt(match[2]); + + if (!(note in noteMap)) return null; + + return (octave + 1) * 12 + noteMap[note]; +} + +// Parse filename to extract note and velocity layer +function parseSampleFilename(filename) { + // Remove extension + const nameWithoutExt = filename.replace(/\.(wav|aif|aiff|flac|mp3|ogg)$/i, ''); + + // Try to find note patterns (e.g., A#3, Bb2, C4) + const notePattern = /([A-G][#b]?)(-?\d+)/gi; + const noteMatches = [...nameWithoutExt.matchAll(notePattern)]; + + if (noteMatches.length === 0) return null; + + // Use the last note match (usually most reliable) + const noteMatch = noteMatches[noteMatches.length - 1]; + const noteStr = noteMatch[1] + noteMatch[2]; + const midiNote = noteNameToMidi(noteStr); + + if (midiNote === null) return null; + + // Try to find velocity indicators + // Common patterns: v1, v2, v3, pp, p, mp, mf, f, ff, fff + const velPatterns = [ + { regex: /v(\d+)/i, type: 'numeric' }, + { regex: /\b(ppp|pp|p|mp|mf|f|ff|fff)\b/i, type: 'dynamic' } + ]; + + let velocityMarker = null; + let velocityType = null; + + for (const pattern of velPatterns) { + const match = nameWithoutExt.match(pattern.regex); + if (match) { + velocityMarker = match[1]; + velocityType = pattern.type; + break; + } + } + + return { + note: noteStr, + midiNote, + velocityMarker, + velocityType, + filename + }; +} + +// Group samples by note and velocity +function groupSamples(samples) { + const groups = {}; + const velocityLayers = new Set(); + + for (const sample of samples) { + const parsed = parseSampleFilename(sample); + if (!parsed) continue; + + const key = parsed.midiNote; + if (!groups[key]) { + groups[key] = { + note: parsed.note, + midiNote: parsed.midiNote, + layers: [] + }; + } + + groups[key].layers.push({ + filename: parsed.filename, + velocityMarker: parsed.velocityMarker, + velocityType: parsed.velocityType + }); + + if (parsed.velocityMarker) { + velocityLayers.add(parsed.velocityMarker); + } + } + + return { groups, velocityLayers: Array.from(velocityLayers).sort() }; +} + +// Show folder import dialog +async function showFolderImportDialog(trackId, nodeId, drawflowNodeId) { + // Select folder + const folderPath = await invoke("open_folder_dialog", { + title: "Select Sample Folder" + }); + + if (!folderPath) return; + + // Read files from folder + const files = await invoke("read_folder_files", { + path: folderPath + }); + + if (!files || files.length === 0) { + alert("No audio files found in folder"); + return; + } + + // Parse and group samples + const { groups, velocityLayers } = groupSamples(files); + const noteGroups = Object.values(groups).sort((a, b) => a.midiNote - b.midiNote); + + if (noteGroups.length === 0) { + alert("Could not detect note names in filenames"); + return; + } + + // Show configuration dialog + return new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.className = 'dialog-overlay'; + + const dialog = document.createElement('div'); + dialog.className = 'dialog'; + dialog.style.width = '600px'; + dialog.style.maxWidth = '90vw'; + dialog.style.maxHeight = '80vh'; + dialog.style.padding = '20px'; + dialog.style.backgroundColor = '#2a2a2a'; + dialog.style.border = '1px solid #444'; + dialog.style.borderRadius = '8px'; + dialog.style.color = '#e0e0e0'; + + let velocityMapping = {}; + + // Initialize default velocity mappings + if (velocityLayers.length > 0) { + const step = Math.floor(127 / velocityLayers.length); + velocityLayers.forEach((marker, idx) => { + velocityMapping[marker] = { + min: idx * step, + max: (idx + 1) * step - 1 + }; + }); + // Ensure last layer goes to 127 + if (velocityLayers.length > 0) { + velocityMapping[velocityLayers[velocityLayers.length - 1]].max = 127; + } + } + + dialog.innerHTML = ` +

    Import Sample Folder

    +
    + Folder: ${folderPath}
    + Found: ${noteGroups.length} notes, ${velocityLayers.length} velocity layer(s) +
    + + ${velocityLayers.length > 0 ? ` +
    + Velocity Mapping: + + + + + + + + + + ${velocityLayers.map(marker => ` + + + + + + `).join('')} + +
    MarkerMin VelocityMax Velocity
    ${marker}
    +
    + ` : ''} + +
    + Preview: +
      + ${noteGroups.slice(0, 20).map(group => ` +
    • ${group.note} (MIDI ${group.midiNote}): ${group.layers.length} sample(s) + ${group.layers.length <= 3 ? `
        ${group.layers.map(l => l.filename).join('
        ')}
      ` : ''} +
    • + `).join('')} + ${noteGroups.length > 20 ? `
    • ... and ${noteGroups.length - 20} more notes
    • ` : ''} +
    +
    + +
    + +
    + +
    + + +
    + `; + + overlay.appendChild(dialog); + document.body.appendChild(overlay); + + // Update velocity mapping when inputs change + const velInputs = dialog.querySelectorAll('.vel-min, .vel-max'); + velInputs.forEach(input => { + input.addEventListener('input', () => { + const marker = input.dataset.marker; + const isMin = input.classList.contains('vel-min'); + const value = parseInt(input.value); + + if (isMin) { + velocityMapping[marker].min = value; + } else { + velocityMapping[marker].max = value; + } + }); + }); + + dialog.querySelector('#btn-cancel').addEventListener('click', () => { + document.body.removeChild(overlay); + resolve(); + }); + + dialog.querySelector('#btn-import').addEventListener('click', async () => { + const autoKeyRanges = dialog.querySelector('#auto-key-ranges').checked; + + try { + // Build layer list + const layersToImport = []; + + for (let i = 0; i < noteGroups.length; i++) { + const group = noteGroups[i]; + + // Calculate key range + let keyMin, keyMax; + if (autoKeyRanges) { + // Split range between adjacent notes + const prevNote = i > 0 ? noteGroups[i - 1].midiNote : 0; + const nextNote = i < noteGroups.length - 1 ? noteGroups[i + 1].midiNote : 127; + + keyMin = i === 0 ? 0 : Math.ceil((prevNote + group.midiNote) / 2); + keyMax = i === noteGroups.length - 1 ? 127 : Math.floor((group.midiNote + nextNote) / 2); + } else { + keyMin = group.midiNote; + keyMax = group.midiNote; + } + + // Add each velocity layer for this note + for (const layer of group.layers) { + let velMin = 0, velMax = 127; + + if (layer.velocityMarker && velocityMapping[layer.velocityMarker]) { + velMin = velocityMapping[layer.velocityMarker].min; + velMax = velocityMapping[layer.velocityMarker].max; + } + + layersToImport.push({ + filePath: `${folderPath}/${layer.filename}`, + keyMin, + keyMax, + rootKey: group.midiNote, + velocityMin: velMin, + velocityMax: velMax + }); + } + } + + // Import all layers + dialog.querySelector('#btn-import').disabled = true; + dialog.querySelector('#btn-import').textContent = 'Importing...'; + + for (const layer of layersToImport) { + await invoke("multi_sampler_add_layer", { + trackId, + nodeId, + filePath: layer.filePath, + keyMin: layer.keyMin, + keyMax: layer.keyMax, + rootKey: layer.rootKey, + velocityMin: layer.velocityMin, + velocityMax: layer.velocityMax, + loopStart: null, + loopEnd: null, + loopMode: "Continuous" + }); + } + + // Refresh the layers list by re-fetching from backend + try { + const layers = await invoke("multi_sampler_get_layers", { + trackId, + nodeId + }); + + // Find the node element and update the layers list + const nodeElement = document.querySelector(`#node-${drawflowNodeId}`); + if (nodeElement) { + const layersList = nodeElement.querySelector('[id^="sample-layers-list-"]'); + + if (layersList) { + if (layers.length === 0) { + layersList.innerHTML = 'No layers loaded'; + } else { + layersList.innerHTML = layers.map((layer, index) => { + const filename = layer.file_path.split('/').pop().split('\\').pop(); + const keyRange = `${midiToNoteName(layer.key_min)}-${midiToNoteName(layer.key_max)}`; + const rootNote = midiToNoteName(layer.root_key); + const velRange = `${layer.velocity_min}-${layer.velocity_max}`; + + return ` + + ${filename} + ${keyRange} + ${rootNote} + ${velRange} + +
    + + +
    + + + `; + }).join(''); + } + } + } + } catch (refreshErr) { + console.error("Failed to refresh layers list:", refreshErr); + } + + document.body.removeChild(overlay); + resolve(); + } catch (err) { + alert(`Failed to import: ${err}`); + dialog.querySelector('#btn-import').disabled = false; + dialog.querySelector('#btn-import').textContent = 'Import'; + } + }); + }); +} + // Show dialog to configure MultiSampler layer zones function showLayerConfigDialog(filePath, existingConfig = null) { return new Promise((resolve) => { @@ -10590,6 +11641,9 @@ function showLayerConfigDialog(filePath, existingConfig = null) { const rootKey = existingConfig?.rootKey ?? 60; const velocityMin = existingConfig?.velocityMin ?? 0; const velocityMax = existingConfig?.velocityMax ?? 127; + const loopMode = existingConfig?.loopMode ?? 'oneshot'; + const loopStart = existingConfig?.loopStart ?? null; + const loopEnd = existingConfig?.loopEnd ?? null; // Create modal dialog const dialog = document.createElement('div'); @@ -10636,6 +11690,33 @@ function showLayerConfigDialog(filePath, existingConfig = null) { +
    + + +
    + Continuous mode will auto-detect loop points if not specified +
    +
    +
    + +
    +
    + + +
    + - +
    + + +
    +
    +
    + Leave empty to auto-detect optimal loop points +
    +
    @@ -10650,6 +11731,8 @@ function showLayerConfigDialog(filePath, existingConfig = null) { const keyMinInput = dialog.querySelector('#key-min'); const keyMaxInput = dialog.querySelector('#key-max'); const rootKeyInput = dialog.querySelector('#root-key'); + const loopModeSelect = dialog.querySelector('#loop-mode'); + const loopPointsGroup = dialog.querySelector('#loop-points-group'); const updateKeyMinName = () => { const note = parseInt(keyMinInput.value) || 0; @@ -10670,6 +11753,12 @@ function showLayerConfigDialog(filePath, existingConfig = null) { keyMaxInput.addEventListener('input', updateKeyMaxName); rootKeyInput.addEventListener('input', updateRootKeyName); + // Toggle loop points visibility based on loop mode + loopModeSelect.addEventListener('change', () => { + const isContinuous = loopModeSelect.value === 'continuous'; + loopPointsGroup.style.display = isContinuous ? 'block' : 'none'; + }); + // Focus first input setTimeout(() => dialog.querySelector('#key-min')?.focus(), 100); @@ -10688,6 +11777,13 @@ function showLayerConfigDialog(filePath, existingConfig = null) { const rootKey = parseInt(rootKeyInput.value); const velocityMin = parseInt(dialog.querySelector('#velocity-min').value); const velocityMax = parseInt(dialog.querySelector('#velocity-max').value); + const loopMode = loopModeSelect.value; + + // Get loop points (null if empty) + const loopStartInput = dialog.querySelector('#loop-start'); + const loopEndInput = dialog.querySelector('#loop-end'); + const loopStart = loopStartInput.value ? parseInt(loopStartInput.value) : null; + const loopEnd = loopEndInput.value ? parseInt(loopEndInput.value) : null; // Validate ranges if (keyMin > keyMax) { @@ -10705,13 +11801,22 @@ function showLayerConfigDialog(filePath, existingConfig = null) { return; } + // Validate loop points if both are specified + if (loopStart !== null && loopEnd !== null && loopStart >= loopEnd) { + alert('Loop Start must be less than Loop End'); + return; + } + dialog.remove(); resolve({ keyMin, keyMax, rootKey, velocityMin, - velocityMax + velocityMax, + loopMode, + loopStart, + loopEnd }); }); @@ -10752,14 +11857,14 @@ const panes = { name: "toolbar", func: toolbar, }, + timelineDeprecated: { + name: "timeline-deprecated", + func: timelineDeprecated, + }, timeline: { name: "timeline", func: timeline, }, - timelineV2: { - name: "timeline-v2", - func: timelineV2, - }, infopanel: { name: "infopanel", func: infopanel, @@ -10825,6 +11930,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/models/layer.js b/src/models/layer.js index 977eea9..3b92b73 100644 --- a/src/models/layer.js +++ b/src/models/layer.js @@ -1179,12 +1179,12 @@ class AudioTrack { name: clip.name, startTime: clip.startTime, duration: clip.duration, + offset: clip.offset || 0, // Default to 0 if not present }; // Restore audio-specific fields if (clip.poolIndex !== undefined) { clipData.poolIndex = clip.poolIndex; - clipData.offset = clip.offset; } // Restore MIDI-specific fields diff --git a/src/nodeTypes.js b/src/nodeTypes.js index ccbed92..a2c78f8 100644 --- a/src/nodeTypes.js +++ b/src/nodeTypes.js @@ -883,7 +883,8 @@ export const nodeTypes = {
    - + +
    diff --git a/src/state.js b/src/state.js index a1788bd..2925f39 100644 --- a/src/state.js +++ b/src/state.js @@ -42,6 +42,12 @@ export let context = { recordingTrackId: null, recordingClipId: null, 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 @@ -91,7 +97,7 @@ export let config = { currentLayout: "animation", // Current active layout key defaultLayout: "animation", // Default layout for new files showStartScreen: false, // Show layout picker on startup (disabled for now) - restoreLayoutFromFile: false, // Restore layout when opening files + restoreLayoutFromFile: true, // Restore layout when opening files customLayouts: [] // User-saved custom layouts }; diff --git a/src/styles.css b/src/styles.css index b31d04d..7a04d2e 100644 --- a/src/styles.css +++ b/src/styles.css @@ -213,6 +213,23 @@ button { user-select: none; } +/* Maximize button in pane headers */ +.maximize-btn { + margin-left: auto; + margin-right: 8px; + padding: 4px 8px; + background: none; + border: 1px solid var(--foreground-color); + color: var(--text-primary); + cursor: pointer; + border-radius: 3px; + font-size: 14px; +} + +.maximize-btn:hover { + background-color: var(--surface-light); +} + .pane { user-select: none; } @@ -675,7 +692,7 @@ button { margin: 0px; } #popupMenu li { - color: var(--keyframe); + color: var(--text-primary); list-style-type: none; display: flex; align-items: center; /* Vertically center the image and text */ @@ -773,7 +790,7 @@ button { background-color: var(--shade); } #popupMenu li { - color: var(--background-color); + color: var(--text-primary); } #popupMenu li:hover { background-color: var(--surface-light); @@ -1022,6 +1039,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 { @@ -1209,6 +1244,7 @@ button { border-bottom: 1px solid #3d3d3d; display: flex; align-items: center; + justify-content: space-between; padding: 0 16px; z-index: 200; user-select: none; @@ -1249,6 +1285,22 @@ button { border-color: #5d5d5d; } +.node-graph-clear-btn { + padding: 4px 12px; + background: #d32f2f; + border: 1px solid #b71c1c; + border-radius: 3px; + color: white; + font-size: 12px; + cursor: pointer; + transition: background 0.2s; +} + +.node-graph-clear-btn:hover { + background: #e53935; + border-color: #c62828; +} + .exit-template-btn:active { background: #5d5d5d; } diff --git a/src/timeline.js b/src/timeline.js index 81eae63..e1d2268 100644 --- a/src/timeline.js +++ b/src/timeline.js @@ -24,7 +24,7 @@ class TimelineState { this.rulerHeight = 30 // Height of time ruler in pixels // Snapping (Phase 5) - this.snapToFrames = false // Whether to snap keyframes to frame boundaries + this.snapToFrames = true // Whether to snap keyframes to frame boundaries (default: on) } /** diff --git a/src/widgets.js b/src/widgets.js index 5640a4e..176f317 100644 --- a/src/widgets.js +++ b/src/widgets.js @@ -582,6 +582,54 @@ class TimelineWindowV2 extends Widget { this.automationNameCache = new Map() } + /** + * Quantize a time value to the nearest beat/measure division based on zoom level. + * Only applies when in measures mode and snapping is enabled. + * @param {number} time - The time value to quantize (in seconds) + * @returns {number} - The quantized time value + */ + quantizeTime(time) { + // Only quantize in measures mode with snapping enabled + if (this.timelineState.timeFormat !== 'measures' || !this.timelineState.snapToFrames) { + return time + } + + const bpm = this.timelineState.bpm || 120 + const beatsPerSecond = bpm / 60 + const beatDuration = 1 / beatsPerSecond // Duration of one beat in seconds + const beatsPerMeasure = this.timelineState.timeSignature?.numerator || 4 + + // Calculate beat width in pixels + const beatWidth = beatDuration * this.timelineState.pixelsPerSecond + + // Base threshold for zoom level detection (adjustable) + const zoomThreshold = 30 + + // Determine quantization level based on zoom (beat width in pixels) + // When zoomed out (small beat width), quantize to measures + // When zoomed in (large beat width), quantize to smaller divisions + let quantizeDuration + if (beatWidth < zoomThreshold * 0.5) { + // Very zoomed out: quantize to whole measures + quantizeDuration = beatDuration * beatsPerMeasure + } else if (beatWidth < zoomThreshold) { + // Zoomed out: quantize to half measures (2 beats in 4/4) + quantizeDuration = beatDuration * (beatsPerMeasure / 2) + } else if (beatWidth < zoomThreshold * 2) { + // Medium zoom: quantize to beats + quantizeDuration = beatDuration + } else if (beatWidth < zoomThreshold * 4) { + // Zoomed in: quantize to half beats (eighth notes in 4/4) + quantizeDuration = beatDuration / 2 + } else { + // Very zoomed in: quantize to quarter beats (sixteenth notes in 4/4) + quantizeDuration = beatDuration / 4 + } + + // Round time to nearest quantization unit + return Math.round(time / quantizeDuration) * quantizeDuration + } + draw(ctx) { ctx.save() @@ -594,9 +642,6 @@ class TimelineWindowV2 extends Widget { ctx.fillStyle = backgroundColor ctx.fillRect(0, 0, this.width, this.height) - // Draw snapping checkbox in ruler header area (Phase 5) - this.drawSnappingCheckbox(ctx) - // Draw time ruler at top, offset by track header width ctx.save() ctx.translate(this.trackHeaderWidth, 0) @@ -659,33 +704,6 @@ class TimelineWindowV2 extends Widget { ctx.restore() } - /** - * Draw snapping checkbox in ruler header area (Phase 5) - */ - drawSnappingCheckbox(ctx) { - const checkboxSize = 14 - const checkboxX = 10 - const checkboxY = (this.ruler.height - checkboxSize) / 2 - - // Draw checkbox border - ctx.strokeStyle = foregroundColor - ctx.lineWidth = 1 - ctx.strokeRect(checkboxX, checkboxY, checkboxSize, checkboxSize) - - // Fill if snapping is enabled - if (this.timelineState.snapToFrames) { - ctx.fillStyle = foregroundColor - ctx.fillRect(checkboxX + 2, checkboxY + 2, checkboxSize - 4, checkboxSize - 4) - } - - // Draw label - ctx.fillStyle = labelColor - ctx.font = '11px sans-serif' - ctx.textAlign = 'left' - ctx.textBaseline = 'middle' - ctx.fillText('Snap', checkboxX + checkboxSize + 6, this.ruler.height / 2) - } - /** * Draw fixed track headers on the left (names, expand/collapse) */ @@ -799,6 +817,40 @@ class TimelineWindowV2 extends Widget { ctx.fillText(typeText, typeX, y + this.trackHierarchy.trackHeight / 2) } + + // Draw MIDI activity indicator for active MIDI track + if (track.type === 'audio' && track.object && track.object.type === 'midi') { + + if (this.context && this.context.lastMidiInputTime > 0) { + + // Check if this is the selected/active MIDI track + const isActiveMidiTrack = isSelected && track.object && track.object.audioTrackId !== undefined + + + if (isActiveMidiTrack) { + const elapsed = Date.now() - this.context.lastMidiInputTime + const fadeTime = 1000 // Fade out over 1 second (increased for visibility) + + if (elapsed < fadeTime) { + 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 + + + // Draw pulsing circle with border + ctx.strokeStyle = `rgba(0, 255, 0, ${alpha})` + ctx.fillStyle = `rgba(0, 255, 0, ${alpha})` + ctx.lineWidth = 2 + ctx.beginPath() + ctx.arc(indicatorX, indicatorY, indicatorSize / 2, 0, Math.PI * 2) + ctx.fill() + ctx.stroke() + } + } + } + } + // Draw toggle buttons for object/shape/audio/midi tracks (Phase 3) if (track.type === 'object' || track.type === 'shape' || track.type === 'audio' || track.type === 'midi') { const buttonSize = 14 @@ -1356,6 +1408,18 @@ class TimelineWindowV2 extends Widget { trackHeight - 10 ) + // Highlight selected MIDI clip + if (isMIDI && context.pianoRollEditor && clip.clipId === context.pianoRollEditor.selectedClipId) { + ctx.strokeStyle = '#6fdc6f' // Bright green for selected MIDI clip + ctx.lineWidth = 2 + ctx.strokeRect( + startX, + y + 5, + clipWidth, + trackHeight - 10 + ) + } + // Draw clip name if there's enough space const minWidthForLabel = 40 if (clipWidth >= minWidthForLabel) { @@ -1384,60 +1448,99 @@ class TimelineWindowV2 extends Widget { const availableHeight = trackHeight - 10 - (verticalPadding * 2) const noteHeight = availableHeight / 12 - // Calculate visible time range within the clip + // Get clip trim boundaries (internal_start = offset, internal_end depends on source) + const clipOffset = clip.offset || 0 + // Use stored internalDuration if available (set when trimming), otherwise calculate from notes + let internalDuration + if (clip.internalDuration !== undefined) { + internalDuration = clip.internalDuration + } else { + // Fallback: calculate from actual notes (for clips that haven't been trimmed) + let contentEndTime = clipOffset + for (const note of clip.notes) { + const noteEnd = note.start_time + note.duration + if (noteEnd > contentEndTime) { + contentEndTime = noteEnd + } + } + internalDuration = contentEndTime - clipOffset + } + const contentEndTime = clipOffset + internalDuration + // If clip.duration exceeds internal duration, we're looping + const isLooping = clip.duration > internalDuration && internalDuration > 0 + + // Calculate visible time range within the clip (in clip-local time) const clipEndX = startX + clipWidth const visibleStartTime = this.timelineState.pixelToTime(Math.max(startX, 0)) - clip.startTime const visibleEndTime = this.timelineState.pixelToTime(Math.min(clipEndX, this.width)) - clip.startTime - // Binary search to find first visible note - let firstVisibleIdx = 0 - let left = 0 - let right = clip.notes.length - 1 - while (left <= right) { - const mid = Math.floor((left + right) / 2) - const noteEndTime = clip.notes[mid].start_time + clip.notes[mid].duration + // Helper function to draw notes for a given loop iteration + const drawNotesForIteration = (loopOffset, opacity) => { + ctx.fillStyle = opacity < 1 ? `rgba(111, 220, 111, ${opacity})` : '#6fdc6f' - if (noteEndTime < visibleStartTime) { - left = mid + 1 - firstVisibleIdx = left - } else { - right = mid - 1 + for (let i = 0; i < clip.notes.length; i++) { + const note = clip.notes[i] + const noteEndTime = note.start_time + note.duration + + // Skip notes that are outside the trimmed region + if (noteEndTime <= clipOffset || note.start_time >= contentEndTime) { + continue + } + + // Calculate note position in this loop iteration + const noteDisplayStart = note.start_time - clipOffset + loopOffset + const noteDisplayEnd = noteEndTime - clipOffset + loopOffset + + // Skip if this iteration's note is beyond clip duration + if (noteDisplayStart >= clip.duration) { + continue + } + + // Exit early if note starts after visible range + if (noteDisplayStart > visibleEndTime) { + continue + } + + // Skip if note ends before visible range + if (noteDisplayEnd < visibleStartTime) { + continue + } + + // Calculate note position (pitch mod 12 for chromatic representation) + const pitchClass = note.note % 12 + // Invert Y so higher pitches appear at top + const noteY = y + 5 + ((11 - pitchClass) * noteHeight) + + // Calculate note timing on timeline + const noteStartX = this.timelineState.timeToPixel(clip.startTime + noteDisplayStart) + let noteEndX = this.timelineState.timeToPixel(clip.startTime + Math.min(noteDisplayEnd, clip.duration)) + + // Clip to visible bounds + const visibleStartX = Math.max(noteStartX, startX + 2) + const visibleEndX = Math.min(noteEndX, startX + clipWidth - 2) + const visibleWidth = visibleEndX - visibleStartX + + if (visibleWidth > 0) { + // Draw note rectangle + ctx.fillRect( + visibleStartX, + noteY, + visibleWidth, + noteHeight - 1 // Small gap between notes + ) + } } } - // Draw visible notes only - ctx.fillStyle = '#6fdc6f' // Bright green for note bars + // Draw primary notes at full opacity + drawNotesForIteration(0, 1.0) - for (let i = firstVisibleIdx; i < clip.notes.length; i++) { - const note = clip.notes[i] - - // Exit early if note starts after visible range - if (note.start_time > visibleEndTime) { - break - } - - // Calculate note position (pitch mod 12 for chromatic representation) - const pitchClass = note.note % 12 - // Invert Y so higher pitches appear at top - const noteY = y + 5 + ((11 - pitchClass) * noteHeight) - - // Calculate note timing on timeline - const noteStartX = this.timelineState.timeToPixel(clip.startTime + note.start_time) - const noteEndX = this.timelineState.timeToPixel(clip.startTime + note.start_time + note.duration) - - // Clip to visible bounds - const visibleStartX = Math.max(noteStartX, startX + 2) - const visibleEndX = Math.min(noteEndX, startX + clipWidth - 2) - const visibleWidth = visibleEndX - visibleStartX - - if (visibleWidth > 0) { - // Draw note rectangle - ctx.fillRect( - visibleStartX, - noteY, - visibleWidth, - noteHeight - 1 // Small gap between notes - ) + // Draw looped iterations at 50% opacity + if (isLooping) { + let loopOffset = internalDuration + while (loopOffset < clip.duration) { + drawNotesForIteration(loopOffset, 0.5) + loopOffset += internalDuration } } } else if (!isMIDI && clip.waveform && clip.waveform.length > 0) { @@ -1940,22 +2043,6 @@ class TimelineWindowV2 extends Widget { } mousedown(x, y) { - // Check if clicking on snapping checkbox (Phase 5) - if (y <= this.ruler.height && x < this.trackHeaderWidth) { - const checkboxSize = 14 - const checkboxX = 10 - const checkboxY = (this.ruler.height - checkboxSize) / 2 - - if (x >= checkboxX && x <= checkboxX + checkboxSize && - y >= checkboxY && y <= checkboxY + checkboxSize) { - // Toggle snapping - this.timelineState.snapToFrames = !this.timelineState.snapToFrames - console.log('Snapping', this.timelineState.snapToFrames ? 'enabled' : 'disabled') - if (this.requestRedraw) this.requestRedraw() - return true - } - } - // Check if clicking in ruler area (after track headers) if (y <= this.ruler.height && x >= this.trackHeaderWidth) { // Adjust x for ruler (remove track header offset) @@ -2187,6 +2274,36 @@ class TimelineWindowV2 extends Widget { return true } + // Check if clicking on loop corner (top-right) to extend/loop clip + const loopCornerInfo = this.getAudioClipLoopCornerAtPoint(track, adjustedX, adjustedY) + if (loopCornerInfo) { + // Skip if right-clicking (button 2) + if (this.lastClickEvent?.button === 2) { + return false + } + + // Select the track + this.selectTrack(track) + + // Start loop corner dragging + this.draggingLoopCorner = { + track: track, + clip: loopCornerInfo.clip, + clipIndex: loopCornerInfo.clipIndex, + audioTrack: loopCornerInfo.audioTrack, + isMIDI: loopCornerInfo.isMIDI, + initialDuration: loopCornerInfo.clip.duration + } + + // Enable global mouse events for dragging + this._globalEvents.add("mousemove") + this._globalEvents.add("mouseup") + + console.log('Started dragging loop corner') + if (this.requestRedraw) this.requestRedraw() + return true + } + // Check if clicking on audio clip edge to start trimming const audioEdgeInfo = this.getAudioClipEdgeAtPoint(track, adjustedX, adjustedY) if (audioEdgeInfo) { @@ -2231,6 +2348,16 @@ class TimelineWindowV2 extends Widget { // Select the track this.selectTrack(track) + // If this is a MIDI clip, update piano roll selection + if (audioClipInfo.isMIDI && context.pianoRollEditor) { + context.pianoRollEditor.selectedClipId = audioClipInfo.clip.clipId + context.pianoRollEditor.selectedNotes.clear() + // Trigger piano roll redraw to show the selection change + if (context.pianoRollRedraw) { + context.pianoRollRedraw() + } + } + // Start audio clip dragging const clickTime = this.timelineState.pixelToTime(adjustedX) this.draggingAudioClip = { @@ -2832,7 +2959,8 @@ class TimelineWindowV2 extends Widget { return { clip: clip, clipIndex: i, - audioTrack: audioTrack + audioTrack: audioTrack, + isMIDI: audioTrack.type === 'midi' } } } @@ -2877,6 +3005,47 @@ class TimelineWindowV2 extends Widget { return null } + /** + * Check if hovering over the loop corner (top-right) of an audio/MIDI clip + * Returns clip info if in the loop corner zone + */ + getAudioClipLoopCornerAtPoint(track, x, y) { + if (track.type !== 'audio') return null + + const trackIndex = this.trackHierarchy.tracks.indexOf(track) + if (trackIndex === -1) return null + + const trackY = this.trackHierarchy.getTrackY(trackIndex) + const trackHeight = this.trackHierarchy.trackHeight + const clipTop = trackY + 5 + const cornerSize = 12 // Size of the corner hot zone in pixels + + // Check if y is in the top portion of the clip + if (y < clipTop || y > clipTop + cornerSize) return null + + const clickTime = this.timelineState.pixelToTime(x) + const audioTrack = track.object + + // Check each clip + for (let i = 0; i < audioTrack.clips.length; i++) { + const clip = audioTrack.clips[i] + const clipEnd = clip.startTime + clip.duration + const clipEndX = this.timelineState.timeToPixel(clipEnd) + + // Check if x is near the right edge (within corner zone) + if (x >= clipEndX - cornerSize && x <= clipEndX) { + return { + clip: clip, + clipIndex: i, + audioTrack: audioTrack, + isMIDI: audioTrack.type === 'midi' + } + } + } + + return null + } + getVideoClipAtPoint(track, x, y) { if (track.type !== 'video') return null @@ -3791,19 +3960,23 @@ class TimelineWindowV2 extends Widget { // Handle audio clip edge dragging (trimming) if (this.draggingAudioClipEdge) { const adjustedX = x - this.trackHeaderWidth - const newTime = this.timelineState.pixelToTime(adjustedX) + const rawTime = this.timelineState.pixelToTime(adjustedX) const minClipDuration = this.context.config.minClipDuration if (this.draggingAudioClipEdge.edge === 'left') { // Dragging left edge - adjust startTime and offset const initialEnd = this.draggingAudioClipEdge.initialClipStart + this.draggingAudioClipEdge.initialClipDuration const maxStartTime = initialEnd - minClipDuration - const newStartTime = Math.max(0, Math.min(newTime, maxStartTime)) + // Quantize the new start time + let newStartTime = Math.max(0, Math.min(rawTime, maxStartTime)) + newStartTime = this.quantizeTime(newStartTime) const startTimeDelta = newStartTime - this.draggingAudioClipEdge.initialClipStart this.draggingAudioClipEdge.clip.startTime = newStartTime this.draggingAudioClipEdge.clip.offset = this.draggingAudioClipEdge.initialClipOffset + startTimeDelta this.draggingAudioClipEdge.clip.duration = this.draggingAudioClipEdge.initialClipDuration - startTimeDelta + // Also update internalDuration when trimming (this is the content length before looping) + this.draggingAudioClipEdge.clip.internalDuration = this.draggingAudioClipEdge.initialClipDuration - startTimeDelta // Also trim linked video clip if it exists if (this.draggingAudioClipEdge.clip.linkedVideoClip) { @@ -3815,14 +3988,21 @@ class TimelineWindowV2 extends Widget { } else { // Dragging right edge - adjust duration const minEndTime = this.draggingAudioClipEdge.initialClipStart + minClipDuration - const newEndTime = Math.max(minEndTime, newTime) + // Quantize the new end time + let newEndTime = Math.max(minEndTime, rawTime) + newEndTime = this.quantizeTime(newEndTime) let newDuration = newEndTime - this.draggingAudioClipEdge.clip.startTime - // Constrain duration to not exceed source file duration minus offset - const maxAvailableDuration = this.draggingAudioClipEdge.clip.sourceDuration - this.draggingAudioClipEdge.clip.offset - newDuration = Math.min(newDuration, maxAvailableDuration) + // Constrain duration to not exceed source file duration minus offset (for audio clips only) + // MIDI clips don't have sourceDuration and can be extended freely + if (this.draggingAudioClipEdge.clip.sourceDuration !== undefined) { + const maxAvailableDuration = this.draggingAudioClipEdge.clip.sourceDuration - (this.draggingAudioClipEdge.clip.offset || 0) + newDuration = Math.min(newDuration, maxAvailableDuration) + } this.draggingAudioClipEdge.clip.duration = newDuration + // Also update internalDuration when trimming (this is the content length before looping) + this.draggingAudioClipEdge.clip.internalDuration = newDuration // Also trim linked video clip if it exists if (this.draggingAudioClipEdge.clip.linkedVideoClip) { @@ -3836,6 +4016,25 @@ class TimelineWindowV2 extends Widget { return true } + // Handle loop corner dragging (extending/looping clip) + if (this.draggingLoopCorner) { + const adjustedX = x - this.trackHeaderWidth + const newTime = this.timelineState.pixelToTime(adjustedX) + const minClipDuration = this.context.config.minClipDuration + + // Calculate new end time and quantize it + let newEndTime = Math.max(this.draggingLoopCorner.clip.startTime + minClipDuration, newTime) + newEndTime = this.quantizeTime(newEndTime) + const newDuration = newEndTime - this.draggingLoopCorner.clip.startTime + + // Update clip duration (no maximum constraint - allows looping) + this.draggingLoopCorner.clip.duration = newDuration + + // Trigger timeline redraw + if (this.requestRedraw) this.requestRedraw() + return true + } + // Handle audio clip dragging if (this.draggingAudioClip) { // Adjust coordinates to timeline area @@ -3989,7 +4188,8 @@ class TimelineWindowV2 extends Widget { // Update cursor based on hover position (when not dragging) if (!this.draggingAudioClip && !this.draggingVideoClip && !this.draggingAudioClipEdge && !this.draggingVideoClipEdge && - !this.draggingKeyframe && !this.draggingPlayhead && !this.draggingSegment) { + !this.draggingKeyframe && !this.draggingPlayhead && !this.draggingSegment && + !this.draggingLoopCorner) { const trackY = y - this.ruler.height if (trackY >= 0 && x >= this.trackHeaderWidth) { const adjustedY = trackY - this.trackScrollOffset @@ -3997,6 +4197,16 @@ class TimelineWindowV2 extends Widget { const track = this.trackHierarchy.getTrackAtY(adjustedY) if (track) { + // Check for audio/MIDI clip loop corner (top-right) - must check before edge detection + if (track.type === 'audio') { + const loopCornerInfo = this.getAudioClipLoopCornerAtPoint(track, adjustedX, adjustedY) + if (loopCornerInfo) { + // Use the same rotate cursor as the transform tool corner handles + this.cursor = "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='currentColor' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M8 3a5 5 0 1 1-4.546 2.914.5.5 0 0 0-.908-.417A6 6 0 1 0 8 2z'/%3E%3Cpath d='M8 4.466V.534a.25.25 0 0 0-.41-.192L5.23 2.308a.25.25 0 0 0 0 .384l2.36 1.966A.25.25 0 0 0 8 4.466'/%3E%3C/svg%3E\") 12 12, auto" + return false + } + } + // Check for audio clip edge if (track.type === 'audio') { const audioEdgeInfo = this.getAudioClipEdgeAtPoint(track, adjustedX, adjustedY) @@ -4085,13 +4295,28 @@ class TimelineWindowV2 extends Widget { if (this.draggingAudioClipEdge) { console.log('Finished trimming audio clip edge') - // Update backend with new clip trim + const clip = this.draggingAudioClipEdge.clip + const trackId = this.draggingAudioClipEdge.audioTrack.audioTrackId + const clipId = clip.clipId + + // If dragging left edge, also move the clip's timeline position + if (this.draggingAudioClipEdge.edge === 'left') { + invoke('audio_move_clip', { + trackId: trackId, + clipId: clipId, + newStartTime: clip.startTime + }).catch(error => { + console.error('Failed to move audio clip in backend:', error) + }) + } + + // Update the internal trim boundaries + // internal_start = offset, internal_end = offset + duration (content region) invoke('audio_trim_clip', { - trackId: this.draggingAudioClipEdge.audioTrack.audioTrackId, - clipId: this.draggingAudioClipEdge.clip.clipId, - newStartTime: this.draggingAudioClipEdge.clip.startTime, - newDuration: this.draggingAudioClipEdge.clip.duration, - newOffset: this.draggingAudioClipEdge.clip.offset + trackId: trackId, + clipId: clipId, + internalStart: clip.offset, + internalEnd: clip.offset + clip.duration }).catch(error => { console.error('Failed to trim audio clip in backend:', error) }) @@ -4111,6 +4336,33 @@ class TimelineWindowV2 extends Widget { return true } + // Complete loop corner dragging (extending/looping clip) + if (this.draggingLoopCorner) { + console.log('Finished extending clip via loop corner') + + const clip = this.draggingLoopCorner.clip + const trackId = this.draggingLoopCorner.audioTrack.audioTrackId + const clipId = clip.clipId + + // Call audio_extend_clip to update the external duration in the backend + invoke('audio_extend_clip', { + trackId: trackId, + clipId: clipId, + newExternalDuration: clip.duration + }).catch(error => { + console.error('Failed to extend audio clip in backend:', error) + }) + + // Clean up dragging state + this.draggingLoopCorner = null + this._globalEvents.delete("mousemove") + this._globalEvents.delete("mouseup") + + // Final redraw + if (this.requestRedraw) this.requestRedraw() + return true + } + // Complete video clip edge dragging (trimming) if (this.draggingVideoClipEdge) { console.log('Finished trimming video clip edge') @@ -4120,12 +4372,26 @@ class TimelineWindowV2 extends Widget { const linkedAudioClip = this.draggingVideoClipEdge.clip.linkedAudioClip const audioTrack = this.draggingVideoClipEdge.videoLayer.linkedAudioTrack if (audioTrack) { + const trackId = audioTrack.audioTrackId + const clipId = linkedAudioClip.clipId + + // If dragging left edge, also move the clip's timeline position + if (this.draggingVideoClipEdge.edge === 'left') { + invoke('audio_move_clip', { + trackId: trackId, + clipId: clipId, + newStartTime: linkedAudioClip.startTime + }).catch(error => { + console.error('Failed to move linked audio clip in backend:', error) + }) + } + + // Update the internal trim boundaries invoke('audio_trim_clip', { - trackId: audioTrack.audioTrackId, - clipId: linkedAudioClip.clipId, - newStartTime: linkedAudioClip.startTime, - newDuration: linkedAudioClip.duration, - newOffset: linkedAudioClip.offset + trackId: trackId, + clipId: clipId, + internalStart: linkedAudioClip.offset, + internalEnd: linkedAudioClip.offset + linkedAudioClip.duration }).catch(error => { console.error('Failed to trim linked audio clip in backend:', error) }) @@ -4875,6 +5141,12 @@ class VirtualPiano extends Widget { // Handle piano keys const baseNote = this.keyboardMap[key]; if (baseNote !== undefined) { + // Check if this key is already pressed (prevents duplicate note-ons from OS key repeat quirks) + if (this.activeKeyPresses.has(key)) { + e.preventDefault(); + return; + } + // Note: octave offset is applied by shifting the visible piano range // so we play the base note directly const note = baseNote + (this.octaveOffset * 12); @@ -5332,10 +5604,12 @@ class PianoRollEditor extends Widget { // Interaction state this.selectedNotes = new Set() // Set of note indices - this.dragMode = null // null, 'move', 'resize-left', 'resize-right', 'create' + this.selectedClipId = null // Currently selected clip ID for editing + this.dragMode = null // null, 'move', 'resize', 'create', 'select' this.dragStartX = 0 this.dragStartY = 0 this.creatingNote = null // Temporary note being created + this.selectionRect = null // Rectangle for multi-select {startX, startY, endX, endY} this.isDragging = false // Note preview playback state @@ -5347,10 +5621,24 @@ class PianoRollEditor extends Widget { this.autoScrollEnabled = true // Auto-scroll to follow playhead during playback this.lastPlayheadTime = 0 // Track last playhead position + // Properties panel state + this.propertyInputs = {} // Will hold references to input elements + // Start timer to check for note duration expiry this.checkNoteDurationTimer = setInterval(() => this.checkNoteDuration(), 50) } + // Get the dimensions of the piano roll grid area (excluding keyboard) + // Note: Properties panel is outside the canvas now, so we don't subtract it here + getGridBounds() { + return { + left: this.keyboardWidth, + top: 0, + width: this.width - this.keyboardWidth, + height: this.height + } + } + checkNoteDuration() { if (this.playingNote !== null && this.playingNoteMaxDuration !== null && this.playingNoteStartTime !== null) { const elapsed = (Date.now() - this.playingNoteStartTime) / 1000 @@ -5370,23 +5658,46 @@ class PianoRollEditor extends Widget { } } - // Get the currently selected MIDI clip from context - getSelectedClip() { + // Get all MIDI clips and the selected clip from the first MIDI track + getMidiClipsData() { if (typeof context === 'undefined' || !context.activeObject || !context.activeObject.audioTracks) { return null } - // Find the first MIDI track with a selected clip + // Find the first MIDI track for (const track of context.activeObject.audioTracks) { if (track.type === 'midi' && track.clips && track.clips.length > 0) { - // For now, just return the first clip on the first MIDI track - // TODO: Add proper clip selection mechanism - return { clip: track.clips[0], trackId: track.audioTrackId } + // If no clip is selected, default to the first clip + if (this.selectedClipId === null && track.clips.length > 0) { + this.selectedClipId = track.clips[0].clipId + } + + // Find the selected clip + let selectedClip = track.clips.find(c => c.clipId === this.selectedClipId) + + // If selected clip not found (maybe deleted), select first clip + if (!selectedClip && track.clips.length > 0) { + selectedClip = track.clips[0] + this.selectedClipId = selectedClip.clipId + } + + return { + allClips: track.clips, + selectedClip: selectedClip, + trackId: track.audioTrackId + } } } return null } + // Get the currently selected MIDI clip (for backward compatibility) + getSelectedClip() { + const data = this.getMidiClipsData() + if (!data || !data.selectedClip) return null + return { clip: data.selectedClip, trackId: data.trackId } + } + hitTest(x, y) { return x >= 0 && x <= this.width && y >= 0 && y <= this.height } @@ -5413,7 +5724,22 @@ class PianoRollEditor extends Widget { return time * this.pixelsPerSecond - this.scrollX + this.keyboardWidth } - // Find note at screen position + // Find which clip contains the given time + findClipAtTime(time) { + const clipsData = this.getMidiClipsData() + if (!clipsData || !clipsData.allClips) return null + + for (const clip of clipsData.allClips) { + const clipStart = clip.startTime || 0 + const clipEnd = clipStart + (clip.duration || 0) + if (time >= clipStart && time <= clipEnd) { + return clip + } + } + return null + } + + // Find note at screen position (only searches selected clip) findNoteAtPosition(x, y) { const clipData = this.getSelectedClip() if (!clipData || !clipData.clip.notes) { @@ -5422,12 +5748,14 @@ class PianoRollEditor extends Widget { const note = this.screenToNote(y) const time = this.screenToTime(x) + const clipStartTime = clipData.clip.startTime || 0 + const clipLocalTime = time - clipStartTime // Search in reverse order so we find top-most notes first for (let i = clipData.clip.notes.length - 1; i >= 0; i--) { const n = clipData.clip.notes[i] const noteMatches = Math.round(n.note) === Math.round(note) - const timeInRange = time >= n.start_time && time <= (n.start_time + n.duration) + const timeInRange = clipLocalTime >= n.start_time && clipLocalTime <= (n.start_time + n.duration) if (noteMatches && timeInRange) { return i @@ -5445,7 +5773,9 @@ class PianoRollEditor extends Widget { } const note = clipData.clip.notes[noteIndex] - const noteEndX = this.timeToScreenX(note.start_time + note.duration) + const clipStartTime = clipData.clip.startTime || 0 + const globalEndTime = clipStartTime + note.start_time + note.duration + const noteEndX = this.timeToScreenX(globalEndTime) // Consider clicking within 8 pixels of the right edge as resize return Math.abs(x - noteEndX) < 8 @@ -5468,6 +5798,22 @@ class PianoRollEditor extends Widget { const note = this.screenToNote(y) const time = this.screenToTime(x) + // Check if clicking on a different clip and switch to it + const clickedClip = this.findClipAtTime(time) + if (clickedClip && clickedClip.clipId !== this.selectedClipId) { + this.selectedClipId = clickedClip.clipId + this.selectedNotes.clear() + // Redraw to show the new selection + if (context.timelineWidget) { + context.timelineWidget.requestRedraw() + } + // Don't start dragging/editing on the same click that switches clips + this.isDragging = false + this._globalEvents.delete("mousemove") + this._globalEvents.delete("mouseup") + return + } + // Check if clicking on an existing note const noteIndex = this.findNoteAtPosition(x, y) @@ -5507,31 +5853,49 @@ class PianoRollEditor extends Widget { } } } else { - // Clicking on empty space - start creating a new note - this.dragMode = 'create' - this.selectedNotes.clear() + // Clicking on empty space + const isShiftHeld = this.lastClickEvent?.shiftKey || false - // Create a temporary note for preview - const newNoteValue = Math.round(note) - this.creatingNote = { - note: newNoteValue, - start_time: time, - duration: 0.1, // Minimum duration - velocity: 100 - } + if (isShiftHeld) { + // Shift+click: Start creating a new note + this.dragMode = 'create' + this.selectedNotes.clear() - // Play preview of the new note - const clipData = this.getSelectedClip() - if (clipData) { - this.playingNote = newNoteValue - this.playingNoteMaxDuration = null // No max duration for creating notes - this.playingNoteStartTime = Date.now() + // Create a temporary note for preview (store in clip-local time) + const clipData = this.getSelectedClip() + const clipStartTime = clipData?.clip?.startTime || 0 + const clipLocalTime = time - clipStartTime - invoke('audio_send_midi_note_on', { - trackId: clipData.trackId, + const newNoteValue = Math.round(note) + this.creatingNote = { note: newNoteValue, + start_time: clipLocalTime, + duration: 0.1, // Minimum duration velocity: 100 - }) + } + + // Play preview of the new note + if (clipData) { + this.playingNote = newNoteValue + this.playingNoteMaxDuration = null // No max duration for creating notes + this.playingNoteStartTime = Date.now() + + invoke('audio_send_midi_note_on', { + trackId: clipData.trackId, + note: newNoteValue, + velocity: 100 + }) + } + } else { + // Regular click: Start selection rectangle + this.dragMode = 'select' + this.selectedNotes.clear() + this.selectionRect = { + startX: x, + startY: y, + endX: x, + endY: y + } } } } @@ -5556,7 +5920,9 @@ class PianoRollEditor extends Widget { // Extend the note being created if (this.creatingNote) { const currentTime = this.screenToTime(x) - const duration = Math.max(0.1, currentTime - this.creatingNote.start_time) + const clipStartTime = clipData.clip.startTime || 0 + const clipLocalTime = currentTime - clipStartTime + const duration = Math.max(0.1, clipLocalTime - this.creatingNote.start_time) this.creatingNote.duration = duration } } else if (this.dragMode === 'move') { @@ -5618,7 +5984,9 @@ class PianoRollEditor extends Widget { if (this.resizingNoteIndex >= 0 && this.resizingNoteIndex < clipData.clip.notes.length) { const note = clipData.clip.notes[this.resizingNoteIndex] const currentTime = this.screenToTime(x) - const newDuration = Math.max(0.1, currentTime - note.start_time) + const clipStartTime = clipData.clip.startTime || 0 + const clipLocalTime = currentTime - clipStartTime + const newDuration = Math.max(0.1, clipLocalTime - note.start_time) note.duration = newDuration // Trigger timeline redraw to show updated notes @@ -5626,6 +5994,15 @@ class PianoRollEditor extends Widget { context.timelineWidget.requestRedraw() } } + } else if (this.dragMode === 'select') { + // Update selection rectangle + if (this.selectionRect) { + this.selectionRect.endX = x + this.selectionRect.endY = y + + // Update selected notes based on rectangle + this.updateSelectionFromRect(clipData) + } } } @@ -5635,6 +6012,31 @@ class PianoRollEditor extends Widget { const clipData = this.getSelectedClip() + // Check if this was a simple click (not a drag) on empty space + if (this.dragMode === 'select' && this.dragStartX !== undefined && this.dragStartY !== undefined) { + const dragDistance = Math.sqrt( + Math.pow(x - this.dragStartX, 2) + Math.pow(y - this.dragStartY, 2) + ) + + // If drag distance is minimal (< 5 pixels), treat it as a click to reposition playhead + if (dragDistance < 5) { + const time = this.screenToTime(x) + + // Set playhead position + if (context.activeObject) { + context.activeObject.currentTime = time + + // Request redraws to show the new playhead position + if (context.timelineWidget) { + context.timelineWidget.requestRedraw() + } + if (context.pianoRollRedraw) { + context.pianoRollRedraw() + } + } + } + } + // Stop playing note if (this.playingNote !== null && clipData) { invoke('audio_send_midi_note_off', { @@ -5688,6 +6090,7 @@ class PianoRollEditor extends Widget { this.isDragging = false this.dragMode = null this.creatingNote = null + this.selectionRect = null this.resizingNoteIndex = -1 } @@ -5711,6 +6114,76 @@ class PianoRollEditor extends Widget { this.autoScrollEnabled = false } + keydown(e) { + // Handle delete/backspace to delete selected notes + if (e.key === 'Delete' || e.key === 'Backspace') { + if (this.selectedNotes.size > 0) { + const clipData = this.getSelectedClip() + if (clipData && clipData.clip && clipData.clip.notes) { + // Convert set to sorted array in reverse order to avoid index shifting + const indicesToDelete = Array.from(this.selectedNotes).sort((a, b) => b - a) + + for (const index of indicesToDelete) { + if (index >= 0 && index < clipData.clip.notes.length) { + clipData.clip.notes.splice(index, 1) + } + } + + // Clear selection + this.selectedNotes.clear() + + // Sync to backend + this.syncNotesToBackend(clipData) + + // Trigger redraws + if (context.timelineWidget) { + context.timelineWidget.requestRedraw() + } + if (context.pianoRollRedraw) { + context.pianoRollRedraw() + } + } + e.preventDefault() + } + } + } + + updateSelectionFromRect(clipData) { + if (!clipData || !clipData.clip || !clipData.clip.notes || !this.selectionRect) { + return + } + + const clipStartTime = clipData.clip.startTime || 0 + this.selectedNotes.clear() + + // Get rectangle bounds + const minX = Math.min(this.selectionRect.startX, this.selectionRect.endX) + const maxX = Math.max(this.selectionRect.startX, this.selectionRect.endX) + const minY = Math.min(this.selectionRect.startY, this.selectionRect.endY) + const maxY = Math.max(this.selectionRect.startY, this.selectionRect.endY) + + // Convert to time/note coordinates + const minTime = this.screenToTime(minX) + const maxTime = this.screenToTime(maxX) + const minNote = this.screenToNote(maxY) // Note: Y is inverted + const maxNote = this.screenToNote(minY) + + // Check each note + for (let i = 0; i < clipData.clip.notes.length; i++) { + const note = clipData.clip.notes[i] + const noteGlobalStart = clipStartTime + note.start_time + const noteGlobalEnd = noteGlobalStart + note.duration + + // Check if note overlaps with selection rectangle + const timeOverlaps = noteGlobalEnd >= minTime && noteGlobalStart <= maxTime + const noteOverlaps = note.note >= minNote && note.note <= maxNote + + if (timeOverlaps && noteOverlaps) { + this.selectedNotes.add(i) + } + } + } + syncNotesToBackend(clipData) { // Convert notes to backend format: (start_time, note, velocity, duration) const notes = clipData.clip.notes.map(n => [ @@ -5770,14 +6243,124 @@ class PianoRollEditor extends Widget { // Draw grid this.drawGrid(ctx, this.width, this.height) - // Draw notes if we have a selected clip - const selected = this.getSelectedClip() - if (selected && selected.clip && selected.clip.notes) { - this.drawNotes(ctx, this.width, this.height, selected.clip) + // Draw clip boundaries + const clipsData = this.getMidiClipsData() + if (clipsData && clipsData.allClips) { + this.drawClipBoundaries(ctx, this.width, this.height, clipsData.allClips) + } + + // Draw notes for all clips in the track + if (clipsData && clipsData.allClips) { + // Draw non-selected clips first (at lower opacity) + for (const clip of clipsData.allClips) { + if (clip.clipId !== this.selectedClipId && clip.notes) { + this.drawNotes(ctx, this.width, this.height, clip, 0.3) + } + } + + // Draw selected clip on top (at full opacity) + if (clipsData.selectedClip && clipsData.selectedClip.notes) { + this.drawNotes(ctx, this.width, this.height, clipsData.selectedClip, 1.0) + } } // Draw playhead this.drawPlayhead(ctx, this.width, this.height) + + // Draw selection rectangle + if (this.selectionRect) { + this.drawSelectionRect(ctx, this.width, this.height) + } + + // Update HTML properties panel + this.updatePropertiesPanel() + } + + drawSelectionRect(ctx, width, height) { + if (!this.selectionRect) return + + const gridLeft = this.keyboardWidth + const minX = Math.max(gridLeft, Math.min(this.selectionRect.startX, this.selectionRect.endX)) + const maxX = Math.min(width, Math.max(this.selectionRect.startX, this.selectionRect.endX)) + const minY = Math.max(0, Math.min(this.selectionRect.startY, this.selectionRect.endY)) + const maxY = Math.min(height, Math.max(this.selectionRect.startY, this.selectionRect.endY)) + + ctx.save() + + // Draw filled rectangle with transparency + ctx.fillStyle = 'rgba(100, 150, 255, 0.2)' + ctx.fillRect(minX, minY, maxX - minX, maxY - minY) + + // Draw border + ctx.strokeStyle = 'rgba(100, 150, 255, 0.6)' + ctx.lineWidth = 1 + ctx.strokeRect(minX, minY, maxX - minX, maxY - minY) + + ctx.restore() + } + + updatePropertiesPanel() { + // Update the HTML properties panel with current selection + if (!this.propertiesPanel) return + + const clipData = this.getSelectedClip() + const properties = this.getSelectedNoteProperties(clipData) + + // Update pitch (display-only) + this.propertiesPanel.pitch.textContent = properties.pitch || '-' + + // Update velocity + if (properties.velocity !== null) { + this.propertiesPanel.velocity.input.value = properties.velocity + this.propertiesPanel.velocity.slider.value = properties.velocity + } else { + this.propertiesPanel.velocity.input.value = '' + this.propertiesPanel.velocity.slider.value = 64 // Default middle value + } + + // Update modulation + if (properties.modulation !== null) { + this.propertiesPanel.modulation.input.value = properties.modulation + this.propertiesPanel.modulation.slider.value = properties.modulation + } else { + this.propertiesPanel.modulation.input.value = '' + this.propertiesPanel.modulation.slider.value = 0 + } + } + + getSelectedNoteProperties(clipData) { + if (!clipData || !clipData.clip || !clipData.clip.notes || this.selectedNotes.size === 0) { + return { pitch: null, velocity: null, modulation: null } + } + + const selectedIndices = Array.from(this.selectedNotes) + const notes = selectedIndices.map(i => clipData.clip.notes[i]).filter(n => n) + + if (notes.length === 0) { + return { pitch: null, velocity: null, modulation: null } + } + + // Check if all selected notes have the same values + const firstNote = notes[0] + const allSamePitch = notes.every(n => n.note === firstNote.note) + const allSameVelocity = notes.every(n => n.velocity === firstNote.velocity) + const allSameModulation = notes.every(n => (n.modulation || 0) === (firstNote.modulation || 0)) + + // Convert MIDI note number to name + const noteName = allSamePitch ? this.midiNoteToName(firstNote.note) : null + + return { + pitch: noteName, + velocity: allSameVelocity ? firstNote.velocity : null, + modulation: allSameModulation ? (firstNote.modulation || 0) : null + } + } + + midiNoteToName(midiNote) { + const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'] + const octave = Math.floor(midiNote / 12) - 1 + const noteName = noteNames[midiNote % 12] + return `${noteName}${octave} (${midiNote})` } drawKeyboard(ctx, width, height) { @@ -5810,17 +6393,19 @@ class PianoRollEditor extends Widget { } drawGrid(ctx, width, height) { - const gridLeft = this.keyboardWidth - const gridWidth = width - gridLeft + const gridBounds = this.getGridBounds() + const gridLeft = gridBounds.left + const gridWidth = gridBounds.width + const gridHeight = gridBounds.height ctx.save() ctx.beginPath() - ctx.rect(gridLeft, 0, gridWidth, height) + ctx.rect(gridLeft, 0, gridWidth, gridHeight) ctx.clip() // Draw background ctx.fillStyle = backgroundColor - ctx.fillRect(gridLeft, 0, gridWidth, height) + ctx.fillRect(gridLeft, 0, gridWidth, gridHeight) // Draw horizontal lines (note separators) ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)' @@ -5870,7 +6455,7 @@ class PianoRollEditor extends Widget { ctx.restore() } - drawNotes(ctx, width, height, clip) { + drawClipBoundaries(ctx, width, height, clips) { const gridLeft = this.keyboardWidth ctx.save() @@ -5878,13 +6463,73 @@ class PianoRollEditor extends Widget { ctx.rect(gridLeft, 0, width - gridLeft, height) ctx.clip() - // Draw existing notes - ctx.fillStyle = '#6fdc6f' + // Draw background highlight for selected clip + const selectedClip = clips.find(c => c.clipId === this.selectedClipId) + if (selectedClip) { + const clipStart = selectedClip.startTime || 0 + const clipEnd = clipStart + (selectedClip.duration || 0) + const startX = Math.max(gridLeft, this.timeToScreenX(clipStart)) + const endX = Math.min(width, this.timeToScreenX(clipEnd)) + if (endX > startX) { + ctx.fillStyle = 'rgba(111, 220, 111, 0.05)' // Very subtle green tint + ctx.fillRect(startX, 0, endX - startX, height) + } + } + + // Draw start and end lines for each clip + for (const clip of clips) { + const clipStart = clip.startTime || 0 + const clipEnd = clipStart + (clip.duration || 0) + const isSelected = clip.clipId === this.selectedClipId + + // Use brighter green for selected clip, dimmer for others + const color = isSelected ? 'rgba(111, 220, 111, 0.5)' : 'rgba(111, 220, 111, 0.2)' + const lineWidth = isSelected ? 2 : 1 + + ctx.strokeStyle = color + ctx.lineWidth = lineWidth + + // Draw clip start line + const startX = this.timeToScreenX(clipStart) + if (startX >= gridLeft && startX <= width) { + ctx.beginPath() + ctx.moveTo(startX, 0) + ctx.lineTo(startX, height) + ctx.stroke() + } + + // Draw clip end line + const endX = this.timeToScreenX(clipEnd) + if (endX >= gridLeft && endX <= width) { + ctx.beginPath() + ctx.moveTo(endX, 0) + ctx.lineTo(endX, height) + ctx.stroke() + } + } + + ctx.restore() + } + + drawNotes(ctx, width, height, clip, opacity = 1.0) { + const gridLeft = this.keyboardWidth + const clipStartTime = clip.startTime || 0 + const isSelectedClip = clip.clipId === this.selectedClipId + + ctx.save() + ctx.globalAlpha = opacity + ctx.beginPath() + ctx.rect(gridLeft, 0, width - gridLeft, height) + ctx.clip() + + // Draw existing notes at their global timeline position for (let i = 0; i < clip.notes.length; i++) { const note = clip.notes[i] - const x = this.timeToScreenX(note.start_time) + // Convert note time to global timeline time + const globalTime = clipStartTime + note.start_time + const x = this.timeToScreenX(globalTime) const y = this.noteToScreenY(note.note) const noteWidth = note.duration * this.pixelsPerSecond const noteHeight = this.noteHeight - 2 @@ -5894,11 +6539,24 @@ class PianoRollEditor extends Widget { continue } - // Highlight selected notes - if (this.selectedNotes.has(i)) { - ctx.fillStyle = '#8ffc8f' + // Calculate brightness based on velocity (1-127) + // Map velocity to brightness range: 0.35 (min) to 1.0 (max) + const velocity = note.velocity || 100 + const brightness = 0.35 + (velocity / 127) * 0.65 + + // Highlight selected notes (only for selected clip) + if (isSelectedClip && this.selectedNotes.has(i)) { + // Selected note: brighter green with velocity-based brightness + const r = Math.round(143 * brightness) + const g = Math.round(252 * brightness) + const b = Math.round(143 * brightness) + ctx.fillStyle = `rgb(${r}, ${g}, ${b})` } else { - ctx.fillStyle = '#6fdc6f' + // Normal note: velocity-based brightness + const r = Math.round(111 * brightness) + const g = Math.round(220 * brightness) + const b = Math.round(111 * brightness) + ctx.fillStyle = `rgb(${r}, ${g}, ${b})` } ctx.fillRect(x, y, noteWidth, noteHeight) @@ -5908,9 +6566,11 @@ class PianoRollEditor extends Widget { ctx.strokeRect(x, y, noteWidth, noteHeight) } - // Draw note being created - if (this.creatingNote) { - const x = this.timeToScreenX(this.creatingNote.start_time) + // Draw note being created (only for selected clip) + if (this.creatingNote && isSelectedClip) { + // Note being created is in clip-local time, convert to global + const globalTime = clipStartTime + this.creatingNote.start_time + const x = this.timeToScreenX(globalTime) const y = this.noteToScreenY(this.creatingNote.note) const noteWidth = this.creatingNote.duration * this.pixelsPerSecond const noteHeight = this.noteHeight - 2 diff --git a/tests/helpers/app.js b/tests/helpers/app.js index f869ef7..b7d86f2 100644 --- a/tests/helpers/app.js +++ b/tests/helpers/app.js @@ -9,11 +9,23 @@ export async function waitForAppReady(timeout = 5000) { await browser.waitForApp(); - // Check for "Create New File" dialog and click Create if present - const createButton = await browser.$('button*=Create'); - if (await createButton.isExisting()) { - await createButton.click(); - await browser.pause(500); // Wait for dialog to close + // Check for "Animation" card on start screen and click it if present + // The card has a label div with text "Animation" + const animationCard = await browser.$('.focus-card-label*=Animation'); + if (await animationCard.isExisting()) { + // Click the parent focus-card element + const card = await animationCard.parentElement(); + await card.waitForClickable({ timeout: 2000 }); + await card.click(); + await browser.pause(1000); // Wait longer for animation view to load + } else { + // Legacy: Check for "Create New File" dialog and click Create if present + const createButton = await browser.$('button*=Create'); + if (await createButton.isExisting()) { + await createButton.waitForClickable({ timeout: 2000 }); + await createButton.click(); + await browser.pause(500); // Wait for dialog to close + } } // Wait for the main canvas to be present diff --git a/tests/helpers/canvas.js b/tests/helpers/canvas.js index 6304f7f..e5b9320 100644 --- a/tests/helpers/canvas.js +++ b/tests/helpers/canvas.js @@ -2,12 +2,30 @@ * Canvas interaction utilities for UI testing */ +/** + * Reset canvas scroll/pan to origin + */ +export async function resetCanvasView() { + await browser.execute(function() { + if (window.context && window.context.stageWidget) { + window.context.stageWidget.offsetX = 0; + window.context.stageWidget.offsetY = 0; + // Trigger redraw to apply the reset + if (window.context.updateUI) { + window.context.updateUI(); + } + } + }); + await browser.pause(100); // Wait for canvas to reset +} + /** * Click at specific coordinates on the canvas * @param {number} x - X coordinate relative to canvas * @param {number} y - Y coordinate relative to canvas */ export async function clickCanvas(x, y) { + await resetCanvasView(); await browser.clickCanvas(x, y); await browser.pause(100); // Wait for render } @@ -20,6 +38,7 @@ export async function clickCanvas(x, y) { * @param {number} toY - Ending Y coordinate */ export async function dragCanvas(fromX, fromY, toX, toY) { + await resetCanvasView(); await browser.dragCanvas(fromX, fromY, toX, toY); await browser.pause(200); // Wait for render } @@ -31,17 +50,21 @@ export async function dragCanvas(fromX, fromY, toX, toY) { * @param {number} width - Rectangle width * @param {number} height - Rectangle height * @param {boolean} filled - Whether to fill the shape (default: true) + * @param {string} color - Fill color in hex format (e.g., '#ff0000') */ -export async function drawRectangle(x, y, width, height, filled = true) { +export async function drawRectangle(x, y, width, height, filled = true, color = null) { // Select the rectangle tool await selectTool('rectangle'); - // Set fill option - await browser.execute((filled) => { + // Set fill option and color if provided + await browser.execute((filled, color) => { if (window.context) { window.context.fillShape = filled; + if (color) { + window.context.fillStyle = color; + } } - }, filled); + }, filled, color); // Draw by dragging from start to end point await dragCanvas(x, y, x + width, y + height); @@ -192,14 +215,24 @@ export async function doubleClickCanvas(x, y) { export async function setPlayheadTime(time) { await browser.execute(function(timeValue) { if (window.context && window.context.activeObject) { + // Set time on both the active object and timeline state window.context.activeObject.currentTime = timeValue; - // Update timeline widget if it exists if (window.context.timelineWidget && window.context.timelineWidget.timelineState) { window.context.timelineWidget.timelineState.currentTime = timeValue; } + + // Trigger timeline redraw to show updated playhead position + if (window.context.timelineWidget && window.context.timelineWidget.requestRedraw) { + window.context.timelineWidget.requestRedraw(); + } + + // Trigger stage redraw to show shapes at new time + if (window.context.updateUI) { + window.context.updateUI(); + } } }, time); - await browser.pause(100); + await browser.pause(200); } /** diff --git a/tests/helpers/manual.js b/tests/helpers/manual.js new file mode 100644 index 0000000..d056d46 --- /dev/null +++ b/tests/helpers/manual.js @@ -0,0 +1,68 @@ +/** + * Manual testing utilities for user-in-the-loop verification + * These helpers pause execution and wait for user confirmation + */ + +/** + * Pause and wait for user to verify something visually with a confirm dialog + * @param {string} message - What the user should verify + * @param {boolean} waitForConfirm - If true, show confirm dialog and wait for user input + * @throws {Error} If user clicks Cancel to indicate verification failed + */ +export async function verifyManually(message, waitForConfirm = true) { + console.log('\n=== MANUAL VERIFICATION ==='); + console.log(message); + console.log('===========================\n'); + + if (waitForConfirm) { + // Show a confirm dialog in the browser and wait for user response + const result = await browser.execute(function(msg) { + return confirm(msg); + }, message); + + if (!result) { + console.log('User clicked Cancel - verification failed'); + throw new Error('Manual verification failed: User clicked Cancel'); + } else { + console.log('User clicked OK - verification passed'); + } + + return result; + } else { + // Just pause for observation + await browser.pause(3000); + return true; + } +} + +/** + * Add a visual marker/annotation to describe what should be visible + * @param {string} description - Description of current state + */ +export async function logStep(description) { + console.log(`\n>>> STEP: ${description}`); +} + +/** + * Extended pause with a description of what's happening + * @param {string} action - What action just occurred + * @param {number} pauseTime - How long to pause + */ +export async function pauseAndDescribe(action, pauseTime = 2000) { + console.log(`>>> ${action}`); + await browser.pause(pauseTime); +} + +/** + * Ask user a yes/no question via confirm dialog + * @param {string} question - Question to ask the user + * @returns {Promise} True if user clicked OK, false if Cancel + */ +export async function askUser(question) { + console.log(`\n>>> QUESTION: ${question}`); + const result = await browser.execute(function(msg) { + return confirm(msg); + }, question); + console.log(`User answered: ${result ? 'YES (OK)' : 'NO (Cancel)'}`); + return result; +} diff --git a/tests/specs/group-editing.test.js b/tests/specs/group-editing.test.js index fa1790e..3b93b2b 100644 --- a/tests/specs/group-editing.test.js +++ b/tests/specs/group-editing.test.js @@ -14,6 +14,7 @@ import { clickCanvas } from '../helpers/canvas.js'; import { assertShapeExists } from '../helpers/assertions.js'; +import { verifyManually, logStep } from '../helpers/manual.js'; describe('Group Editing', () => { before(async () => { @@ -71,6 +72,7 @@ describe('Group Editing', () => { it('should handle nested group editing with correct positioning', async () => { // Create first group with two shapes + await logStep('Drawing two rectangles for inner group'); await drawRectangle(400, 100, 60, 60); await drawRectangle(480, 100, 60, 60); await selectMultipleShapes([ @@ -80,24 +82,47 @@ describe('Group Editing', () => { await useKeyboardShortcut('g', true); await browser.pause(300); + await verifyManually('VERIFY: Do you see two rectangles grouped together?\nClick OK if yes, Cancel if no'); + // Verify both shapes exist await assertShapeExists(430, 130, 'First shape should exist'); await assertShapeExists(510, 130, 'Second shape should exist'); // Create another shape and group everything together + await logStep('Drawing third rectangle and creating nested group'); await drawRectangle(400, 180, 60, 60); - await selectMultipleShapes([ - { x: 470, y: 130 }, // Center of first group - { x: 430, y: 210 } // Center of new shape - ]); + + // Select both the group and the new shape by dragging a selection box + // We need to start from well outside the shapes to avoid hitting them + // The first group spans x=400-540, y=100-160 + // The third shape spans x=400-460, y=180-240 + await selectTool('select'); + await dragCanvas(390, 90, 550, 250); // Start from outside all shapes + await browser.pause(200); + await useKeyboardShortcut('g', true); await browser.pause(300); + await verifyManually('VERIFY: All three rectangles now grouped together (nested group)?\nClick OK if yes, Cancel if no'); + // Double-click to enter outer group + await logStep('Double-clicking to enter outer group'); await doubleClickCanvas(470, 130); + await browser.pause(300); + + await verifyManually('VERIFY: Are we now inside the outer group?\nClick OK if yes, Cancel if no'); // Double-click again to enter inner group + await logStep('Double-clicking again to enter inner group'); await doubleClickCanvas(470, 130); + await browser.pause(300); + + await verifyManually( + 'VERIFY: Are we now inside the inner group?\n' + + 'Can you see the two original rectangles at their original positions?\n' + + 'First at (430, 130), second at (510, 130)?\n\n' + + 'Click OK if yes, Cancel if no' + ); // All shapes should still be at their original positions await assertShapeExists(430, 130, 'First shape should maintain position in nested group'); diff --git a/tests/specs/manual/timeline-manual.test.js b/tests/specs/manual/timeline-manual.test.js new file mode 100644 index 0000000..92e58c1 --- /dev/null +++ b/tests/specs/manual/timeline-manual.test.js @@ -0,0 +1,279 @@ +/** + * MANUAL Timeline Animation Tests + * Run these with visual verification - watch the app window as tests execute + * + * To run: pnpm wdio run wdio.conf.js --spec tests/specs/manual/timeline-manual.test.js + */ + +import { describe, it, before, afterEach } from 'mocha'; +import { waitForAppReady } from '../../helpers/app.js'; +import { + drawRectangle, + selectMultipleShapes, + selectTool, + dragCanvas, + clickCanvas, + setPlayheadTime, + getPlayheadTime, + addKeyframe, + useKeyboardShortcut +} from '../../helpers/canvas.js'; +import { verifyManually, logStep, pauseAndDescribe } from '../../helpers/manual.js'; + +describe('MANUAL: Timeline Animation', () => { + before(async () => { + await waitForAppReady(); + }); + + afterEach(async () => { + // Close any open dialogs by accepting them + try { + await browser.execute(function() { + // Close any open confirm/alert dialogs + // This is a no-op if no dialog is open + }); + } catch (e) { + // Ignore errors + } + + // Pause briefly to show final state before ending + await browser.pause(1000); + console.log('\n>>> Test completed. Session will restart for next test.\n'); + }); + + it('TEST 1: Group animation - draw, group, keyframe, move group', async () => { + await logStep('Drawing a RED rectangle at (100, 100) with size 100x100'); + await drawRectangle(100, 100, 100, 100, true, '#ff0000'); + await pauseAndDescribe('RED rectangle drawn', 200); + + await verifyManually( + 'VERIFY: Do you see a RED filled rectangle at the top-left area?\n' + + 'It should be centered around (150, 150)\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Selecting the RED rectangle by dragging a selection box over it'); + await selectMultipleShapes([{ x: 150, y: 150 }]); + await pauseAndDescribe('RED rectangle selected', 200); + + await verifyManually( + 'VERIFY: Is the RED rectangle now selected? (Should have selection indicators)\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Grouping the selected rectangle (Ctrl+G)'); + await useKeyboardShortcut('g', true); + await pauseAndDescribe('RED rectangle grouped', 200); + + await verifyManually( + 'VERIFY: Was the rectangle grouped? (May look similar but is now a group)\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Selecting the group by dragging a selection box over it'); + await selectMultipleShapes([{ x: 150, y: 150 }]); + await pauseAndDescribe('Group selected', 200); + + await logStep('Moving playhead to time 0.333 (frame 10 at 30fps)'); + await setPlayheadTime(0.333); + await pauseAndDescribe('Playhead moved to 0.333s - WAIT for UI to update', 300); + + await verifyManually( + 'VERIFY: Did the playhead indicator move on the timeline?\n' + + 'It should be at approximately frame 10\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Adding a keyframe at current position'); + await addKeyframe(); + await pauseAndDescribe('Keyframe added', 200); + + await verifyManually( + 'VERIFY: Was a keyframe added? (Should see a keyframe marker on timeline)\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Dragging the selected group to move it right (from x=150 to x=250)'); + await dragCanvas(150, 150, 250, 150); + await pauseAndDescribe('Group moved to the right', 300); + + await verifyManually( + 'VERIFY: Did the RED rectangle move to the right?\n' + + 'It should now be centered around (250, 150)\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Moving playhead back to time 0 (frame 1)'); + await setPlayheadTime(0); + await pauseAndDescribe('Playhead back at start', 300); + + await verifyManually( + 'VERIFY: Did the RED rectangle jump back to its original position (x=150)?\n' + + 'This confirms the group animation is working!\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Moving playhead to middle (time 0.166, frame 5)'); + await setPlayheadTime(0.166); + await pauseAndDescribe('Playhead at middle frame', 300); + + await verifyManually( + 'VERIFY: Is the RED rectangle now between the two positions?\n' + + 'It should be around x=200 (interpolated halfway)\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Moving playhead back and forth to show animation'); + await setPlayheadTime(0); + await browser.pause(300); + await setPlayheadTime(0.333); + await browser.pause(300); + await setPlayheadTime(0); + await browser.pause(300); + await setPlayheadTime(0.333); + await browser.pause(300); + + await verifyManually( + 'VERIFY: Did you see the RED rectangle animate back and forth?\n' + + 'This demonstrates the timeline animation is working!\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('TEST 1 COMPLETE - Showing completion alert'); + const completionShown = await browser.execute(function() { + alert('TEST 1 COMPLETE - Click OK to finish'); + return true; + }); + await browser.pause(2000); // Wait for alert to be dismissed before ending test + }); + + it('TEST 2: Shape tween - draw shape, add keyframes, modify edges', async () => { + await logStep('Resetting playhead to time 0 at start of test'); + await setPlayheadTime(0); + await pauseAndDescribe('Playhead reset to time 0', 200); + + await logStep('Drawing a BLUE rectangle at (400, 100)'); + await drawRectangle(400, 100, 80, 80, true, '#0000ff'); + await pauseAndDescribe('BLUE rectangle drawn', 200); + + await verifyManually( + 'VERIFY: Do you see a BLUE filled rectangle?\n' + + 'It should be at (400, 100) with size 80x80\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Selecting the BLUE rectangle'); + await selectMultipleShapes([{ x: 440, y: 140 }]); + await pauseAndDescribe('BLUE rectangle selected', 200); + + await verifyManually( + 'VERIFY: Is the BLUE rectangle selected?\n' + + '(An initial keyframe should be automatically added at time 0)\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Moving playhead to time 0.5 (frame 12 at 24fps)'); + await setPlayheadTime(0.5); + await pauseAndDescribe('Playhead moved to 0.5s - WAIT for UI to update', 300); + + await verifyManually( + 'VERIFY: Did the playhead move to 0.5s on the timeline?\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Adding a keyframe at time 0.5'); + await addKeyframe(); + await pauseAndDescribe('Keyframe added at 0.5s', 200); + + await verifyManually( + 'VERIFY: Was a keyframe added at 0.5s?\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Clicking away from the shape to deselect it'); + await clickCanvas(600, 300); + await pauseAndDescribe('Shape deselected', 100); + + await verifyManually( + 'VERIFY: Is the BLUE rectangle now deselected?\n' + + '(No selection indicators around it)\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Dragging the right edge of the BLUE rectangle to curve/extend it'); + await dragCanvas(480, 140, 530, 140); + await pauseAndDescribe('Dragged right edge of BLUE rectangle', 300); + + await verifyManually( + 'VERIFY: Did the right edge of the BLUE rectangle get curved/pulled out?\n' + + 'The shape should now be modified/stretched to the right\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Moving playhead back to time 0'); + await setPlayheadTime(0); + await pauseAndDescribe('Playhead back at start', 300); + + await verifyManually( + 'VERIFY: Did the BLUE rectangle return to its original rectangular shape?\n' + + 'The edge modification should not be visible at time 0\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Moving playhead to middle between keyframes (time 0.25, frame 6)'); + await setPlayheadTime(0.25); + await pauseAndDescribe('Playhead at middle (0.25s) - halfway between frame 0 and frame 12', 300); + + await verifyManually( + 'VERIFY: Is the BLUE rectangle shape somewhere between the two versions?\n' + + 'It should be partially morphed (shape tween interpolation)\n' + + 'Halfway between the original rectangle and the curved version\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('TEST 2 COMPLETE - Showing completion alert'); + const completionShown = await browser.execute(function() { + alert('TEST 2 COMPLETE - Click OK to finish'); + return true; + }); + await browser.pause(2000); // Wait for alert to be dismissed before ending test + }); + + it('TEST 3: Test dragging unselected shape edge', async () => { + await logStep('Resetting playhead to time 0'); + await setPlayheadTime(0); + await pauseAndDescribe('Playhead reset to time 0', 100); + + await logStep('Drawing a GREEN rectangle at (200, 250) WITHOUT selecting it'); + await drawRectangle(200, 250, 100, 100, true, '#00ff00'); + await pauseAndDescribe('GREEN rectangle drawn (not selected)', 100); + + await verifyManually( + 'VERIFY: GREEN rectangle should be visible but NOT selected\n' + + '(No selection indicators around it)\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('Switching to select tool'); + await selectTool('select'); + await pauseAndDescribe('Select tool activated', 100); + + await logStep('Dragging from the right edge (x=300) of GREEN rectangle to extend it'); + await dragCanvas(300, 300, 350, 300); + await pauseAndDescribe('Dragged the right edge of GREEN rectangle', 200); + + await verifyManually( + 'VERIFY: What happened to the GREEN rectangle?\n\n' + + 'Expected: The right edge should be curved/pulled out to x=350\n' + + 'Did the edge get modified as expected?\n\n' + + 'Click OK if yes, Cancel if no' + ); + + await logStep('TEST 3 COMPLETE - Showing completion alert'); + const completionShown = await browser.execute(function() { + alert('TEST 3 COMPLETE - Click OK to finish'); + return true; + }); + await browser.pause(2000); // Wait for alert to be dismissed before ending test + }); +}); diff --git a/tests/specs/shapes.test.js b/tests/specs/shapes.test.js index 0504f6e..5a9ed77 100644 --- a/tests/specs/shapes.test.js +++ b/tests/specs/shapes.test.js @@ -48,9 +48,9 @@ describe('Shape Drawing', () => { }); it('should draw large rectangles', async () => { - // Draw a large rectangle - await drawRectangle(50, 300, 400, 200); - await assertShapeExists(250, 400, 'Large rectangle should exist at center'); + // Draw a large rectangle (canvas is ~350px tall, so keep within bounds) + await drawRectangle(50, 50, 400, 250); + await assertShapeExists(250, 175, 'Large rectangle should exist at center'); }); }); diff --git a/tests/specs/timeline-animation.test.js b/tests/specs/timeline-animation.test.js index dc8fcd5..adc727a 100644 --- a/tests/specs/timeline-animation.test.js +++ b/tests/specs/timeline-animation.test.js @@ -19,6 +19,7 @@ import { getPixelColor } from '../helpers/canvas.js'; import { assertShapeExists } from '../helpers/assertions.js'; +import { verifyManually, logStep } from '../helpers/manual.js'; describe('Timeline Animation', () => { before(async () => { @@ -44,10 +45,19 @@ describe('Timeline Animation', () => { await addKeyframe(); await browser.pause(200); + await logStep('About to drag selected shape - dragging selected shapes does not move them yet'); + // Shape is selected, so dragging from its center will move it await dragCanvas(150, 150, 250, 150); await browser.pause(300); + await verifyManually( + 'VERIFY: Did the shape move to x=250?\n' + + 'Expected: Shape at x=250\n' + + 'Note: Dragging selected shapes is not implemented yet\n\n' + + 'Click OK if at x=250, Cancel if not' + ); + // At frame 10, shape should be at the new position (moved 100px to the right) await assertShapeExists(250, 150, 'Shape should be at new position at frame 10'); @@ -55,6 +65,11 @@ describe('Timeline Animation', () => { await setPlayheadTime(0); await browser.pause(200); + await verifyManually( + 'VERIFY: Did the shape return to original position (x=150)?\n\n' + + 'Click OK if yes, Cancel if no' + ); + // Shape should be at original position await assertShapeExists(150, 150, 'Shape should be at original position at frame 1'); @@ -62,6 +77,11 @@ describe('Timeline Animation', () => { await setPlayheadTime(0.166); await browser.pause(200); + await verifyManually( + 'VERIFY: Is the shape interpolated at x=200 (halfway)?\n\n' + + 'Click OK if yes, Cancel if no' + ); + // Shape should be interpolated between the two positions // At frame 5 (halfway), shape should be around x=200 (halfway between 150 and 250) await assertShapeExists(200, 150, 'Shape should be interpolated at frame 5'); @@ -91,74 +111,85 @@ describe('Timeline Animation', () => { it('should handle multiple keyframes on the same shape', async () => { // Draw a shape - await drawRectangle(100, 300, 80, 80); + await drawRectangle(100, 100, 80, 80); // Select it - await selectMultipleShapes([{ x: 140, y: 340 }]); + await selectMultipleShapes([{ x: 140, y: 140 }]); await browser.pause(200); - // Keyframe 1: time 0 (original position at x=140, y=340) + // Keyframe 1: time 0 (original position at x=140, y=140) // Keyframe 2: time 0.333 (move right) await setPlayheadTime(0.333); await addKeyframe(); await browser.pause(200); + + await logStep('Dragging selected shape (not implemented yet)'); // Shape should still be selected, drag to move - await dragCanvas(140, 340, 200, 340); + await dragCanvas(140, 140, 200, 140); await browser.pause(300); - // Keyframe 3: time 0.666 (move down but stay within canvas) + await verifyManually('VERIFY: Did shape move to x=200? (probably not)\nClick OK if at x=200, Cancel if not'); + + // Keyframe 3: time 0.666 (move down) await setPlayheadTime(0.666); await addKeyframe(); await browser.pause(200); - // Drag to move down (y=380 instead of 400 to stay in canvas) - await dragCanvas(200, 340, 200, 380); + // Drag to move down + await dragCanvas(200, 140, 200, 180); await browser.pause(300); + await verifyManually('VERIFY: Did shape move to y=180?\nClick OK if yes, Cancel if no'); + // Verify positions at each keyframe await setPlayheadTime(0); await browser.pause(200); - await assertShapeExists(140, 340, 'Shape at keyframe 1 (x=140, y=340)'); + await verifyManually('VERIFY: Shape at original position (x=140, y=140)?\nClick OK if yes, Cancel if no'); + await assertShapeExists(140, 140, 'Shape at keyframe 1 (x=140, y=140)'); await setPlayheadTime(0.333); await browser.pause(200); - await assertShapeExists(200, 340, 'Shape at keyframe 2 (x=200, y=340)'); + await verifyManually('VERIFY: Shape at x=200, y=140?\nClick OK if yes, Cancel if no'); + await assertShapeExists(200, 140, 'Shape at keyframe 2 (x=200, y=140)'); await setPlayheadTime(0.666); await browser.pause(200); - await assertShapeExists(200, 380, 'Shape at keyframe 3 (x=200, y=380)'); + await verifyManually('VERIFY: Shape at x=200, y=180?\nClick OK if yes, Cancel if no'); + await assertShapeExists(200, 180, 'Shape at keyframe 3 (x=200, y=180)'); // Check interpolation between keyframe 1 and 2 (at t=0.166, halfway) await setPlayheadTime(0.166); await browser.pause(200); - await assertShapeExists(170, 340, 'Shape interpolated between kf1 and kf2'); + await verifyManually('VERIFY: Shape interpolated at x=170, y=140?\nClick OK if yes, Cancel if no'); + await assertShapeExists(170, 140, 'Shape interpolated between kf1 and kf2'); // Check interpolation between keyframe 2 and 3 (at t=0.5, halfway) await setPlayheadTime(0.5); await browser.pause(200); - await assertShapeExists(200, 360, 'Shape interpolated between kf2 and kf3'); + await verifyManually('VERIFY: Shape interpolated at x=200, y=160?\nClick OK if yes, Cancel if no'); + await assertShapeExists(200, 160, 'Shape interpolated between kf2 and kf3'); }); }); describe('Group/Object Animation', () => { it('should animate group position across keyframes', async () => { // Create a group with two shapes - await drawRectangle(300, 300, 60, 60); - await drawRectangle(380, 300, 60, 60); + await drawRectangle(300, 100, 60, 60); + await drawRectangle(380, 100, 60, 60); await selectMultipleShapes([ - { x: 330, y: 330 }, - { x: 410, y: 330 } + { x: 330, y: 130 }, + { x: 410, y: 130 } ]); await useKeyboardShortcut('g', true); await browser.pause(300); // Verify both shapes exist at frame 1 - await assertShapeExists(330, 330, 'First shape at frame 1'); - await assertShapeExists(410, 330, 'Second shape at frame 1'); + await assertShapeExists(330, 130, 'First shape at frame 1'); + await assertShapeExists(410, 130, 'Second shape at frame 1'); // Select the group by dragging a selection box over it - await selectMultipleShapes([{ x: 370, y: 330 }]); + await selectMultipleShapes([{ x: 370, y: 130 }]); await browser.pause(200); // Move to frame 10 and add keyframe @@ -166,30 +197,37 @@ describe('Timeline Animation', () => { await addKeyframe(); await browser.pause(200); + await logStep('Dragging group down'); // Group is selected, so dragging will move it - // Drag from center of group down (but keep it within canvas bounds) - await dragCanvas(370, 330, 370, 380); + // Drag from center of group down + await dragCanvas(370, 130, 370, 200); await browser.pause(300); - // At frame 10, group should be at new position (moved 50px down) - await assertShapeExists(330, 380, 'First shape at new position at frame 10'); - await assertShapeExists(410, 380, 'Second shape at new position at frame 10'); + await verifyManually('VERIFY: Did the group move down to y=200?\nClick OK if yes, Cancel if no'); + + // At frame 10, group should be at new position (moved down) + await assertShapeExists(330, 200, 'First shape at new position at frame 10'); + await assertShapeExists(410, 200, 'Second shape at new position at frame 10'); // Go to frame 1 await setPlayheadTime(0); await browser.pause(200); + await verifyManually('VERIFY: Did group return to original position (y=130)?\nClick OK if yes, Cancel if no'); + // Group should be at original position - await assertShapeExists(330, 330, 'First shape at original position at frame 1'); - await assertShapeExists(410, 330, 'Second shape at original position at frame 1'); + await assertShapeExists(330, 130, 'First shape at original position at frame 1'); + await assertShapeExists(410, 130, 'Second shape at original position at frame 1'); // Go to frame 5 (middle, t=0.166) await setPlayheadTime(0.166); await browser.pause(200); - // Group should be interpolated (halfway between y=330 and y=380, so y=355) - await assertShapeExists(330, 355, 'First shape interpolated at frame 5'); - await assertShapeExists(410, 355, 'Second shape interpolated at frame 5'); + await verifyManually('VERIFY: Is group interpolated at y=165 (halfway)?\nClick OK if yes, Cancel if no'); + + // Group should be interpolated (halfway between y=130 and y=200, so y=165) + await assertShapeExists(330, 165, 'First shape interpolated at frame 5'); + await assertShapeExists(410, 165, 'Second shape interpolated at frame 5'); }); it('should maintain relative positions of shapes within animated group', async () => { @@ -233,41 +271,47 @@ describe('Timeline Animation', () => { describe('Interpolation', () => { it('should smoothly interpolate between keyframes', async () => { // Draw a simple shape - await drawRectangle(500, 100, 50, 50); + await drawRectangle(100, 100, 50, 50); // Select it - await selectMultipleShapes([{ x: 525, y: 125 }]); + await selectMultipleShapes([{ x: 125, y: 125 }]); await browser.pause(200); - // Keyframe at start (x=525) + // Keyframe at start (x=125) await setPlayheadTime(0); await browser.pause(100); - // Keyframe at end (1 second = frame 30, move to x=725) + // Keyframe at end (1 second = frame 30, move to x=325) await setPlayheadTime(1.0); await addKeyframe(); await browser.pause(200); - await dragCanvas(525, 125, 725, 125); + await logStep('Dragging shape (selected shapes cannot be dragged yet)'); + await dragCanvas(125, 125, 325, 125); await browser.pause(300); + await verifyManually('VERIFY: Did shape move to x=325? (probably not)\nClick OK if at x=325, Cancel if not'); + // Check multiple intermediate frames for smooth interpolation // Total movement: 200px over 1 second - // At 25% (0.25s), x should be 525 + 50 = 575 + // At 25% (0.25s), x should be 125 + 50 = 175 await setPlayheadTime(0.25); await browser.pause(200); - await assertShapeExists(575, 125, 'Shape at 25% interpolation'); + await verifyManually('VERIFY: Shape at x=175 (25% interpolation)?\nClick OK if yes, Cancel if no'); + await assertShapeExists(175, 125, 'Shape at 25% interpolation'); - // At 50% (0.5s), x should be 525 + 100 = 625 + // At 50% (0.5s), x should be 125 + 100 = 225 await setPlayheadTime(0.5); await browser.pause(200); - await assertShapeExists(625, 125, 'Shape at 50% interpolation'); + await verifyManually('VERIFY: Shape at x=225 (50% interpolation)?\nClick OK if yes, Cancel if no'); + await assertShapeExists(225, 125, 'Shape at 50% interpolation'); - // At 75% (0.75s), x should be 525 + 150 = 675 + // At 75% (0.75s), x should be 125 + 150 = 275 await setPlayheadTime(0.75); await browser.pause(200); - await assertShapeExists(675, 125, 'Shape at 75% interpolation'); + await verifyManually('VERIFY: Shape at x=275 (75% interpolation)?\nClick OK if yes, Cancel if no'); + await assertShapeExists(275, 125, 'Shape at 75% interpolation'); }); }); });