Compare commits
No commits in common. "c10f42da8f7aa166ba5f6ac8ddbe584cccd30811" and "e9ee0d92e2dcd302c1d7e712956cc5b20510dd3f" have entirely different histories.
c10f42da8f
...
e9ee0d92e2
|
|
@ -466,66 +466,6 @@ impl Engine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read audio from pool as mono f32 samples.
|
|
||||||
/// Handles all storage types: InMemory/Mapped use read_samples(),
|
|
||||||
/// Compressed falls back to decoding from the file path.
|
|
||||||
fn read_mono_from_pool(pool: &crate::audio::pool::AudioClipPool, pool_index: usize) -> Option<(Vec<f32>, f32)> {
|
|
||||||
let audio_file = pool.get_file(pool_index)?;
|
|
||||||
let channels = audio_file.channels as usize;
|
|
||||||
let frames = audio_file.frames as usize;
|
|
||||||
let sample_rate = audio_file.sample_rate as f32;
|
|
||||||
|
|
||||||
// Try read_samples first (works for InMemory and Mapped)
|
|
||||||
let mut mono_samples = vec![0.0f32; frames];
|
|
||||||
let read_count = if channels == 1 {
|
|
||||||
audio_file.read_samples(0, frames, 0, &mut mono_samples)
|
|
||||||
} else {
|
|
||||||
let mut channel_buf = vec![0.0f32; frames];
|
|
||||||
let mut count = 0;
|
|
||||||
for ch in 0..channels {
|
|
||||||
count = audio_file.read_samples(0, frames, ch, &mut channel_buf);
|
|
||||||
for (i, &s) in channel_buf.iter().enumerate() {
|
|
||||||
mono_samples[i] += s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let scale = 1.0 / channels as f32;
|
|
||||||
for s in &mut mono_samples {
|
|
||||||
*s *= scale;
|
|
||||||
}
|
|
||||||
count
|
|
||||||
};
|
|
||||||
|
|
||||||
if read_count > 0 {
|
|
||||||
return Some((mono_samples, sample_rate));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compressed storage: decode from file path using sample_loader
|
|
||||||
let path = audio_file.path.to_string_lossy();
|
|
||||||
if !path.starts_with("<embedded") {
|
|
||||||
if let Ok(sample_data) = crate::audio::sample_loader::load_audio_file(&*path) {
|
|
||||||
return Some((sample_data.samples, sample_data.sample_rate as f32));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Last resort: try interleaved data() and mix down
|
|
||||||
let data = audio_file.data();
|
|
||||||
if !data.is_empty() && channels > 0 {
|
|
||||||
let actual_frames = data.len() / channels;
|
|
||||||
let mut mono = vec![0.0f32; actual_frames];
|
|
||||||
for frame in 0..actual_frames {
|
|
||||||
let mut sum = 0.0f32;
|
|
||||||
for ch in 0..channels {
|
|
||||||
sum += data[frame * channels + ch];
|
|
||||||
}
|
|
||||||
mono[frame] = sum / channels as f32;
|
|
||||||
}
|
|
||||||
return Some((mono, sample_rate));
|
|
||||||
}
|
|
||||||
|
|
||||||
eprintln!("[read_mono_from_pool] Failed to read audio from pool_index={}", pool_index);
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Handle a command from the UI thread
|
/// Handle a command from the UI thread
|
||||||
fn handle_command(&mut self, cmd: Command) {
|
fn handle_command(&mut self, cmd: Command) {
|
||||||
match cmd {
|
match cmd {
|
||||||
|
|
@ -1646,38 +1586,6 @@ impl Engine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Command::SamplerLoadFromPool(track_id, node_id, pool_index) => {
|
|
||||||
use crate::audio::node_graph::nodes::SimpleSamplerNode;
|
|
||||||
|
|
||||||
let sample_result = Self::read_mono_from_pool(&self.audio_pool, pool_index);
|
|
||||||
|
|
||||||
if let Some((mono_samples, sample_rate)) = sample_result {
|
|
||||||
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
|
||||||
let graph = &mut track.instrument_graph;
|
|
||||||
let node_idx = NodeIndex::new(node_id as usize);
|
|
||||||
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
|
||||||
if let Some(sampler_node) = graph_node.node.as_any_mut().downcast_mut::<SimpleSamplerNode>() {
|
|
||||||
sampler_node.set_sample(mono_samples, sample_rate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Command::SamplerSetRootNote(track_id, node_id, root_note) => {
|
|
||||||
use crate::audio::node_graph::nodes::SimpleSamplerNode;
|
|
||||||
|
|
||||||
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
|
||||||
let graph = &mut track.instrument_graph;
|
|
||||||
let node_idx = NodeIndex::new(node_id as usize);
|
|
||||||
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
|
||||||
if let Some(sampler_node) = graph_node.node.as_any_mut().downcast_mut::<SimpleSamplerNode>() {
|
|
||||||
sampler_node.set_root_note(root_note);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Command::MultiSamplerAddLayer(track_id, node_id, file_path, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode) => {
|
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;
|
use crate::audio::node_graph::nodes::MultiSamplerNode;
|
||||||
|
|
||||||
|
|
@ -1696,29 +1604,6 @@ impl Engine {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Command::MultiSamplerAddLayerFromPool(track_id, node_id, pool_index, key_min, key_max, root_key) => {
|
|
||||||
use crate::audio::node_graph::nodes::MultiSamplerNode;
|
|
||||||
use crate::audio::node_graph::nodes::LoopMode;
|
|
||||||
|
|
||||||
let sample_result = Self::read_mono_from_pool(&self.audio_pool, pool_index);
|
|
||||||
|
|
||||||
if let Some((mono_samples, sample_rate)) = sample_result {
|
|
||||||
if let Some(TrackNode::Midi(track)) = self.project.get_track_mut(track_id) {
|
|
||||||
let graph = &mut track.instrument_graph;
|
|
||||||
let node_idx = NodeIndex::new(node_id as usize);
|
|
||||||
if let Some(graph_node) = graph.get_graph_node_mut(node_idx) {
|
|
||||||
if let Some(multi_node) = graph_node.node.as_any_mut().downcast_mut::<MultiSamplerNode>() {
|
|
||||||
multi_node.add_layer(
|
|
||||||
mono_samples, sample_rate,
|
|
||||||
key_min, key_max, root_key,
|
|
||||||
0, 127, None, None, LoopMode::OneShot,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Command::MultiSamplerUpdateLayer(track_id, node_id, layer_index, key_min, key_max, root_key, velocity_min, velocity_max, loop_start, loop_end, loop_mode) => {
|
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;
|
use crate::audio::node_graph::nodes::MultiSamplerNode;
|
||||||
|
|
||||||
|
|
@ -3214,26 +3099,11 @@ impl EngineController {
|
||||||
let _ = self.command_tx.push(Command::SamplerLoadSample(track_id, node_id, file_path));
|
let _ = self.command_tx.push(Command::SamplerLoadSample(track_id, node_id, file_path));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load a sample from the audio pool into a SimpleSampler node
|
|
||||||
pub fn sampler_load_from_pool(&mut self, track_id: TrackId, node_id: u32, pool_index: usize) {
|
|
||||||
let _ = self.command_tx.push(Command::SamplerLoadFromPool(track_id, node_id, pool_index));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Set the root note for a SimpleSampler node
|
|
||||||
pub fn sampler_set_root_note(&mut self, track_id: TrackId, node_id: u32, root_note: u8) {
|
|
||||||
let _ = self.command_tx.push(Command::SamplerSetRootNote(track_id, node_id, root_note));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Add a sample layer to a MultiSampler node
|
/// 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, loop_start: Option<usize>, loop_end: Option<usize>, loop_mode: crate::audio::node_graph::nodes::LoopMode) {
|
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<usize>, loop_end: Option<usize>, 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));
|
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));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add a sample layer from the audio pool to a MultiSampler node
|
|
||||||
pub fn multi_sampler_add_layer_from_pool(&mut self, track_id: TrackId, node_id: u32, pool_index: usize, key_min: u8, key_max: u8, root_key: u8) {
|
|
||||||
let _ = self.command_tx.push(Command::MultiSamplerAddLayerFromPool(track_id, node_id, pool_index, key_min, key_max, root_key));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update a MultiSampler layer's configuration
|
/// 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, loop_start: Option<usize>, loop_end: Option<usize>, loop_mode: crate::audio::node_graph::nodes::LoopMode) {
|
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<usize>, loop_end: Option<usize>, 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));
|
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));
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,6 @@ use super::project::Project;
|
||||||
use crate::command::AudioEvent;
|
use crate::command::AudioEvent;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// Render chunk size for offline export. Matches the real-time playback buffer size
|
|
||||||
/// so that MIDI events are processed at the same granularity, avoiding timing jitter.
|
|
||||||
const EXPORT_CHUNK_FRAMES: usize = 256;
|
|
||||||
|
|
||||||
/// Supported export formats
|
/// Supported export formats
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum ExportFormat {
|
pub enum ExportFormat {
|
||||||
|
|
@ -77,21 +73,6 @@ pub fn export_audio<P: AsRef<Path>>(
|
||||||
mut event_tx: Option<&mut rtrb::Producer<AudioEvent>>,
|
mut event_tx: Option<&mut rtrb::Producer<AudioEvent>>,
|
||||||
) -> Result<(), String>
|
) -> Result<(), String>
|
||||||
{
|
{
|
||||||
// Validate duration
|
|
||||||
let duration = settings.end_time - settings.start_time;
|
|
||||||
if duration <= 0.0 {
|
|
||||||
return Err(format!(
|
|
||||||
"Export duration is zero or negative (start={:.3}s, end={:.3}s). \
|
|
||||||
Check that the timeline has content.",
|
|
||||||
settings.start_time, settings.end_time
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let total_frames = (duration * settings.sample_rate as f64).round() as usize;
|
|
||||||
if total_frames == 0 {
|
|
||||||
return Err("Export would produce zero audio frames".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset all node graphs to clear stale effect buffers (echo, reverb, etc.)
|
// Reset all node graphs to clear stale effect buffers (echo, reverb, etc.)
|
||||||
project.reset_all_graphs();
|
project.reset_all_graphs();
|
||||||
|
|
||||||
|
|
@ -154,7 +135,9 @@ pub fn render_to_memory(
|
||||||
println!("Export: duration={:.3}s, total_frames={}, total_samples={}, channels={}",
|
println!("Export: duration={:.3}s, total_frames={}, total_samples={}, channels={}",
|
||||||
duration, total_frames, total_samples, settings.channels);
|
duration, total_frames, total_samples, settings.channels);
|
||||||
|
|
||||||
let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
|
// 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
|
// Create buffer for rendering
|
||||||
let mut render_buffer = vec![0.0f32; chunk_samples];
|
let mut render_buffer = vec![0.0f32; chunk_samples];
|
||||||
|
|
@ -164,7 +147,7 @@ pub fn render_to_memory(
|
||||||
let mut all_samples = Vec::with_capacity(total_samples);
|
let mut all_samples = Vec::with_capacity(total_samples);
|
||||||
|
|
||||||
let mut playhead = settings.start_time;
|
let mut playhead = settings.start_time;
|
||||||
let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64;
|
let chunk_duration = CHUNK_FRAMES as f64 / settings.sample_rate as f64;
|
||||||
let mut frames_rendered = 0;
|
let mut frames_rendered = 0;
|
||||||
|
|
||||||
// Render the entire timeline in chunks
|
// Render the entire timeline in chunks
|
||||||
|
|
@ -362,8 +345,9 @@ fn export_mp3<P: AsRef<Path>>(
|
||||||
let duration = settings.end_time - settings.start_time;
|
let duration = settings.end_time - settings.start_time;
|
||||||
let total_frames = (duration * settings.sample_rate as f64).round() as usize;
|
let total_frames = (duration * settings.sample_rate as f64).round() as usize;
|
||||||
|
|
||||||
let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
|
const CHUNK_FRAMES: usize = 4096;
|
||||||
let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64;
|
let chunk_samples = CHUNK_FRAMES * settings.channels as usize;
|
||||||
|
let chunk_duration = CHUNK_FRAMES as f64 / settings.sample_rate as f64;
|
||||||
|
|
||||||
// Create buffers for rendering
|
// Create buffers for rendering
|
||||||
let mut render_buffer = vec![0.0f32; chunk_samples];
|
let mut render_buffer = vec![0.0f32; chunk_samples];
|
||||||
|
|
@ -529,8 +513,9 @@ fn export_aac<P: AsRef<Path>>(
|
||||||
let duration = settings.end_time - settings.start_time;
|
let duration = settings.end_time - settings.start_time;
|
||||||
let total_frames = (duration * settings.sample_rate as f64).round() as usize;
|
let total_frames = (duration * settings.sample_rate as f64).round() as usize;
|
||||||
|
|
||||||
let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
|
const CHUNK_FRAMES: usize = 4096;
|
||||||
let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64;
|
let chunk_samples = CHUNK_FRAMES * settings.channels as usize;
|
||||||
|
let chunk_duration = CHUNK_FRAMES as f64 / settings.sample_rate as f64;
|
||||||
|
|
||||||
// Create buffers for rendering
|
// Create buffers for rendering
|
||||||
let mut render_buffer = vec![0.0f32; chunk_samples];
|
let mut render_buffer = vec![0.0f32; chunk_samples];
|
||||||
|
|
@ -684,13 +669,9 @@ fn encode_complete_frame_mp3(
|
||||||
channel_layout: ffmpeg_next::channel_layout::ChannelLayout,
|
channel_layout: ffmpeg_next::channel_layout::ChannelLayout,
|
||||||
pts: i64,
|
pts: i64,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if num_frames == 0 {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let channels = planar_samples.len();
|
let channels = planar_samples.len();
|
||||||
|
|
||||||
// Create audio frame
|
// Create audio frame with exact size
|
||||||
let mut frame = ffmpeg_next::frame::Audio::new(
|
let mut frame = ffmpeg_next::frame::Audio::new(
|
||||||
ffmpeg_next::format::Sample::I16(ffmpeg_next::format::sample::Type::Planar),
|
ffmpeg_next::format::Sample::I16(ffmpeg_next::format::sample::Type::Planar),
|
||||||
num_frames,
|
num_frames,
|
||||||
|
|
@ -699,23 +680,33 @@ fn encode_complete_frame_mp3(
|
||||||
frame.set_rate(sample_rate);
|
frame.set_rate(sample_rate);
|
||||||
frame.set_pts(Some(pts));
|
frame.set_pts(Some(pts));
|
||||||
|
|
||||||
// Verify frame was allocated (check linesize[0] via planes())
|
|
||||||
if frame.planes() == 0 {
|
|
||||||
return Err("FFmpeg failed to allocate audio frame. Try exporting as WAV instead.".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy all planar samples to frame
|
// Copy all planar samples to frame
|
||||||
// Use plane_mut::<i16> instead of data_mut — data_mut(ch) is buggy for planar audio:
|
|
||||||
// FFmpeg only sets linesize[0], so data_mut returns 0-length slices for ch > 0.
|
|
||||||
// plane_mut uses self.samples() for the length, which is correct for all planes.
|
|
||||||
for ch in 0..channels {
|
for ch in 0..channels {
|
||||||
let plane = frame.plane_mut::<i16>(ch);
|
let plane = frame.data_mut(ch);
|
||||||
plane.copy_from_slice(&planar_samples[ch]);
|
let src = &planar_samples[ch];
|
||||||
|
|
||||||
|
// Verify buffer size
|
||||||
|
let byte_size = num_frames * std::mem::size_of::<i16>();
|
||||||
|
if plane.len() < byte_size {
|
||||||
|
return Err(format!(
|
||||||
|
"FFmpeg frame buffer too small: {} bytes, need {} bytes",
|
||||||
|
plane.len(), byte_size
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe byte-level copy
|
||||||
|
for (i, &sample) in src.iter().enumerate() {
|
||||||
|
let bytes = sample.to_ne_bytes();
|
||||||
|
let offset = i * 2;
|
||||||
|
plane[offset..offset + 2].copy_from_slice(&bytes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send frame to encoder
|
||||||
encoder.send_frame(&frame)
|
encoder.send_frame(&frame)
|
||||||
.map_err(|e| format!("Failed to send frame: {}", e))?;
|
.map_err(|e| format!("Failed to send frame: {}", e))?;
|
||||||
|
|
||||||
|
// Receive and write packets
|
||||||
receive_and_write_packets(encoder, output)?;
|
receive_and_write_packets(encoder, output)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -731,13 +722,9 @@ fn encode_complete_frame_aac(
|
||||||
channel_layout: ffmpeg_next::channel_layout::ChannelLayout,
|
channel_layout: ffmpeg_next::channel_layout::ChannelLayout,
|
||||||
pts: i64,
|
pts: i64,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
if num_frames == 0 {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let channels = planar_samples.len();
|
let channels = planar_samples.len();
|
||||||
|
|
||||||
// Create audio frame
|
// Create audio frame with exact size
|
||||||
let mut frame = ffmpeg_next::frame::Audio::new(
|
let mut frame = ffmpeg_next::frame::Audio::new(
|
||||||
ffmpeg_next::format::Sample::F32(ffmpeg_next::format::sample::Type::Planar),
|
ffmpeg_next::format::Sample::F32(ffmpeg_next::format::sample::Type::Planar),
|
||||||
num_frames,
|
num_frames,
|
||||||
|
|
@ -746,23 +733,33 @@ fn encode_complete_frame_aac(
|
||||||
frame.set_rate(sample_rate);
|
frame.set_rate(sample_rate);
|
||||||
frame.set_pts(Some(pts));
|
frame.set_pts(Some(pts));
|
||||||
|
|
||||||
// Verify frame was allocated
|
|
||||||
if frame.planes() == 0 {
|
|
||||||
return Err("FFmpeg failed to allocate audio frame. Try exporting as WAV instead.".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy all planar samples to frame
|
// Copy all planar samples to frame
|
||||||
// Use plane_mut::<f32> instead of data_mut — data_mut(ch) is buggy for planar audio:
|
|
||||||
// FFmpeg only sets linesize[0], so data_mut returns 0-length slices for ch > 0.
|
|
||||||
// plane_mut uses self.samples() for the length, which is correct for all planes.
|
|
||||||
for ch in 0..channels {
|
for ch in 0..channels {
|
||||||
let plane = frame.plane_mut::<f32>(ch);
|
let plane = frame.data_mut(ch);
|
||||||
plane.copy_from_slice(&planar_samples[ch]);
|
let src = &planar_samples[ch];
|
||||||
|
|
||||||
|
// Verify buffer size
|
||||||
|
let byte_size = num_frames * std::mem::size_of::<f32>();
|
||||||
|
if plane.len() < byte_size {
|
||||||
|
return Err(format!(
|
||||||
|
"FFmpeg frame buffer too small: {} bytes, need {} bytes",
|
||||||
|
plane.len(), byte_size
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe byte-level copy
|
||||||
|
for (i, &sample) in src.iter().enumerate() {
|
||||||
|
let bytes = sample.to_ne_bytes();
|
||||||
|
let offset = i * 4;
|
||||||
|
plane[offset..offset + 4].copy_from_slice(&bytes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send frame to encoder
|
||||||
encoder.send_frame(&frame)
|
encoder.send_frame(&frame)
|
||||||
.map_err(|e| format!("Failed to send frame: {}", e))?;
|
.map_err(|e| format!("Failed to send frame: {}", e))?;
|
||||||
|
|
||||||
|
// Receive and write packets
|
||||||
receive_and_write_packets(encoder, output)?;
|
receive_and_write_packets(encoder, output)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
|
||||||
|
|
@ -101,7 +101,8 @@ impl OscilloscopeNode {
|
||||||
|
|
||||||
let inputs = vec![
|
let inputs = vec![
|
||||||
NodePort::new("Audio In", SignalType::Audio, 0),
|
NodePort::new("Audio In", SignalType::Audio, 0),
|
||||||
NodePort::new("CV In", SignalType::CV, 1),
|
NodePort::new("V/oct", SignalType::CV, 1),
|
||||||
|
NodePort::new("CV In", SignalType::CV, 2),
|
||||||
];
|
];
|
||||||
|
|
||||||
let outputs = vec![
|
let outputs = vec![
|
||||||
|
|
@ -222,24 +223,13 @@ impl AudioNode for OscilloscopeNode {
|
||||||
let output = &mut outputs[0];
|
let output = &mut outputs[0];
|
||||||
let len = input.len().min(output.len());
|
let len = input.len().min(output.len());
|
||||||
|
|
||||||
// Read CV input if available (port 1) — used for both display and V/Oct triggering
|
// Read V/oct input if available and update trigger period
|
||||||
if inputs.len() > 1 && !inputs[1].is_empty() {
|
if inputs.len() > 1 && !inputs[1].is_empty() {
|
||||||
let cv_input = inputs[1];
|
self.voct_value = inputs[1][0]; // Use first sample of V/oct input
|
||||||
let cv_len = len.min(cv_input.len());
|
let frequency = Self::voct_to_frequency(self.voct_value);
|
||||||
|
// Calculate period in samples, clamped to reasonable range
|
||||||
// Check if connected (not NaN sentinel)
|
let period_samples = (sample_rate as f32 / frequency).max(1.0);
|
||||||
if cv_len > 0 && !cv_input[0].is_nan() {
|
self.trigger_period = period_samples as usize;
|
||||||
// Update V/Oct trigger period from CV value
|
|
||||||
self.voct_value = cv_input[0];
|
|
||||||
let frequency = Self::voct_to_frequency(self.voct_value);
|
|
||||||
let period_samples = (sample_rate as f32 / frequency).max(1.0);
|
|
||||||
self.trigger_period = period_samples as usize;
|
|
||||||
|
|
||||||
// Capture CV samples to buffer
|
|
||||||
if let Ok(mut cv_buffer) = self.cv_buffer.lock() {
|
|
||||||
cv_buffer.write(&cv_input[..cv_len]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update sample counter for V/oct triggering
|
// Update sample counter for V/oct triggering
|
||||||
|
|
@ -255,6 +245,14 @@ impl AudioNode for OscilloscopeNode {
|
||||||
buffer.write(&input[..len]);
|
buffer.write(&input[..len]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture CV samples if CV input is connected (input 2)
|
||||||
|
if inputs.len() > 2 && !inputs[2].is_empty() {
|
||||||
|
let cv_input = inputs[2];
|
||||||
|
if let Ok(mut cv_buffer) = self.cv_buffer.lock() {
|
||||||
|
cv_buffer.write(&cv_input[..len.min(cv_input.len())]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update last sample for trigger detection (use left channel, frame 0)
|
// Update last sample for trigger detection (use left channel, frame 0)
|
||||||
if !input.is_empty() {
|
if !input.is_empty() {
|
||||||
self.last_sample = input[0];
|
self.last_sample = input[0];
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,6 @@ pub struct SimpleSamplerNode {
|
||||||
gain: f32,
|
gain: f32,
|
||||||
loop_enabled: bool,
|
loop_enabled: bool,
|
||||||
pitch_shift: f32, // Additional pitch shift in semitones
|
pitch_shift: f32, // Additional pitch shift in semitones
|
||||||
root_note: u8, // MIDI note for original pitch playback (default 69 = A4)
|
|
||||||
|
|
||||||
inputs: Vec<NodePort>,
|
inputs: Vec<NodePort>,
|
||||||
outputs: Vec<NodePort>,
|
outputs: Vec<NodePort>,
|
||||||
|
|
@ -62,7 +61,6 @@ impl SimpleSamplerNode {
|
||||||
gain: 1.0,
|
gain: 1.0,
|
||||||
loop_enabled: false,
|
loop_enabled: false,
|
||||||
pitch_shift: 0.0,
|
pitch_shift: 0.0,
|
||||||
root_note: 69, // A4 — V/Oct 0.0 from MIDI-to-CV
|
|
||||||
inputs,
|
inputs,
|
||||||
outputs,
|
outputs,
|
||||||
parameters,
|
parameters,
|
||||||
|
|
@ -103,25 +101,13 @@ impl SimpleSamplerNode {
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert V/oct CV to playback speed multiplier
|
/// Convert V/oct CV to playback speed multiplier
|
||||||
/// Accounts for root_note: when the incoming MIDI note matches root_note,
|
/// 0V = 1.0 (original speed), +1V = 2.0 (one octave up), -1V = 0.5 (one octave down)
|
||||||
/// the sample plays at original speed. V/Oct 0.0 = A4 (MIDI 69) by convention.
|
|
||||||
fn voct_to_speed(&self, voct: f32) -> f32 {
|
fn voct_to_speed(&self, voct: f32) -> f32 {
|
||||||
// Offset so root_note plays at original speed
|
// Add pitch shift parameter
|
||||||
let root_offset = (self.root_note as f32 - 69.0) / 12.0;
|
let total_semitones = voct * 12.0 + self.pitch_shift;
|
||||||
let total_semitones = (voct - root_offset) * 12.0 + self.pitch_shift;
|
|
||||||
2.0_f32.powf(total_semitones / 12.0)
|
2.0_f32.powf(total_semitones / 12.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the root note (MIDI note number for original-pitch playback)
|
|
||||||
pub fn set_root_note(&mut self, note: u8) {
|
|
||||||
self.root_note = note.min(127);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the current root note
|
|
||||||
pub fn root_note(&self) -> u8 {
|
|
||||||
self.root_note
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read sample at playhead with linear interpolation
|
/// Read sample at playhead with linear interpolation
|
||||||
fn read_sample(&self, playhead: f32, sample: &[f32]) -> f32 {
|
fn read_sample(&self, playhead: f32, sample: &[f32]) -> f32 {
|
||||||
if sample.is_empty() {
|
if sample.is_empty() {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
|
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
||||||
use crate::audio::midi::MidiEvent;
|
use crate::audio::midi::MidiEvent;
|
||||||
|
|
||||||
const PARAM_RISE_TIME: u32 = 0;
|
const PARAM_RISE_TIME: u32 = 0;
|
||||||
|
|
@ -90,8 +90,9 @@ impl AudioNode for SlewLimiterNode {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let input = inputs[0];
|
||||||
let output = &mut outputs[0];
|
let output = &mut outputs[0];
|
||||||
let length = output.len();
|
let length = input.len().min(output.len());
|
||||||
|
|
||||||
// Calculate maximum change per sample
|
// Calculate maximum change per sample
|
||||||
let sample_duration = 1.0 / sample_rate as f32;
|
let sample_duration = 1.0 / sample_rate as f32;
|
||||||
|
|
@ -110,9 +111,7 @@ impl AudioNode for SlewLimiterNode {
|
||||||
};
|
};
|
||||||
|
|
||||||
for i in 0..length {
|
for i in 0..length {
|
||||||
// Use cv_input_or_default to handle unconnected inputs (NaN sentinel)
|
let target = input[i];
|
||||||
// Default to last_value so output holds steady when unconnected
|
|
||||||
let target = cv_input_or_default(inputs, 0, i, self.last_value);
|
|
||||||
let difference = target - self.last_value;
|
let difference = target - self.last_value;
|
||||||
|
|
||||||
let max_change = if difference > 0.0 {
|
let max_change = if difference > 0.0 {
|
||||||
|
|
|
||||||
|
|
@ -177,14 +177,8 @@ pub enum Command {
|
||||||
|
|
||||||
/// Load a sample into a SimpleSampler node (track_id, node_id, file_path)
|
/// Load a sample into a SimpleSampler node (track_id, node_id, file_path)
|
||||||
SamplerLoadSample(TrackId, u32, String),
|
SamplerLoadSample(TrackId, u32, String),
|
||||||
/// Load a sample from the audio pool into a SimpleSampler node (track_id, node_id, pool_index)
|
|
||||||
SamplerLoadFromPool(TrackId, u32, usize),
|
|
||||||
/// Set the root note (original pitch) for a SimpleSampler node (track_id, node_id, midi_note)
|
|
||||||
SamplerSetRootNote(TrackId, u32, 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)
|
/// 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<usize>, Option<usize>, LoopMode),
|
MultiSamplerAddLayer(TrackId, u32, String, u8, u8, u8, u8, u8, Option<usize>, Option<usize>, LoopMode),
|
||||||
/// Add a sample layer from the audio pool to a MultiSampler node (track_id, node_id, pool_index, key_min, key_max, root_key)
|
|
||||||
MultiSamplerAddLayerFromPool(TrackId, u32, usize, 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, loop_start, loop_end, loop_mode)
|
/// 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<usize>, Option<usize>, LoopMode),
|
MultiSamplerUpdateLayer(TrackId, u32, usize, u8, u8, u8, u8, u8, Option<usize>, Option<usize>, LoopMode),
|
||||||
/// Remove a layer from a MultiSampler node (track_id, node_id, layer_index)
|
/// Remove a layer from a MultiSampler node (track_id, node_id, layer_index)
|
||||||
|
|
|
||||||
|
|
@ -332,9 +332,7 @@ where
|
||||||
ports: &SlotMap<Key, Value>,
|
ports: &SlotMap<Key, Value>,
|
||||||
port_locations: &PortLocations,
|
port_locations: &PortLocations,
|
||||||
cursor_pos: Pos2,
|
cursor_pos: Pos2,
|
||||||
zoom: f32,
|
|
||||||
) -> Pos2 {
|
) -> Pos2 {
|
||||||
let snap_distance = DISTANCE_TO_CONNECT * zoom;
|
|
||||||
ports
|
ports
|
||||||
.iter()
|
.iter()
|
||||||
.find_map(|(port_id, _)| {
|
.find_map(|(port_id, _)| {
|
||||||
|
|
@ -354,7 +352,7 @@ where
|
||||||
.unwrap()
|
.unwrap()
|
||||||
})
|
})
|
||||||
.filter(|nearest_hook| {
|
.filter(|nearest_hook| {
|
||||||
nearest_hook.distance(cursor_pos) < snap_distance
|
nearest_hook.distance(cursor_pos) < DISTANCE_TO_CONNECT
|
||||||
})
|
})
|
||||||
.copied()
|
.copied()
|
||||||
})
|
})
|
||||||
|
|
@ -374,7 +372,6 @@ where
|
||||||
&self.graph.inputs,
|
&self.graph.inputs,
|
||||||
&port_locations,
|
&port_locations,
|
||||||
cursor_pos,
|
cursor_pos,
|
||||||
self.pan_zoom.zoom,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
AnyParameterId::Input(_) => (
|
AnyParameterId::Input(_) => (
|
||||||
|
|
@ -384,7 +381,6 @@ where
|
||||||
&self.graph.outputs,
|
&self.graph.outputs,
|
||||||
&port_locations,
|
&port_locations,
|
||||||
cursor_pos,
|
cursor_pos,
|
||||||
self.pan_zoom.zoom,
|
|
||||||
),
|
),
|
||||||
start_pos,
|
start_pos,
|
||||||
),
|
),
|
||||||
|
|
@ -631,11 +627,11 @@ where
|
||||||
ui: &mut Ui,
|
ui: &mut Ui,
|
||||||
user_state: &mut UserState,
|
user_state: &mut UserState,
|
||||||
) -> Vec<NodeResponse<UserResponse, NodeData>> {
|
) -> Vec<NodeResponse<UserResponse, NodeData>> {
|
||||||
let mut child_ui = ui.new_child(
|
let mut child_ui = ui.child_ui_with_id_source(
|
||||||
egui::UiBuilder::new()
|
Rect::from_min_size(*self.position + self.pan, Self::MAX_NODE_SIZE.into()),
|
||||||
.max_rect(Rect::from_min_size(*self.position + self.pan, Self::MAX_NODE_SIZE.into()))
|
Layout::default(),
|
||||||
.layout(Layout::default())
|
self.node_id,
|
||||||
.id_salt(self.node_id),
|
None,
|
||||||
);
|
);
|
||||||
|
|
||||||
Self::show_graph_node(self, pan_zoom, &mut child_ui, user_state)
|
Self::show_graph_node(self, pan_zoom, &mut child_ui, user_state)
|
||||||
|
|
@ -680,11 +676,7 @@ where
|
||||||
inner_rect.max.x = inner_rect.max.x.max(inner_rect.min.x);
|
inner_rect.max.x = inner_rect.max.x.max(inner_rect.min.x);
|
||||||
inner_rect.max.y = inner_rect.max.y.max(inner_rect.min.y);
|
inner_rect.max.y = inner_rect.max.y.max(inner_rect.min.y);
|
||||||
|
|
||||||
let mut child_ui = ui.new_child(
|
let mut child_ui = ui.child_ui(inner_rect, *ui.layout(), None);
|
||||||
egui::UiBuilder::new()
|
|
||||||
.max_rect(inner_rect)
|
|
||||||
.layout(*ui.layout()),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get interaction rect from memory, it may expand after the window response on resize.
|
// Get interaction rect from memory, it may expand after the window response on resize.
|
||||||
let interaction_rect = ui
|
let interaction_rect = ui
|
||||||
|
|
@ -912,10 +904,9 @@ where
|
||||||
|
|
||||||
let resp = ui.allocate_rect(port_rect, sense);
|
let resp = ui.allocate_rect(port_rect, sense);
|
||||||
|
|
||||||
// Check if the mouse is within snap distance of the port center
|
// Check if the mouse is within the port's interaction rect
|
||||||
// Uses circular distance to match snap_to_ports() behavior
|
|
||||||
let close_enough = if let Some(pointer_pos) = ui.ctx().pointer_hover_pos() {
|
let close_enough = if let Some(pointer_pos) = ui.ctx().pointer_hover_pos() {
|
||||||
port_pos.distance(pointer_pos) < DISTANCE_TO_CONNECT * pan_zoom.zoom
|
port_rect.contains(pointer_pos)
|
||||||
} else {
|
} else {
|
||||||
false
|
false
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -241,14 +241,14 @@ impl<NodeData> Node<NodeData> {
|
||||||
pub fn inputs<'a, DataType, DataValue>(
|
pub fn inputs<'a, DataType, DataValue>(
|
||||||
&'a self,
|
&'a self,
|
||||||
graph: &'a Graph<NodeData, DataType, DataValue>,
|
graph: &'a Graph<NodeData, DataType, DataValue>,
|
||||||
) -> impl Iterator<Item = &'a InputParam<DataType, DataValue>> + 'a {
|
) -> impl Iterator<Item = &InputParam<DataType, DataValue>> + 'a {
|
||||||
self.input_ids().map(|id| graph.get_input(id))
|
self.input_ids().map(|id| graph.get_input(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn outputs<'a, DataType, DataValue>(
|
pub fn outputs<'a, DataType, DataValue>(
|
||||||
&'a self,
|
&'a self,
|
||||||
graph: &'a Graph<NodeData, DataType, DataValue>,
|
graph: &'a Graph<NodeData, DataType, DataValue>,
|
||||||
) -> impl Iterator<Item = &'a OutputParam<DataType>> + 'a {
|
) -> impl Iterator<Item = &OutputParam<DataType>> + 'a {
|
||||||
self.output_ids().map(|id| graph.get_output(id))
|
self.output_ids().map(|id| graph.get_output(id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,7 +84,7 @@ pub trait DataTypeTrait<UserState>: PartialEq + Eq {
|
||||||
/// }
|
/// }
|
||||||
/// }
|
/// }
|
||||||
/// ```
|
/// ```
|
||||||
fn name(&self) -> std::borrow::Cow<'_, str>;
|
fn name(&self) -> std::borrow::Cow<str>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This trait must be implemented for the `NodeData` generic parameter of the
|
/// This trait must be implemented for the `NodeData` generic parameter of the
|
||||||
|
|
@ -251,7 +251,7 @@ pub trait NodeTemplateTrait: Clone {
|
||||||
/// The return type is Cow<str> to allow returning owned or borrowed values
|
/// The return type is Cow<str> to allow returning owned or borrowed values
|
||||||
/// more flexibly. Refer to the documentation for `DataTypeTrait::name` for
|
/// more flexibly. Refer to the documentation for `DataTypeTrait::name` for
|
||||||
/// more information
|
/// more information
|
||||||
fn node_finder_label(&self, user_state: &mut Self::UserState) -> std::borrow::Cow<'_, str>;
|
fn node_finder_label(&self, user_state: &mut Self::UserState) -> std::borrow::Cow<str>;
|
||||||
|
|
||||||
/// Vec of categories to which the node belongs.
|
/// Vec of categories to which the node belongs.
|
||||||
///
|
///
|
||||||
|
|
|
||||||
|
|
@ -124,54 +124,6 @@ impl Action for SetLayerPropertiesAction {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn execute_backend(
|
|
||||||
&mut self,
|
|
||||||
backend: &mut crate::action::BackendContext,
|
|
||||||
_document: &crate::document::Document,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let controller = match backend.audio_controller.as_mut() {
|
|
||||||
Some(c) => c,
|
|
||||||
None => return Ok(()),
|
|
||||||
};
|
|
||||||
|
|
||||||
for &layer_id in &self.layer_ids {
|
|
||||||
if let Some(&track_id) = backend.layer_to_track_map.get(&layer_id) {
|
|
||||||
match &self.property {
|
|
||||||
LayerProperty::Volume(v) => controller.set_track_volume(track_id, *v as f32),
|
|
||||||
LayerProperty::Muted(m) => controller.set_track_mute(track_id, *m),
|
|
||||||
LayerProperty::Soloed(s) => controller.set_track_solo(track_id, *s),
|
|
||||||
_ => {} // Locked/Opacity/Visible are UI-only
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn rollback_backend(
|
|
||||||
&mut self,
|
|
||||||
backend: &mut crate::action::BackendContext,
|
|
||||||
_document: &crate::document::Document,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
let controller = match backend.audio_controller.as_mut() {
|
|
||||||
Some(c) => c,
|
|
||||||
None => return Ok(()),
|
|
||||||
};
|
|
||||||
|
|
||||||
for (i, &layer_id) in self.layer_ids.iter().enumerate() {
|
|
||||||
if let Some(&track_id) = backend.layer_to_track_map.get(&layer_id) {
|
|
||||||
if let Some(old_value) = &self.old_values[i] {
|
|
||||||
match old_value {
|
|
||||||
OldValue::Volume(v) => controller.set_track_volume(track_id, *v as f32),
|
|
||||||
OldValue::Muted(m) => controller.set_track_mute(track_id, *m),
|
|
||||||
OldValue::Soloed(s) => controller.set_track_solo(track_id, *s),
|
|
||||||
_ => {} // Locked/Opacity/Visible are UI-only
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> String {
|
fn description(&self) -> String {
|
||||||
let property_name = match &self.property {
|
let property_name = match &self.property {
|
||||||
LayerProperty::Volume(_) => "volume",
|
LayerProperty::Volume(_) => "volume",
|
||||||
|
|
|
||||||
|
|
@ -196,13 +196,26 @@ fn export_audio_ffmpeg_mp3<P: AsRef<Path>>(
|
||||||
frame.set_rate(settings.sample_rate);
|
frame.set_rate(settings.sample_rate);
|
||||||
|
|
||||||
// Copy planar samples to frame
|
// Copy planar samples to frame
|
||||||
// Use plane_mut::<i16> instead of data_mut — data_mut(ch) is buggy for planar audio:
|
|
||||||
// FFmpeg only sets linesize[0], so data_mut returns 0-length slices for ch > 0.
|
|
||||||
// plane_mut uses self.samples() for the length, which is correct for all planes.
|
|
||||||
for ch in 0..settings.channels as usize {
|
for ch in 0..settings.channels as usize {
|
||||||
let plane = frame.plane_mut::<i16>(ch);
|
let plane = frame.data_mut(ch);
|
||||||
let offset = samples_encoded;
|
let offset = samples_encoded;
|
||||||
plane.copy_from_slice(&planar_samples[ch][offset..offset + chunk_size]);
|
let src = &planar_samples[ch][offset..offset + chunk_size];
|
||||||
|
|
||||||
|
// Convert i16 samples to bytes and copy
|
||||||
|
let byte_size = chunk_size * std::mem::size_of::<i16>();
|
||||||
|
if plane.len() < byte_size {
|
||||||
|
return Err(format!(
|
||||||
|
"FFmpeg frame buffer too small: {} bytes, need {} bytes",
|
||||||
|
plane.len(), byte_size
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Safe byte-level copy using slice operations
|
||||||
|
for (i, &sample) in src.iter().enumerate() {
|
||||||
|
let bytes = sample.to_ne_bytes();
|
||||||
|
let offset = i * 2;
|
||||||
|
plane[offset..offset + 2].copy_from_slice(&bytes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send frame to encoder
|
// Send frame to encoder
|
||||||
|
|
|
||||||
|
|
@ -41,61 +41,35 @@ pub struct ExportDialog {
|
||||||
/// Output file path
|
/// Output file path
|
||||||
pub output_path: Option<PathBuf>,
|
pub output_path: Option<PathBuf>,
|
||||||
|
|
||||||
|
/// Selected audio preset index (for UI)
|
||||||
|
pub selected_audio_preset: usize,
|
||||||
|
|
||||||
/// Error message (if any)
|
/// Error message (if any)
|
||||||
pub error_message: Option<String>,
|
pub error_message: Option<String>,
|
||||||
|
|
||||||
/// Whether advanced settings are shown
|
|
||||||
pub show_advanced: bool,
|
|
||||||
|
|
||||||
/// Selected video preset index
|
|
||||||
pub selected_video_preset: usize,
|
|
||||||
|
|
||||||
/// Output filename (editable text, without directory)
|
|
||||||
pub output_filename: String,
|
|
||||||
|
|
||||||
/// Output directory
|
|
||||||
pub output_dir: PathBuf,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for ExportDialog {
|
impl Default for ExportDialog {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
let home = std::env::var("HOME")
|
|
||||||
.map(PathBuf::from)
|
|
||||||
.unwrap_or_else(|_| PathBuf::from("."));
|
|
||||||
let music_dir = {
|
|
||||||
let m = home.join("Music");
|
|
||||||
if m.is_dir() { m } else { home }
|
|
||||||
};
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
open: false,
|
open: false,
|
||||||
export_type: ExportType::Audio,
|
export_type: ExportType::Audio,
|
||||||
audio_settings: AudioExportSettings::standard_mp3(),
|
audio_settings: AudioExportSettings::default(),
|
||||||
video_settings: VideoExportSettings::default(),
|
video_settings: VideoExportSettings::default(),
|
||||||
include_audio: true,
|
include_audio: true,
|
||||||
output_path: None,
|
output_path: None,
|
||||||
|
selected_audio_preset: 0,
|
||||||
error_message: None,
|
error_message: None,
|
||||||
show_advanced: false,
|
|
||||||
selected_video_preset: 0,
|
|
||||||
output_filename: String::new(),
|
|
||||||
output_dir: music_dir,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExportDialog {
|
impl ExportDialog {
|
||||||
/// Open the dialog with default settings
|
/// Open the dialog with default settings
|
||||||
pub fn open(&mut self, timeline_duration: f64, project_name: &str) {
|
pub fn open(&mut self, timeline_duration: f64) {
|
||||||
self.open = true;
|
self.open = true;
|
||||||
self.audio_settings.end_time = timeline_duration;
|
self.audio_settings.end_time = timeline_duration;
|
||||||
self.video_settings.end_time = timeline_duration;
|
self.video_settings.end_time = timeline_duration;
|
||||||
self.error_message = None;
|
self.error_message = None;
|
||||||
|
|
||||||
// Pre-populate filename from project name if not already set
|
|
||||||
if self.output_filename.is_empty() || !self.output_filename.contains(project_name) {
|
|
||||||
let ext = self.audio_settings.format.extension();
|
|
||||||
self.output_filename = format!("{}.{}", project_name, ext);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Close the dialog
|
/// Close the dialog
|
||||||
|
|
@ -104,27 +78,6 @@ impl ExportDialog {
|
||||||
self.error_message = None;
|
self.error_message = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the filename extension to match the current format
|
|
||||||
fn update_filename_extension(&mut self) {
|
|
||||||
let ext = match self.export_type {
|
|
||||||
ExportType::Audio => self.audio_settings.format.extension(),
|
|
||||||
ExportType::Video => self.video_settings.codec.container_format(),
|
|
||||||
};
|
|
||||||
// Replace extension in filename
|
|
||||||
if let Some(dot_pos) = self.output_filename.rfind('.') {
|
|
||||||
self.output_filename.truncate(dot_pos + 1);
|
|
||||||
self.output_filename.push_str(ext);
|
|
||||||
} else if !self.output_filename.is_empty() {
|
|
||||||
self.output_filename.push('.');
|
|
||||||
self.output_filename.push_str(ext);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the full output path from directory + filename
|
|
||||||
fn build_output_path(&self) -> PathBuf {
|
|
||||||
self.output_dir.join(&self.output_filename)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render the export dialog
|
/// Render the export dialog
|
||||||
///
|
///
|
||||||
/// Returns Some(ExportResult) if the user clicked Export, None otherwise.
|
/// Returns Some(ExportResult) if the user clicked Export, None otherwise.
|
||||||
|
|
@ -135,19 +88,21 @@ impl ExportDialog {
|
||||||
|
|
||||||
let mut should_export = false;
|
let mut should_export = false;
|
||||||
let mut should_close = false;
|
let mut should_close = false;
|
||||||
|
let mut open = self.open;
|
||||||
|
|
||||||
let window_title = match self.export_type {
|
let window_title = match self.export_type {
|
||||||
ExportType::Audio => "Export Audio",
|
ExportType::Audio => "Export Audio",
|
||||||
ExportType::Video => "Export Video",
|
ExportType::Video => "Export Video",
|
||||||
};
|
};
|
||||||
|
|
||||||
let modal_response = egui::Modal::new(egui::Id::new("export_dialog_modal"))
|
egui::Window::new(window_title)
|
||||||
|
.open(&mut open)
|
||||||
|
.resizable(false)
|
||||||
|
.collapsible(false)
|
||||||
|
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
ui.set_width(500.0);
|
ui.set_width(500.0);
|
||||||
|
|
||||||
ui.heading(window_title);
|
|
||||||
ui.add_space(8.0);
|
|
||||||
|
|
||||||
// Error message (if any)
|
// Error message (if any)
|
||||||
if let Some(error) = &self.error_message {
|
if let Some(error) = &self.error_message {
|
||||||
ui.colored_label(egui::Color32::RED, error);
|
ui.colored_label(egui::Color32::RED, error);
|
||||||
|
|
@ -156,42 +111,30 @@ impl ExportDialog {
|
||||||
|
|
||||||
// Export type selection (tabs)
|
// Export type selection (tabs)
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui.selectable_value(&mut self.export_type, ExportType::Audio, "Audio").clicked() {
|
ui.selectable_value(&mut self.export_type, ExportType::Audio, "🎵 Audio");
|
||||||
self.update_filename_extension();
|
ui.selectable_value(&mut self.export_type, ExportType::Video, "🎬 Video");
|
||||||
}
|
|
||||||
if ui.selectable_value(&mut self.export_type, ExportType::Video, "Video").clicked() {
|
|
||||||
self.update_filename_extension();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.add_space(12.0);
|
ui.add_space(12.0);
|
||||||
ui.separator();
|
ui.separator();
|
||||||
ui.add_space(12.0);
|
ui.add_space(12.0);
|
||||||
|
|
||||||
// Basic settings
|
// Render either audio or video settings
|
||||||
match self.export_type {
|
match self.export_type {
|
||||||
ExportType::Audio => self.render_audio_basic(ui),
|
ExportType::Audio => self.render_audio_settings(ui),
|
||||||
ExportType::Video => self.render_video_basic(ui),
|
ExportType::Video => self.render_video_settings(ui),
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(12.0);
|
ui.add_space(12.0);
|
||||||
|
|
||||||
// Output file
|
// Time range (common to both)
|
||||||
|
self.render_time_range(ui);
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
|
||||||
|
// Output file path (common to both)
|
||||||
self.render_output_selection(ui);
|
self.render_output_selection(ui);
|
||||||
|
|
||||||
ui.add_space(4.0);
|
|
||||||
|
|
||||||
// Advanced toggle
|
|
||||||
ui.toggle_value(&mut self.show_advanced, "Advanced settings");
|
|
||||||
|
|
||||||
if self.show_advanced {
|
|
||||||
ui.add_space(8.0);
|
|
||||||
match self.export_type {
|
|
||||||
ExportType::Audio => self.render_audio_advanced(ui),
|
|
||||||
ExportType::Video => self.render_video_advanced(ui),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.add_space(16.0);
|
ui.add_space(16.0);
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
|
|
@ -208,10 +151,8 @@ impl ExportDialog {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close on backdrop click or escape
|
// Update open state (in case user clicked X button)
|
||||||
if modal_response.backdrop_response.clicked() {
|
self.open = open;
|
||||||
should_close = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if should_close {
|
if should_close {
|
||||||
self.close();
|
self.close();
|
||||||
|
|
@ -219,49 +160,66 @@ impl ExportDialog {
|
||||||
}
|
}
|
||||||
|
|
||||||
if should_export {
|
if should_export {
|
||||||
self.output_path = Some(self.build_output_path());
|
|
||||||
return self.handle_export();
|
return self.handle_export();
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render basic audio settings (format + filename)
|
/// Render audio export settings UI
|
||||||
fn render_audio_basic(&mut self, ui: &mut egui::Ui) {
|
fn render_audio_settings(&mut self, ui: &mut egui::Ui) {
|
||||||
|
// Preset selection
|
||||||
|
ui.heading("Preset");
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let presets = [
|
||||||
|
("High Quality WAV", AudioExportSettings::high_quality_wav()),
|
||||||
|
("High Quality FLAC", AudioExportSettings::high_quality_flac()),
|
||||||
|
("Standard MP3", AudioExportSettings::standard_mp3()),
|
||||||
|
("Standard AAC", AudioExportSettings::standard_aac()),
|
||||||
|
("High Quality MP3", AudioExportSettings::high_quality_mp3()),
|
||||||
|
("High Quality AAC", AudioExportSettings::high_quality_aac()),
|
||||||
|
("Podcast MP3", AudioExportSettings::podcast_mp3()),
|
||||||
|
("Podcast AAC", AudioExportSettings::podcast_aac()),
|
||||||
|
];
|
||||||
|
|
||||||
|
egui::ComboBox::from_id_salt("export_preset")
|
||||||
|
.selected_text(presets[self.selected_audio_preset].0)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
for (i, (name, _)) in presets.iter().enumerate() {
|
||||||
|
if ui.selectable_value(&mut self.selected_audio_preset, i, *name).clicked() {
|
||||||
|
// Save current time range before applying preset
|
||||||
|
let saved_start = self.audio_settings.start_time;
|
||||||
|
let saved_end = self.audio_settings.end_time;
|
||||||
|
self.audio_settings = presets[i].1.clone();
|
||||||
|
// Restore time range
|
||||||
|
self.audio_settings.start_time = saved_start;
|
||||||
|
self.audio_settings.end_time = saved_end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
|
||||||
|
// Format settings
|
||||||
|
ui.heading("Format");
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Format:");
|
ui.label("Format:");
|
||||||
let prev_format = self.audio_settings.format;
|
|
||||||
egui::ComboBox::from_id_salt("audio_format")
|
egui::ComboBox::from_id_salt("audio_format")
|
||||||
.selected_text(self.audio_settings.format.name())
|
.selected_text(self.audio_settings.format.name())
|
||||||
.show_ui(ui, |ui| {
|
.show_ui(ui, |ui| {
|
||||||
|
ui.selectable_value(&mut self.audio_settings.format, AudioFormat::Wav, "WAV (Uncompressed)");
|
||||||
|
ui.selectable_value(&mut self.audio_settings.format, AudioFormat::Flac, "FLAC (Lossless)");
|
||||||
ui.selectable_value(&mut self.audio_settings.format, AudioFormat::Mp3, "MP3");
|
ui.selectable_value(&mut self.audio_settings.format, AudioFormat::Mp3, "MP3");
|
||||||
ui.selectable_value(&mut self.audio_settings.format, AudioFormat::Aac, "AAC");
|
ui.selectable_value(&mut self.audio_settings.format, AudioFormat::Aac, "AAC");
|
||||||
ui.selectable_value(&mut self.audio_settings.format, AudioFormat::Flac, "FLAC (Lossless)");
|
|
||||||
ui.selectable_value(&mut self.audio_settings.format, AudioFormat::Wav, "WAV (Uncompressed)");
|
|
||||||
});
|
});
|
||||||
if self.audio_settings.format != prev_format {
|
|
||||||
self.update_filename_extension();
|
|
||||||
// Apply sensible defaults when switching formats
|
|
||||||
match self.audio_settings.format {
|
|
||||||
AudioFormat::Mp3 => {
|
|
||||||
self.audio_settings.sample_rate = 44100;
|
|
||||||
self.audio_settings.bitrate_kbps = 192;
|
|
||||||
}
|
|
||||||
AudioFormat::Aac => {
|
|
||||||
self.audio_settings.sample_rate = 44100;
|
|
||||||
self.audio_settings.bitrate_kbps = 256;
|
|
||||||
}
|
|
||||||
AudioFormat::Flac | AudioFormat::Wav => {
|
|
||||||
self.audio_settings.sample_rate = 48000;
|
|
||||||
self.audio_settings.bit_depth = 24;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
/// Render advanced audio settings (sample rate, channels, bit depth, bitrate, time range)
|
ui.add_space(8.0);
|
||||||
fn render_audio_advanced(&mut self, ui: &mut egui::Ui) {
|
|
||||||
|
// Audio settings
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Sample Rate:");
|
ui.label("Sample Rate:");
|
||||||
egui::ComboBox::from_id_salt("sample_rate")
|
egui::ComboBox::from_id_salt("sample_rate")
|
||||||
|
|
@ -279,6 +237,8 @@ impl ExportDialog {
|
||||||
ui.radio_value(&mut self.audio_settings.channels, 2, "Stereo");
|
ui.radio_value(&mut self.audio_settings.channels, 2, "Stereo");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
// Format-specific settings
|
// Format-specific settings
|
||||||
if self.audio_settings.format.supports_bit_depth() {
|
if self.audio_settings.format.supports_bit_depth() {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
|
|
@ -301,48 +261,12 @@ impl ExportDialog {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.add_space(8.0);
|
|
||||||
|
|
||||||
// Time range
|
|
||||||
self.render_time_range(ui);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Video presets: (name, codec, quality, width, height, fps)
|
/// Render video export settings UI
|
||||||
const VIDEO_PRESETS: &'static [(&'static str, VideoCodec, VideoQuality, u32, u32, f64)] = &[
|
fn render_video_settings(&mut self, ui: &mut egui::Ui) {
|
||||||
("1080p H.264 (Standard)", VideoCodec::H264, VideoQuality::High, 1920, 1080, 30.0),
|
// Codec selection
|
||||||
("1080p H.264 60fps", VideoCodec::H264, VideoQuality::High, 1920, 1080, 60.0),
|
ui.heading("Codec");
|
||||||
("4K H.264", VideoCodec::H264, VideoQuality::VeryHigh, 3840, 2160, 30.0),
|
|
||||||
("720p H.264 (Small)", VideoCodec::H264, VideoQuality::Medium, 1280, 720, 30.0),
|
|
||||||
("1080p H.265 (Smaller)", VideoCodec::H265, VideoQuality::High, 1920, 1080, 30.0),
|
|
||||||
("1080p VP9 (WebM)", VideoCodec::VP9, VideoQuality::High, 1920, 1080, 30.0),
|
|
||||||
("1080p ProRes 422", VideoCodec::ProRes422, VideoQuality::VeryHigh, 1920, 1080, 30.0),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Render basic video settings (preset dropdown)
|
|
||||||
fn render_video_basic(&mut self, ui: &mut egui::Ui) {
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Preset:");
|
|
||||||
egui::ComboBox::from_id_salt("video_preset")
|
|
||||||
.selected_text(Self::VIDEO_PRESETS[self.selected_video_preset].0)
|
|
||||||
.show_ui(ui, |ui| {
|
|
||||||
for (i, preset) in Self::VIDEO_PRESETS.iter().enumerate() {
|
|
||||||
if ui.selectable_value(&mut self.selected_video_preset, i, preset.0).clicked() {
|
|
||||||
let (_, codec, quality, w, h, fps) = *preset;
|
|
||||||
self.video_settings.codec = codec;
|
|
||||||
self.video_settings.quality = quality;
|
|
||||||
self.video_settings.width = Some(w);
|
|
||||||
self.video_settings.height = Some(h);
|
|
||||||
self.video_settings.framerate = fps;
|
|
||||||
self.update_filename_extension();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render advanced video settings (codec, resolution, framerate, quality, time range)
|
|
||||||
fn render_video_advanced(&mut self, ui: &mut egui::Ui) {
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Codec:");
|
ui.label("Codec:");
|
||||||
egui::ComboBox::from_id_salt("video_codec")
|
egui::ComboBox::from_id_salt("video_codec")
|
||||||
|
|
@ -356,34 +280,44 @@ impl ExportDialog {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
|
||||||
|
// Resolution
|
||||||
|
ui.heading("Resolution");
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Resolution:");
|
ui.label("Width:");
|
||||||
let mut custom_width = self.video_settings.width.unwrap_or(1920);
|
let mut custom_width = self.video_settings.width.unwrap_or(1920);
|
||||||
if ui.add(egui::DragValue::new(&mut custom_width).range(1..=7680)).changed() {
|
if ui.add(egui::DragValue::new(&mut custom_width).range(1..=7680)).changed() {
|
||||||
self.video_settings.width = Some(custom_width);
|
self.video_settings.width = Some(custom_width);
|
||||||
}
|
}
|
||||||
ui.label("x");
|
|
||||||
|
ui.label("Height:");
|
||||||
let mut custom_height = self.video_settings.height.unwrap_or(1080);
|
let mut custom_height = self.video_settings.height.unwrap_or(1080);
|
||||||
if ui.add(egui::DragValue::new(&mut custom_height).range(1..=4320)).changed() {
|
if ui.add(egui::DragValue::new(&mut custom_height).range(1..=4320)).changed() {
|
||||||
self.video_settings.height = Some(custom_height);
|
self.video_settings.height = Some(custom_height);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Resolution presets
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if ui.small_button("1080p").clicked() {
|
if ui.button("1080p").clicked() {
|
||||||
self.video_settings.width = Some(1920);
|
self.video_settings.width = Some(1920);
|
||||||
self.video_settings.height = Some(1080);
|
self.video_settings.height = Some(1080);
|
||||||
}
|
}
|
||||||
if ui.small_button("4K").clicked() {
|
if ui.button("4K").clicked() {
|
||||||
self.video_settings.width = Some(3840);
|
self.video_settings.width = Some(3840);
|
||||||
self.video_settings.height = Some(2160);
|
self.video_settings.height = Some(2160);
|
||||||
}
|
}
|
||||||
if ui.small_button("720p").clicked() {
|
if ui.button("720p").clicked() {
|
||||||
self.video_settings.width = Some(1280);
|
self.video_settings.width = Some(1280);
|
||||||
self.video_settings.height = Some(720);
|
self.video_settings.height = Some(720);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
|
||||||
|
// Framerate
|
||||||
|
ui.heading("Framerate");
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("FPS:");
|
ui.label("FPS:");
|
||||||
egui::ComboBox::from_id_salt("framerate")
|
egui::ComboBox::from_id_salt("framerate")
|
||||||
|
|
@ -395,6 +329,10 @@ impl ExportDialog {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
|
||||||
|
// Quality
|
||||||
|
ui.heading("Quality");
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Quality:");
|
ui.label("Quality:");
|
||||||
egui::ComboBox::from_id_salt("video_quality")
|
egui::ComboBox::from_id_salt("video_quality")
|
||||||
|
|
@ -407,12 +345,10 @@ impl ExportDialog {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
ui.add_space(12.0);
|
||||||
|
|
||||||
|
// Include audio checkbox
|
||||||
ui.checkbox(&mut self.include_audio, "Include Audio");
|
ui.checkbox(&mut self.include_audio, "Include Audio");
|
||||||
|
|
||||||
ui.add_space(8.0);
|
|
||||||
|
|
||||||
// Time range
|
|
||||||
self.render_time_range(ui);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render time range UI (common to both audio and video)
|
/// Render time range UI (common to both audio and video)
|
||||||
|
|
@ -422,6 +358,7 @@ impl ExportDialog {
|
||||||
ExportType::Video => (&mut self.video_settings.start_time, &mut self.video_settings.end_time),
|
ExportType::Video => (&mut self.video_settings.start_time, &mut self.video_settings.end_time),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
ui.heading("Time Range");
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Start:");
|
ui.label("Start:");
|
||||||
ui.add(egui::DragValue::new(start_time)
|
ui.add(egui::DragValue::new(start_time)
|
||||||
|
|
@ -440,32 +377,46 @@ impl ExportDialog {
|
||||||
ui.label(format!("Duration: {:.2} seconds", duration));
|
ui.label(format!("Duration: {:.2} seconds", duration));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render output file selection UI
|
/// Render output file selection UI (common to both audio and video)
|
||||||
fn render_output_selection(&mut self, ui: &mut egui::Ui) {
|
fn render_output_selection(&mut self, ui: &mut egui::Ui) {
|
||||||
|
ui.heading("Output");
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Save to:");
|
let path_text = self.output_path.as_ref()
|
||||||
let dir_text = self.output_dir.display().to_string();
|
.map(|p| p.display().to_string())
|
||||||
ui.label(&dir_text);
|
.unwrap_or_else(|| "No file selected".to_string());
|
||||||
if ui.button("Change...").clicked() {
|
|
||||||
if let Some(dir) = rfd::FileDialog::new()
|
ui.label("File:");
|
||||||
.set_directory(&self.output_dir)
|
ui.text_edit_singleline(&mut path_text.clone());
|
||||||
.pick_folder()
|
|
||||||
|
if ui.button("Browse...").clicked() {
|
||||||
|
// Determine file extension and filter based on export type
|
||||||
|
let (default_name, filter_name, extensions) = match self.export_type {
|
||||||
|
ExportType::Audio => {
|
||||||
|
let ext = self.audio_settings.format.extension();
|
||||||
|
(format!("audio.{}", ext), "Audio", vec![ext])
|
||||||
|
}
|
||||||
|
ExportType::Video => {
|
||||||
|
let ext = self.video_settings.codec.container_format();
|
||||||
|
(format!("video.{}", ext), "Video", vec![ext])
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(path) = rfd::FileDialog::new()
|
||||||
|
.set_file_name(&default_name)
|
||||||
|
.add_filter(filter_name, &extensions)
|
||||||
|
.save_file()
|
||||||
{
|
{
|
||||||
self.output_dir = dir;
|
self.output_path = Some(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Filename:");
|
|
||||||
ui.text_edit_singleline(&mut self.output_filename);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle export button click
|
/// Handle export button click
|
||||||
fn handle_export(&mut self) -> Option<ExportResult> {
|
fn handle_export(&mut self) -> Option<ExportResult> {
|
||||||
if self.output_filename.trim().is_empty() {
|
// Check if output path is set
|
||||||
self.error_message = Some("Please enter a filename".to_string());
|
if self.output_path.is_none() {
|
||||||
|
self.error_message = Some("Please select an output file".to_string());
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -578,13 +529,14 @@ impl ExportProgressDialog {
|
||||||
|
|
||||||
let mut should_cancel = false;
|
let mut should_cancel = false;
|
||||||
|
|
||||||
egui::Modal::new(egui::Id::new("export_progress_modal"))
|
egui::Window::new("Exporting...")
|
||||||
|
.open(&mut self.open)
|
||||||
|
.resizable(false)
|
||||||
|
.collapsible(false)
|
||||||
|
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
ui.set_width(400.0);
|
ui.set_width(400.0);
|
||||||
|
|
||||||
ui.heading("Exporting...");
|
|
||||||
ui.add_space(8.0);
|
|
||||||
|
|
||||||
// Status message
|
// Status message
|
||||||
ui.label(&self.message);
|
ui.label(&self.message);
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
|
|
|
||||||
|
|
@ -479,11 +479,9 @@ impl ExportOrchestrator {
|
||||||
) {
|
) {
|
||||||
println!("🧵 [EXPORT THREAD] run_audio_export started");
|
println!("🧵 [EXPORT THREAD] run_audio_export started");
|
||||||
|
|
||||||
// Send start notification with calculated total frames
|
// Send start notification
|
||||||
let duration = settings.end_time - settings.start_time;
|
|
||||||
let total_frames = (duration * settings.sample_rate as f64).round() as usize;
|
|
||||||
progress_tx
|
progress_tx
|
||||||
.send(ExportProgress::Started { total_frames })
|
.send(ExportProgress::Started { total_frames: 0 })
|
||||||
.ok();
|
.ok();
|
||||||
println!("🧵 [EXPORT THREAD] Sent Started progress");
|
println!("🧵 [EXPORT THREAD] Sent Started progress");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2113,12 +2113,7 @@ impl EditorApp {
|
||||||
println!("Menu: Export");
|
println!("Menu: Export");
|
||||||
// Open export dialog with calculated timeline endpoint
|
// Open export dialog with calculated timeline endpoint
|
||||||
let timeline_endpoint = self.action_executor.document().calculate_timeline_endpoint();
|
let timeline_endpoint = self.action_executor.document().calculate_timeline_endpoint();
|
||||||
// Derive project name from the .beam file path, falling back to document name
|
self.export_dialog.open(timeline_endpoint);
|
||||||
let project_name = self.current_file_path.as_ref()
|
|
||||||
.and_then(|p| p.file_stem())
|
|
||||||
.map(|s| s.to_string_lossy().into_owned())
|
|
||||||
.unwrap_or_else(|| self.action_executor.document().name.clone());
|
|
||||||
self.export_dialog.open(timeline_endpoint, &project_name);
|
|
||||||
}
|
}
|
||||||
MenuAction::Quit => {
|
MenuAction::Quit => {
|
||||||
println!("Menu: Quit");
|
println!("Menu: Quit");
|
||||||
|
|
@ -2712,41 +2707,6 @@ impl EditorApp {
|
||||||
}
|
}
|
||||||
eprintln!("📊 [APPLY] Step 7: Fetched {} raw audio samples in {:.2}ms", raw_fetched, step7_start.elapsed().as_secs_f64() * 1000.0);
|
eprintln!("📊 [APPLY] Step 7: Fetched {} raw audio samples in {:.2}ms", raw_fetched, step7_start.elapsed().as_secs_f64() * 1000.0);
|
||||||
|
|
||||||
// Rebuild MIDI event cache for all MIDI clips (needed for timeline/piano roll rendering)
|
|
||||||
let step8_start = std::time::Instant::now();
|
|
||||||
self.midi_event_cache.clear();
|
|
||||||
let midi_clip_ids: Vec<u32> = self.action_executor.document()
|
|
||||||
.audio_clips.values()
|
|
||||||
.filter_map(|clip| clip.midi_clip_id())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut midi_fetched = 0;
|
|
||||||
if let Some(ref controller_arc) = self.audio_controller {
|
|
||||||
let mut controller = controller_arc.lock().unwrap();
|
|
||||||
for clip_id in midi_clip_ids {
|
|
||||||
// track_id is unused by the query, pass 0
|
|
||||||
match controller.query_midi_clip(0, clip_id) {
|
|
||||||
Ok(clip_data) => {
|
|
||||||
let processed_events: Vec<(f64, u8, u8, bool)> = clip_data.events.iter()
|
|
||||||
.filter_map(|event| {
|
|
||||||
let status_type = event.status & 0xF0;
|
|
||||||
if status_type == 0x90 || status_type == 0x80 {
|
|
||||||
let is_note_on = status_type == 0x90 && event.data2 > 0;
|
|
||||||
Some((event.timestamp, event.data1, event.data2, is_note_on))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
self.midi_event_cache.insert(clip_id, processed_events);
|
|
||||||
midi_fetched += 1;
|
|
||||||
}
|
|
||||||
Err(e) => eprintln!("Failed to fetch MIDI clip {}: {}", clip_id, e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
eprintln!("📊 [APPLY] Step 8: Rebuilt MIDI event cache for {} clips in {:.2}ms", midi_fetched, step8_start.elapsed().as_secs_f64() * 1000.0);
|
|
||||||
|
|
||||||
// Reset playback state
|
// Reset playback state
|
||||||
self.playback_time = 0.0;
|
self.playback_time = 0.0;
|
||||||
self.is_playing = false;
|
self.is_playing = false;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ use eframe::egui;
|
||||||
use egui_node_graph2::*;
|
use egui_node_graph2::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use crate::widgets;
|
|
||||||
|
|
||||||
/// Signal types for audio node graph
|
/// Signal types for audio node graph
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|
@ -136,85 +135,20 @@ impl NodeTemplate {
|
||||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct NodeData {
|
pub struct NodeData {
|
||||||
pub template: NodeTemplate,
|
pub template: NodeTemplate,
|
||||||
/// Display name of loaded sample (for SimpleSampler/MultiSampler nodes)
|
|
||||||
#[serde(default)]
|
|
||||||
pub sample_display_name: Option<String>,
|
|
||||||
/// Root note (MIDI note number) for original-pitch playback (default 69 = A4)
|
|
||||||
#[serde(default = "default_root_note")]
|
|
||||||
pub root_note: u8,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_root_note() -> u8 { 69 }
|
|
||||||
|
|
||||||
/// Cached oscilloscope waveform data for rendering in node body
|
/// Cached oscilloscope waveform data for rendering in node body
|
||||||
pub struct OscilloscopeCache {
|
pub struct OscilloscopeCache {
|
||||||
pub audio: Vec<f32>,
|
pub audio: Vec<f32>,
|
||||||
pub cv: Vec<f32>,
|
pub cv: Vec<f32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Info about an audio clip available for sampler selection
|
|
||||||
pub struct SamplerClipInfo {
|
|
||||||
pub name: String,
|
|
||||||
pub pool_index: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Info about an asset folder available for multi-sampler
|
|
||||||
pub struct SamplerFolderInfo {
|
|
||||||
pub folder_id: uuid::Uuid,
|
|
||||||
pub name: String,
|
|
||||||
/// Pool indices of audio clips in this folder
|
|
||||||
pub clip_pool_indices: Vec<(String, usize)>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Pending sampler load request from bottom_ui(), handled by the node graph pane
|
|
||||||
pub enum PendingSamplerLoad {
|
|
||||||
/// Load a single clip from the audio pool into a SimpleSampler
|
|
||||||
SimpleFromPool { node_id: NodeId, backend_node_id: u32, pool_index: usize, name: String },
|
|
||||||
/// Open a file dialog to load into a SimpleSampler
|
|
||||||
SimpleFromFile { node_id: NodeId, backend_node_id: u32 },
|
|
||||||
/// Load a single clip from the audio pool as a MultiSampler layer
|
|
||||||
MultiFromPool { node_id: NodeId, backend_node_id: u32, pool_index: usize, name: String },
|
|
||||||
/// Load all clips in a folder as MultiSampler layers
|
|
||||||
MultiFromFolder { node_id: NodeId, folder_id: uuid::Uuid },
|
|
||||||
/// Open a file/folder dialog to load into a MultiSampler
|
|
||||||
MultiFromFilesystem { node_id: NodeId, backend_node_id: u32 },
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Custom graph state - can track selected nodes, etc.
|
/// Custom graph state - can track selected nodes, etc.
|
||||||
|
#[derive(Default)]
|
||||||
pub struct GraphState {
|
pub struct GraphState {
|
||||||
pub active_node: Option<NodeId>,
|
pub active_node: Option<NodeId>,
|
||||||
/// Oscilloscope data cached per node, populated before draw_graph_editor()
|
/// Oscilloscope data cached per node, populated before draw_graph_editor()
|
||||||
pub oscilloscope_data: HashMap<NodeId, OscilloscopeCache>,
|
pub oscilloscope_data: HashMap<NodeId, OscilloscopeCache>,
|
||||||
/// Audio clips available for sampler selection, populated before draw
|
|
||||||
pub available_clips: Vec<SamplerClipInfo>,
|
|
||||||
/// Asset folders available for multi-sampler, populated before draw
|
|
||||||
pub available_folders: Vec<SamplerFolderInfo>,
|
|
||||||
/// Pending sample load request from bottom_ui popup
|
|
||||||
pub pending_sampler_load: Option<PendingSamplerLoad>,
|
|
||||||
/// Search text for the sampler clip picker popup
|
|
||||||
pub sampler_search_text: String,
|
|
||||||
/// Mapping from frontend NodeId to backend node index, populated before draw
|
|
||||||
pub node_backend_ids: HashMap<NodeId, u32>,
|
|
||||||
/// Pending root note changes from bottom_ui (node_id, backend_node_id, new_root_note)
|
|
||||||
pub pending_root_note_changes: Vec<(NodeId, u32, u8)>,
|
|
||||||
/// Time scale per oscilloscope node (in milliseconds)
|
|
||||||
pub oscilloscope_time_scale: HashMap<NodeId, f32>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for GraphState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
active_node: None,
|
|
||||||
oscilloscope_data: HashMap::new(),
|
|
||||||
available_clips: Vec::new(),
|
|
||||||
available_folders: Vec::new(),
|
|
||||||
pending_sampler_load: None,
|
|
||||||
sampler_search_text: String::new(),
|
|
||||||
node_backend_ids: HashMap::new(),
|
|
||||||
pending_root_note_changes: Vec::new(),
|
|
||||||
oscilloscope_time_scale: HashMap::new(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// User response type (empty for now)
|
/// User response type (empty for now)
|
||||||
|
|
@ -399,7 +333,7 @@ impl NodeTemplateTrait for NodeTemplate {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn user_data(&self, _user_state: &mut Self::UserState) -> Self::NodeData {
|
fn user_data(&self, _user_state: &mut Self::UserState) -> Self::NodeData {
|
||||||
NodeData { template: *self, sample_display_name: None, root_note: 69 }
|
NodeData { template: *self }
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_node(
|
fn build_node(
|
||||||
|
|
@ -564,7 +498,6 @@ impl NodeTemplateTrait for NodeTemplate {
|
||||||
graph.add_output_param(node_id, "Audio Out".into(), DataType::Audio);
|
graph.add_output_param(node_id, "Audio Out".into(), DataType::Audio);
|
||||||
}
|
}
|
||||||
NodeTemplate::SimpleSampler => {
|
NodeTemplate::SimpleSampler => {
|
||||||
graph.add_input_param(node_id, "V/Oct".into(), DataType::CV, ValueType::float(0.0), InputParamKind::ConnectionOnly, true);
|
|
||||||
graph.add_input_param(node_id, "Gate".into(), DataType::CV, ValueType::float(0.0), InputParamKind::ConnectionOnly, true);
|
graph.add_input_param(node_id, "Gate".into(), DataType::CV, ValueType::float(0.0), InputParamKind::ConnectionOnly, true);
|
||||||
graph.add_output_param(node_id, "Audio Out".into(), DataType::Audio);
|
graph.add_output_param(node_id, "Audio Out".into(), DataType::Audio);
|
||||||
}
|
}
|
||||||
|
|
@ -848,14 +781,6 @@ impl WidgetValueTrait for ValueType {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const NOTE_NAMES: [&str; 12] = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
|
|
||||||
|
|
||||||
fn midi_note_name(note: u8) -> String {
|
|
||||||
let octave = (note as i32 / 12) - 1;
|
|
||||||
let name = NOTE_NAMES[note as usize % 12];
|
|
||||||
format!("{}{}", name, octave)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Implement NodeDataTrait for custom node UI (optional)
|
// Implement NodeDataTrait for custom node UI (optional)
|
||||||
impl NodeDataTrait for NodeData {
|
impl NodeDataTrait for NodeData {
|
||||||
type Response = UserResponse;
|
type Response = UserResponse;
|
||||||
|
|
@ -873,121 +798,7 @@ impl NodeDataTrait for NodeData {
|
||||||
where
|
where
|
||||||
Self::Response: UserResponseTrait,
|
Self::Response: UserResponseTrait,
|
||||||
{
|
{
|
||||||
if self.template == NodeTemplate::SimpleSampler || self.template == NodeTemplate::MultiSampler {
|
if self.template == NodeTemplate::Oscilloscope {
|
||||||
let is_multi = self.template == NodeTemplate::MultiSampler;
|
|
||||||
let backend_node_id = user_state.node_backend_ids.get(&node_id).copied().unwrap_or(0);
|
|
||||||
let default_text = if is_multi { "Select samples..." } else { "Select sample..." };
|
|
||||||
let button_text = self.sample_display_name.as_deref().unwrap_or(default_text);
|
|
||||||
|
|
||||||
let button = ui.button(button_text);
|
|
||||||
if button.clicked() {
|
|
||||||
user_state.sampler_search_text.clear();
|
|
||||||
}
|
|
||||||
let popup_id = egui::Popup::default_response_id(&button);
|
|
||||||
|
|
||||||
let mut close_popup = false;
|
|
||||||
egui::Popup::from_toggle_button_response(&button)
|
|
||||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
|
||||||
.width(160.0)
|
|
||||||
.show(|ui| {
|
|
||||||
let search_width = ui.available_width();
|
|
||||||
ui.add_sized([search_width, 0.0], egui::TextEdit::singleline(&mut user_state.sampler_search_text).hint_text("Search..."));
|
|
||||||
ui.separator();
|
|
||||||
let search = user_state.sampler_search_text.to_lowercase();
|
|
||||||
|
|
||||||
// Folders section (multi-sampler only)
|
|
||||||
if is_multi && !user_state.available_folders.is_empty() {
|
|
||||||
ui.label(egui::RichText::new("Folders").small().weak());
|
|
||||||
for folder in &user_state.available_folders {
|
|
||||||
if !search.is_empty() && !folder.name.to_lowercase().contains(&search) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let label = format!("📁 {} ({} clips)", folder.name, folder.clip_pool_indices.len());
|
|
||||||
if widgets::list_item(ui, false, &label) {
|
|
||||||
user_state.pending_sampler_load = Some(PendingSamplerLoad::MultiFromFolder {
|
|
||||||
node_id,
|
|
||||||
folder_id: folder.folder_id,
|
|
||||||
});
|
|
||||||
close_popup = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ui.separator();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audio clips list
|
|
||||||
if is_multi {
|
|
||||||
ui.label(egui::RichText::new("Audio Clips").small().weak());
|
|
||||||
}
|
|
||||||
let filtered_clips: Vec<&SamplerClipInfo> = user_state.available_clips.iter()
|
|
||||||
.filter(|clip| search.is_empty() || clip.name.to_lowercase().contains(&search))
|
|
||||||
.collect();
|
|
||||||
let items = filtered_clips.iter().map(|clip| (false, clip.name.as_str()));
|
|
||||||
if let Some(idx) = widgets::scrollable_list(ui, 200.0, items) {
|
|
||||||
let clip = filtered_clips[idx];
|
|
||||||
if is_multi {
|
|
||||||
user_state.pending_sampler_load = Some(PendingSamplerLoad::MultiFromPool {
|
|
||||||
node_id,
|
|
||||||
backend_node_id,
|
|
||||||
pool_index: clip.pool_index,
|
|
||||||
name: clip.name.clone(),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
user_state.pending_sampler_load = Some(PendingSamplerLoad::SimpleFromPool {
|
|
||||||
node_id,
|
|
||||||
backend_node_id,
|
|
||||||
pool_index: clip.pool_index,
|
|
||||||
name: clip.name.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
close_popup = true;
|
|
||||||
}
|
|
||||||
ui.separator();
|
|
||||||
if ui.button("Open...").clicked() {
|
|
||||||
if is_multi {
|
|
||||||
user_state.pending_sampler_load = Some(PendingSamplerLoad::MultiFromFilesystem {
|
|
||||||
node_id,
|
|
||||||
backend_node_id,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
user_state.pending_sampler_load = Some(PendingSamplerLoad::SimpleFromFile {
|
|
||||||
node_id,
|
|
||||||
backend_node_id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
close_popup = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if close_popup {
|
|
||||||
egui::Popup::close_id(ui.ctx(), popup_id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Root note selector
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label(egui::RichText::new("Root:").weak());
|
|
||||||
let note_name = midi_note_name(self.root_note);
|
|
||||||
let root_btn = ui.button(¬e_name);
|
|
||||||
let root_popup_id = egui::Popup::default_response_id(&root_btn);
|
|
||||||
let mut close_root = false;
|
|
||||||
egui::Popup::from_toggle_button_response(&root_btn)
|
|
||||||
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
|
|
||||||
.width(80.0)
|
|
||||||
.show(|ui| {
|
|
||||||
let notes: Vec<(u8, String)> = (24..=96).rev()
|
|
||||||
.map(|n| (n, midi_note_name(n)))
|
|
||||||
.collect();
|
|
||||||
let items = notes.iter().map(|(n, name)| (*n == self.root_note, name.as_str()));
|
|
||||||
if let Some(idx) = widgets::scrollable_list(ui, 200.0, items) {
|
|
||||||
let (note, _) = ¬es[idx];
|
|
||||||
user_state.pending_root_note_changes.push((node_id, backend_node_id, *note));
|
|
||||||
close_root = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if close_root {
|
|
||||||
egui::Popup::close_id(ui.ctx(), root_popup_id);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if self.template == NodeTemplate::Oscilloscope {
|
|
||||||
let size = egui::vec2(200.0, 80.0);
|
let size = egui::vec2(200.0, 80.0);
|
||||||
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
|
let (rect, _) = ui.allocate_exact_size(size, egui::Sense::hover());
|
||||||
let painter = ui.painter_at(rect);
|
let painter = ui.painter_at(rect);
|
||||||
|
|
@ -1023,15 +834,6 @@ impl NodeDataTrait for NodeData {
|
||||||
painter.add(egui::Shape::line(points, egui::Stroke::new(1.5, egui::Color32::from_rgb(0xFF, 0x98, 0x00))));
|
painter.add(egui::Shape::line(points, egui::Stroke::new(1.5, egui::Color32::from_rgb(0xFF, 0x98, 0x00))));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Time window slider
|
|
||||||
let time_ms = user_state.oscilloscope_time_scale.entry(node_id).or_insert(100.0);
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.spacing_mut().slider_width = 140.0;
|
|
||||||
ui.add(egui::Slider::new(time_ms, 10.0..=1000.0)
|
|
||||||
.suffix(" ms")
|
|
||||||
.logarithmic(true));
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
ui.label("");
|
ui.label("");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -560,126 +560,6 @@ impl NodeGraphPane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_pending_sampler_load(
|
|
||||||
&mut self,
|
|
||||||
load: graph_data::PendingSamplerLoad,
|
|
||||||
shared: &mut crate::panes::SharedPaneState,
|
|
||||||
) {
|
|
||||||
let backend_track_id = match self.backend_track_id {
|
|
||||||
Some(id) => id,
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
let controller_arc = match &shared.audio_controller {
|
|
||||||
Some(c) => std::sync::Arc::clone(c),
|
|
||||||
None => return,
|
|
||||||
};
|
|
||||||
|
|
||||||
match load {
|
|
||||||
graph_data::PendingSamplerLoad::SimpleFromPool { node_id, backend_node_id, pool_index, name } => {
|
|
||||||
let mut controller = controller_arc.lock().unwrap();
|
|
||||||
controller.sampler_load_from_pool(backend_track_id, backend_node_id, pool_index);
|
|
||||||
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
|
|
||||||
node.user_data.sample_display_name = Some(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
graph_data::PendingSamplerLoad::SimpleFromFile { node_id, backend_node_id } => {
|
|
||||||
if let Some(path) = rfd::FileDialog::new()
|
|
||||||
.add_filter("Audio", &["wav", "flac", "mp3", "ogg", "aiff"])
|
|
||||||
.pick_file()
|
|
||||||
{
|
|
||||||
let file_name = path.file_stem()
|
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_else(|| "Sample".to_string());
|
|
||||||
|
|
||||||
// Import into audio pool + asset library, then load from pool
|
|
||||||
let mut controller = controller_arc.lock().unwrap();
|
|
||||||
match controller.import_audio_sync(path.to_path_buf()) {
|
|
||||||
Ok(pool_index) => {
|
|
||||||
// Add to document asset library
|
|
||||||
let metadata = daw_backend::io::read_metadata(&path).ok();
|
|
||||||
let duration = metadata.as_ref().map(|m| m.duration).unwrap_or(0.0);
|
|
||||||
let clip = lightningbeam_core::clip::AudioClip::new_sampled(&file_name, pool_index, duration);
|
|
||||||
shared.action_executor.document_mut().add_audio_clip(clip);
|
|
||||||
|
|
||||||
// Load into sampler from pool
|
|
||||||
controller.sampler_load_from_pool(backend_track_id, backend_node_id, pool_index);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to import audio '{}': {}", path.display(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
|
|
||||||
node.user_data.sample_display_name = Some(file_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
graph_data::PendingSamplerLoad::MultiFromPool { node_id, backend_node_id, pool_index, name } => {
|
|
||||||
let mut controller = controller_arc.lock().unwrap();
|
|
||||||
// Add as a single layer spanning full key range, root_key = 60 (C4)
|
|
||||||
controller.multi_sampler_add_layer_from_pool(
|
|
||||||
backend_track_id, backend_node_id, pool_index,
|
|
||||||
0, 127, 60,
|
|
||||||
);
|
|
||||||
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
|
|
||||||
node.user_data.sample_display_name = Some(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
graph_data::PendingSamplerLoad::MultiFromFolder { node_id, folder_id } => {
|
|
||||||
// Find folder clips from available_folders
|
|
||||||
let folder_clips: Vec<(String, usize)> = self.user_state.available_folders.iter()
|
|
||||||
.find(|f| f.folder_id == folder_id)
|
|
||||||
.map(|f| f.clip_pool_indices.clone())
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
if !folder_clips.is_empty() {
|
|
||||||
// TODO: Add MultiSamplerLoadFromPool command to avoid disk re-reads.
|
|
||||||
// For now, folder loading is a placeholder — the UI is wired up but
|
|
||||||
// loading multi-sampler layers from pool requires a new backend command.
|
|
||||||
let folder_name = self.user_state.available_folders.iter()
|
|
||||||
.find(|f| f.folder_id == folder_id)
|
|
||||||
.map(|f| f.name.clone())
|
|
||||||
.unwrap_or_else(|| "Folder".to_string());
|
|
||||||
eprintln!("MultiSampler folder load not yet implemented for folder: {}", folder_name);
|
|
||||||
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
|
|
||||||
node.user_data.sample_display_name = Some(format!("📁 {}", folder_name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
graph_data::PendingSamplerLoad::MultiFromFilesystem { node_id, backend_node_id } => {
|
|
||||||
if let Some(path) = rfd::FileDialog::new()
|
|
||||||
.add_filter("Audio", &["wav", "flac", "mp3", "ogg", "aiff"])
|
|
||||||
.pick_file()
|
|
||||||
{
|
|
||||||
let file_name = path.file_stem()
|
|
||||||
.map(|s| s.to_string_lossy().to_string())
|
|
||||||
.unwrap_or_else(|| "Sample".to_string());
|
|
||||||
let mut controller = controller_arc.lock().unwrap();
|
|
||||||
// Import into audio pool + asset library, then load from pool
|
|
||||||
match controller.import_audio_sync(path.to_path_buf()) {
|
|
||||||
Ok(pool_index) => {
|
|
||||||
let metadata = daw_backend::io::read_metadata(&path).ok();
|
|
||||||
let duration = metadata.as_ref().map(|m| m.duration).unwrap_or(0.0);
|
|
||||||
let clip = lightningbeam_core::clip::AudioClip::new_sampled(&file_name, pool_index, duration);
|
|
||||||
shared.action_executor.document_mut().add_audio_clip(clip);
|
|
||||||
|
|
||||||
// Add as layer spanning full key range
|
|
||||||
controller.multi_sampler_add_layer_from_pool(
|
|
||||||
backend_track_id, backend_node_id, pool_index,
|
|
||||||
0, 127, 60,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
eprintln!("Failed to import audio '{}': {}", path.display(), e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
|
|
||||||
node.user_data.sample_display_name = Some(file_name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_parameter_changes(&mut self, shared: &mut crate::panes::SharedPaneState) {
|
fn check_parameter_changes(&mut self, shared: &mut crate::panes::SharedPaneState) {
|
||||||
// Check all input parameters for value changes
|
// Check all input parameters for value changes
|
||||||
let mut _checked_count = 0;
|
let mut _checked_count = 0;
|
||||||
|
|
@ -1674,7 +1554,7 @@ impl NodeGraphPane {
|
||||||
label: group.name.clone(),
|
label: group.name.clone(),
|
||||||
inputs: vec![],
|
inputs: vec![],
|
||||||
outputs: vec![],
|
outputs: vec![],
|
||||||
user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69 },
|
user_data: NodeData { template: NodeTemplate::Group },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add dynamic input ports based on boundary inputs
|
// Add dynamic input ports based on boundary inputs
|
||||||
|
|
@ -1746,7 +1626,7 @@ impl NodeGraphPane {
|
||||||
label: "Group Input".to_string(),
|
label: "Group Input".to_string(),
|
||||||
inputs: vec![],
|
inputs: vec![],
|
||||||
outputs: vec![],
|
outputs: vec![],
|
||||||
user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69 },
|
user_data: NodeData { template: NodeTemplate::Group },
|
||||||
});
|
});
|
||||||
|
|
||||||
for bc in &scope_group.boundary_inputs {
|
for bc in &scope_group.boundary_inputs {
|
||||||
|
|
@ -1793,7 +1673,7 @@ impl NodeGraphPane {
|
||||||
label: "Group Output".to_string(),
|
label: "Group Output".to_string(),
|
||||||
inputs: vec![],
|
inputs: vec![],
|
||||||
outputs: vec![],
|
outputs: vec![],
|
||||||
user_data: NodeData { template: NodeTemplate::Group, sample_display_name: None, root_note: 69 },
|
user_data: NodeData { template: NodeTemplate::Group },
|
||||||
});
|
});
|
||||||
|
|
||||||
for bc in &scope_group.boundary_outputs {
|
for bc in &scope_group.boundary_outputs {
|
||||||
|
|
@ -1986,7 +1866,7 @@ impl NodeGraphPane {
|
||||||
label: label.to_string(),
|
label: label.to_string(),
|
||||||
inputs: vec![],
|
inputs: vec![],
|
||||||
outputs: vec![],
|
outputs: vec![],
|
||||||
user_data: NodeData { template: node_template, sample_display_name: None, root_note: 69 },
|
user_data: NodeData { template: node_template },
|
||||||
});
|
});
|
||||||
|
|
||||||
node_template.build_node(&mut self.state.graph, &mut self.user_state, frontend_id);
|
node_template.build_node(&mut self.state.graph, &mut self.user_state, frontend_id);
|
||||||
|
|
@ -2162,14 +2042,10 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
||||||
|
|
||||||
let mut controller = audio_controller.lock().unwrap();
|
let mut controller = audio_controller.lock().unwrap();
|
||||||
for (node_id, backend_node_id) in oscilloscope_nodes {
|
for (node_id, backend_node_id) in oscilloscope_nodes {
|
||||||
// Calculate sample count from per-node time scale (default 100ms)
|
|
||||||
let time_ms = self.user_state.oscilloscope_time_scale
|
|
||||||
.get(&node_id).copied().unwrap_or(100.0);
|
|
||||||
let sample_count = ((time_ms / 1000.0) * 48000.0) as usize;
|
|
||||||
let result = if let Some(va_id) = va_backend_id {
|
let result = if let Some(va_id) = va_backend_id {
|
||||||
controller.query_voice_oscilloscope_data(backend_track_id, va_id, backend_node_id, sample_count)
|
controller.query_voice_oscilloscope_data(backend_track_id, va_id, backend_node_id, 4800)
|
||||||
} else {
|
} else {
|
||||||
controller.query_oscilloscope_data(backend_track_id, backend_node_id, sample_count)
|
controller.query_oscilloscope_data(backend_track_id, backend_node_id, 4800)
|
||||||
};
|
};
|
||||||
if let Ok(data) = result {
|
if let Ok(data) = result {
|
||||||
self.user_state.oscilloscope_data.insert(node_id, graph_data::OscilloscopeCache {
|
self.user_state.oscilloscope_data.insert(node_id, graph_data::OscilloscopeCache {
|
||||||
|
|
@ -2296,55 +2172,6 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
||||||
let zoom_before = self.state.pan_zoom.zoom;
|
let zoom_before = self.state.pan_zoom.zoom;
|
||||||
let pan_before = self.state.pan_zoom.pan;
|
let pan_before = self.state.pan_zoom.pan;
|
||||||
|
|
||||||
// Populate sampler clip list and node backend ID map for bottom_ui()
|
|
||||||
{
|
|
||||||
use lightningbeam_core::clip::AudioClipType;
|
|
||||||
|
|
||||||
let doc = shared.action_executor.document();
|
|
||||||
|
|
||||||
// Available audio clips
|
|
||||||
self.user_state.available_clips = doc.audio_clips.values()
|
|
||||||
.filter_map(|clip| match &clip.clip_type {
|
|
||||||
AudioClipType::Sampled { audio_pool_index } => Some(graph_data::SamplerClipInfo {
|
|
||||||
name: clip.name.clone(),
|
|
||||||
pool_index: *audio_pool_index,
|
|
||||||
}),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
self.user_state.available_clips.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
|
||||||
|
|
||||||
// Available folders (with their contained audio clips)
|
|
||||||
self.user_state.available_folders = doc.audio_folders.folders.values()
|
|
||||||
.map(|folder| {
|
|
||||||
let clips_in_folder: Vec<(String, usize)> = doc.audio_clips.values()
|
|
||||||
.filter(|clip| clip.folder_id == Some(folder.id))
|
|
||||||
.filter_map(|clip| match &clip.clip_type {
|
|
||||||
AudioClipType::Sampled { audio_pool_index } => Some((clip.name.clone(), *audio_pool_index)),
|
|
||||||
_ => None,
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
graph_data::SamplerFolderInfo {
|
|
||||||
folder_id: folder.id,
|
|
||||||
name: folder.name.clone(),
|
|
||||||
clip_pool_indices: clips_in_folder,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter(|f| !f.clip_pool_indices.is_empty())
|
|
||||||
.collect();
|
|
||||||
self.user_state.available_folders.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
|
||||||
|
|
||||||
// Node backend ID map
|
|
||||||
self.user_state.node_backend_ids = self.node_id_map.iter()
|
|
||||||
.map(|(&node_id, backend_id)| {
|
|
||||||
let id = match backend_id {
|
|
||||||
BackendNodeId::Audio(idx) => idx.index() as u32,
|
|
||||||
};
|
|
||||||
(node_id, id)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw dot grid background with pan/zoom
|
// Draw dot grid background with pan/zoom
|
||||||
let pan_zoom = &self.state.pan_zoom;
|
let pan_zoom = &self.state.pan_zoom;
|
||||||
Self::draw_dot_grid_background(ui, graph_rect, bg_color, grid_color, pan_zoom);
|
Self::draw_dot_grid_background(ui, graph_rect, bg_color, grid_color, pan_zoom);
|
||||||
|
|
@ -2377,27 +2204,6 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
||||||
self.last_node_rects = graph_response.node_rects.clone();
|
self.last_node_rects = graph_response.node_rects.clone();
|
||||||
self.handle_graph_response(graph_response, shared, graph_rect);
|
self.handle_graph_response(graph_response, shared, graph_rect);
|
||||||
|
|
||||||
// Handle pending sampler load requests from bottom_ui()
|
|
||||||
if let Some(load) = self.user_state.pending_sampler_load.take() {
|
|
||||||
self.handle_pending_sampler_load(load, shared);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle pending root note changes
|
|
||||||
if !self.user_state.pending_root_note_changes.is_empty() {
|
|
||||||
let changes: Vec<_> = self.user_state.pending_root_note_changes.drain(..).collect();
|
|
||||||
if let Some(backend_track_id) = self.track_id.and_then(|tid| shared.layer_to_track_map.get(&tid).copied()) {
|
|
||||||
if let Some(controller_arc) = &shared.audio_controller {
|
|
||||||
let mut controller = controller_arc.lock().unwrap();
|
|
||||||
for (node_id, backend_node_id, root_note) in changes {
|
|
||||||
controller.sampler_set_root_note(backend_track_id, backend_node_id, root_note);
|
|
||||||
if let Some(node) = self.state.graph.nodes.get_mut(node_id) {
|
|
||||||
node.user_data.root_note = root_note;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect right-click on nodes — intercept the library's node finder and show our context menu instead
|
// Detect right-click on nodes — intercept the library's node finder and show our context menu instead
|
||||||
{
|
{
|
||||||
let secondary_clicked = ui.input(|i| i.pointer.secondary_released());
|
let secondary_clicked = ui.input(|i| i.pointer.secondary_released());
|
||||||
|
|
|
||||||
|
|
@ -148,70 +148,6 @@ impl VirtualPianoPane {
|
||||||
(start_note as u8, end_note as u8, white_key_width, 0.0)
|
(start_note as u8, end_note as u8, white_key_width, 0.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render keys visually without any input handling (used when a modal is active)
|
|
||||||
fn render_keyboard_visual_only(
|
|
||||||
&self,
|
|
||||||
ui: &mut egui::Ui,
|
|
||||||
rect: egui::Rect,
|
|
||||||
visible_start: u8,
|
|
||||||
visible_end: u8,
|
|
||||||
white_key_width: f32,
|
|
||||||
offset_x: f32,
|
|
||||||
white_key_height: f32,
|
|
||||||
black_key_width: f32,
|
|
||||||
black_key_height: f32,
|
|
||||||
) {
|
|
||||||
// Draw white keys
|
|
||||||
let mut white_pos = 0f32;
|
|
||||||
for note in visible_start..=visible_end {
|
|
||||||
if !Self::is_white_key(note) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let x = rect.min.x + offset_x + (white_pos * white_key_width);
|
|
||||||
let key_rect = egui::Rect::from_min_size(
|
|
||||||
egui::pos2(x, rect.min.y),
|
|
||||||
egui::vec2(white_key_width - 1.0, white_key_height),
|
|
||||||
);
|
|
||||||
let color = if self.pressed_notes.contains(¬e) {
|
|
||||||
egui::Color32::from_rgb(100, 150, 255)
|
|
||||||
} else {
|
|
||||||
egui::Color32::WHITE
|
|
||||||
};
|
|
||||||
ui.painter().rect_filled(key_rect, 2.0, color);
|
|
||||||
ui.painter().rect_stroke(
|
|
||||||
key_rect,
|
|
||||||
2.0,
|
|
||||||
egui::Stroke::new(1.0, egui::Color32::BLACK),
|
|
||||||
egui::StrokeKind::Middle,
|
|
||||||
);
|
|
||||||
white_pos += 1.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Draw black keys
|
|
||||||
for note in visible_start..=visible_end {
|
|
||||||
if !Self::is_black_key(note) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut white_keys_before = 0;
|
|
||||||
for n in visible_start..note {
|
|
||||||
if Self::is_white_key(n) {
|
|
||||||
white_keys_before += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let x = rect.min.x + offset_x + (white_keys_before as f32 * white_key_width) - (black_key_width / 2.0);
|
|
||||||
let key_rect = egui::Rect::from_min_size(
|
|
||||||
egui::pos2(x, rect.min.y),
|
|
||||||
egui::vec2(black_key_width, black_key_height),
|
|
||||||
);
|
|
||||||
let color = if self.pressed_notes.contains(¬e) {
|
|
||||||
egui::Color32::from_rgb(50, 100, 200)
|
|
||||||
} else {
|
|
||||||
egui::Color32::BLACK
|
|
||||||
};
|
|
||||||
ui.painter().rect_filled(key_rect, 2.0, color);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render the piano keyboard
|
/// Render the piano keyboard
|
||||||
fn render_keyboard(&mut self, ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState) {
|
fn render_keyboard(&mut self, ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState) {
|
||||||
// Calculate visible range and key dimensions based on pane size
|
// Calculate visible range and key dimensions based on pane size
|
||||||
|
|
@ -222,20 +158,6 @@ impl VirtualPianoPane {
|
||||||
let black_key_width = white_key_width * self.black_key_width_ratio;
|
let black_key_width = white_key_width * self.black_key_width_ratio;
|
||||||
let black_key_height = white_key_height * self.black_key_height_ratio;
|
let black_key_height = white_key_height * self.black_key_height_ratio;
|
||||||
|
|
||||||
// If a modal dialog is open, don't process mouse input — just render keys visually.
|
|
||||||
// We read raw input (ui.input) which bypasses egui's modal blocking, so we must check manually.
|
|
||||||
let modal_active = ui.ctx().memory(|m| m.top_modal_layer().is_some());
|
|
||||||
if modal_active {
|
|
||||||
// Release any held notes so they don't get stuck
|
|
||||||
if self.dragging_note.is_some() {
|
|
||||||
if let Some(note) = self.dragging_note.take() {
|
|
||||||
self.send_note_off(note, shared);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.render_keyboard_visual_only(ui, rect, visible_start, visible_end, white_key_width, offset_x, white_key_height, black_key_width, black_key_height);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count white keys before each note for positioning
|
// Count white keys before each note for positioning
|
||||||
let mut white_key_positions: std::collections::HashMap<u8, f32> = std::collections::HashMap::new();
|
let mut white_key_positions: std::collections::HashMap<u8, f32> = std::collections::HashMap::new();
|
||||||
let mut white_count = 0;
|
let mut white_count = 0;
|
||||||
|
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
//! Full-width selectable list for use inside popups and dropdowns.
|
|
||||||
//!
|
|
||||||
//! Solves the recurring issue where `selectable_label` inside `ScrollArea`
|
|
||||||
//! inside a `Popup` doesn't fill the available width, making only the text
|
|
||||||
//! portion clickable.
|
|
||||||
|
|
||||||
use eframe::egui;
|
|
||||||
use egui::Ui;
|
|
||||||
|
|
||||||
/// Render a full-width selectable list item.
|
|
||||||
///
|
|
||||||
/// Unlike `ui.selectable_label()`, this allocates the full available width
|
|
||||||
/// for the clickable area, matching native menu item behavior.
|
|
||||||
pub fn list_item(ui: &mut Ui, selected: bool, label: &str) -> bool {
|
|
||||||
let desired_width = ui.available_width();
|
|
||||||
let height = ui.spacing().interact_size.y;
|
|
||||||
let (rect, response) = ui.allocate_exact_size(
|
|
||||||
egui::vec2(desired_width, height),
|
|
||||||
egui::Sense::click(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if ui.is_rect_visible(rect) {
|
|
||||||
let visuals = ui.visuals();
|
|
||||||
if selected {
|
|
||||||
ui.painter().rect_filled(rect, 2.0, visuals.selection.bg_fill);
|
|
||||||
} else if response.hovered() {
|
|
||||||
ui.painter().rect_filled(rect, 2.0, visuals.widgets.hovered.bg_fill);
|
|
||||||
}
|
|
||||||
|
|
||||||
let text_color = if selected {
|
|
||||||
visuals.selection.stroke.color
|
|
||||||
} else if response.hovered() {
|
|
||||||
visuals.widgets.hovered.text_color()
|
|
||||||
} else {
|
|
||||||
visuals.widgets.inactive.text_color()
|
|
||||||
};
|
|
||||||
|
|
||||||
let text_pos = rect.min + egui::vec2(4.0, (rect.height() - 14.0) / 2.0);
|
|
||||||
ui.painter().text(
|
|
||||||
text_pos,
|
|
||||||
egui::Align2::LEFT_TOP,
|
|
||||||
label,
|
|
||||||
egui::FontId::proportional(14.0),
|
|
||||||
text_color,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
response.clicked()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render a scrollable list of items inside a popup, ensuring full-width
|
|
||||||
/// clickable areas and proper ScrollArea sizing.
|
|
||||||
///
|
|
||||||
/// Returns the index of the clicked item, if any.
|
|
||||||
pub fn scrollable_list<'a>(
|
|
||||||
ui: &mut Ui,
|
|
||||||
max_height: f32,
|
|
||||||
items: impl Iterator<Item = (bool, &'a str)>,
|
|
||||||
) -> Option<usize> {
|
|
||||||
let mut clicked_index = None;
|
|
||||||
|
|
||||||
// Force the ScrollArea to use the full width set by the parent
|
|
||||||
let width = ui.available_width();
|
|
||||||
|
|
||||||
egui::ScrollArea::vertical()
|
|
||||||
.max_height(max_height)
|
|
||||||
.show(ui, |ui| {
|
|
||||||
ui.set_min_width(width);
|
|
||||||
for (i, (selected, label)) in items.enumerate() {
|
|
||||||
if list_item(ui, selected, label) {
|
|
||||||
clicked_index = Some(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
clicked_index
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
//! Reusable UI widgets for the editor
|
//! Reusable UI widgets for the editor
|
||||||
|
|
||||||
mod text_field;
|
mod text_field;
|
||||||
pub mod dropdown_list;
|
|
||||||
|
|
||||||
pub use text_field::ImeTextField;
|
pub use text_field::ImeTextField;
|
||||||
pub use dropdown_list::{list_item, scrollable_list};
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue