fix broken mp3/aac export

This commit is contained in:
Skyler Lehmkuhl 2026-02-16 07:53:23 -05:00
parent 6c88c4a8da
commit e03d12009f
7 changed files with 241 additions and 89 deletions

View File

@ -4,6 +4,10 @@ use super::project::Project;
use crate::command::AudioEvent;
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
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExportFormat {
@ -73,6 +77,21 @@ pub fn export_audio<P: AsRef<Path>>(
mut event_tx: Option<&mut rtrb::Producer<AudioEvent>>,
) -> 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.)
project.reset_all_graphs();
@ -135,9 +154,7 @@ pub fn render_to_memory(
println!("Export: duration={:.3}s, total_frames={}, total_samples={}, channels={}",
duration, total_frames, total_samples, settings.channels);
// Render in chunks to avoid memory issues
const CHUNK_FRAMES: usize = 4096;
let chunk_samples = CHUNK_FRAMES * settings.channels as usize;
let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
// Create buffer for rendering
let mut render_buffer = vec![0.0f32; chunk_samples];
@ -147,7 +164,7 @@ pub fn render_to_memory(
let mut all_samples = Vec::with_capacity(total_samples);
let mut playhead = settings.start_time;
let chunk_duration = CHUNK_FRAMES as f64 / settings.sample_rate as f64;
let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64;
let mut frames_rendered = 0;
// Render the entire timeline in chunks
@ -345,9 +362,8 @@ fn export_mp3<P: AsRef<Path>>(
let duration = settings.end_time - settings.start_time;
let total_frames = (duration * settings.sample_rate as f64).round() as usize;
const CHUNK_FRAMES: usize = 4096;
let chunk_samples = CHUNK_FRAMES * settings.channels as usize;
let chunk_duration = CHUNK_FRAMES as f64 / settings.sample_rate as f64;
let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64;
// Create buffers for rendering
let mut render_buffer = vec![0.0f32; chunk_samples];
@ -513,9 +529,8 @@ fn export_aac<P: AsRef<Path>>(
let duration = settings.end_time - settings.start_time;
let total_frames = (duration * settings.sample_rate as f64).round() as usize;
const CHUNK_FRAMES: usize = 4096;
let chunk_samples = CHUNK_FRAMES * settings.channels as usize;
let chunk_duration = CHUNK_FRAMES as f64 / settings.sample_rate as f64;
let chunk_samples = EXPORT_CHUNK_FRAMES * settings.channels as usize;
let chunk_duration = EXPORT_CHUNK_FRAMES as f64 / settings.sample_rate as f64;
// Create buffers for rendering
let mut render_buffer = vec![0.0f32; chunk_samples];
@ -669,9 +684,13 @@ fn encode_complete_frame_mp3(
channel_layout: ffmpeg_next::channel_layout::ChannelLayout,
pts: i64,
) -> Result<(), String> {
if num_frames == 0 {
return Ok(());
}
let channels = planar_samples.len();
// Create audio frame with exact size
// Create audio frame
let mut frame = ffmpeg_next::frame::Audio::new(
ffmpeg_next::format::Sample::I16(ffmpeg_next::format::sample::Type::Planar),
num_frames,
@ -680,33 +699,23 @@ fn encode_complete_frame_mp3(
frame.set_rate(sample_rate);
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
// 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 {
let plane = frame.data_mut(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
));
let plane = frame.plane_mut::<i16>(ch);
plane.copy_from_slice(&planar_samples[ch]);
}
// 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)
.map_err(|e| format!("Failed to send frame: {}", e))?;
// Receive and write packets
receive_and_write_packets(encoder, output)?;
Ok(())
@ -722,9 +731,13 @@ fn encode_complete_frame_aac(
channel_layout: ffmpeg_next::channel_layout::ChannelLayout,
pts: i64,
) -> Result<(), String> {
if num_frames == 0 {
return Ok(());
}
let channels = planar_samples.len();
// Create audio frame with exact size
// Create audio frame
let mut frame = ffmpeg_next::frame::Audio::new(
ffmpeg_next::format::Sample::F32(ffmpeg_next::format::sample::Type::Planar),
num_frames,
@ -733,33 +746,23 @@ fn encode_complete_frame_aac(
frame.set_rate(sample_rate);
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
// 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 {
let plane = frame.data_mut(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
));
let plane = frame.plane_mut::<f32>(ch);
plane.copy_from_slice(&planar_samples[ch]);
}
// 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)
.map_err(|e| format!("Failed to send frame: {}", e))?;
// Receive and write packets
receive_and_write_packets(encoder, output)?;
Ok(())

View File

@ -124,6 +124,54 @@ impl Action for SetLayerPropertiesAction {
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 {
let property_name = match &self.property {
LayerProperty::Volume(_) => "volume",

View File

@ -196,26 +196,13 @@ fn export_audio_ffmpeg_mp3<P: AsRef<Path>>(
frame.set_rate(settings.sample_rate);
// 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 {
let plane = frame.data_mut(ch);
let plane = frame.plane_mut::<i16>(ch);
let offset = samples_encoded;
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);
}
plane.copy_from_slice(&planar_samples[ch][offset..offset + chunk_size]);
}
// Send frame to encoder

View File

@ -88,21 +88,19 @@ impl ExportDialog {
let mut should_export = false;
let mut should_close = false;
let mut open = self.open;
let window_title = match self.export_type {
ExportType::Audio => "Export Audio",
ExportType::Video => "Export Video",
};
egui::Window::new(window_title)
.open(&mut open)
.resizable(false)
.collapsible(false)
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
let modal_response = egui::Modal::new(egui::Id::new("export_dialog_modal"))
.show(ctx, |ui| {
ui.set_width(500.0);
ui.heading(window_title);
ui.add_space(8.0);
// Error message (if any)
if let Some(error) = &self.error_message {
ui.colored_label(egui::Color32::RED, error);
@ -151,8 +149,10 @@ impl ExportDialog {
});
});
// Update open state (in case user clicked X button)
self.open = open;
// Close on backdrop click or escape
if modal_response.backdrop_response.clicked() {
should_close = true;
}
if should_close {
self.close();
@ -529,14 +529,13 @@ impl ExportProgressDialog {
let mut should_cancel = false;
egui::Window::new("Exporting...")
.open(&mut self.open)
.resizable(false)
.collapsible(false)
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
egui::Modal::new(egui::Id::new("export_progress_modal"))
.show(ctx, |ui| {
ui.set_width(400.0);
ui.heading("Exporting...");
ui.add_space(8.0);
// Status message
ui.label(&self.message);
ui.add_space(8.0);

View File

@ -479,9 +479,11 @@ impl ExportOrchestrator {
) {
println!("🧵 [EXPORT THREAD] run_audio_export started");
// Send start notification
// Send start notification with calculated total frames
let duration = settings.end_time - settings.start_time;
let total_frames = (duration * settings.sample_rate as f64).round() as usize;
progress_tx
.send(ExportProgress::Started { total_frames: 0 })
.send(ExportProgress::Started { total_frames })
.ok();
println!("🧵 [EXPORT THREAD] Sent Started progress");

View File

@ -2751,6 +2751,41 @@ impl EditorApp {
}
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
self.playback_time = 0.0;
self.is_playing = false;

View File

@ -148,6 +148,70 @@ impl VirtualPianoPane {
(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(&note) {
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(&note) {
egui::Color32::from_rgb(50, 100, 200)
} else {
egui::Color32::BLACK
};
ui.painter().rect_filled(key_rect, 2.0, color);
}
}
/// Render the piano keyboard
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
@ -158,6 +222,20 @@ impl VirtualPianoPane {
let black_key_width = white_key_width * self.black_key_width_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
let mut white_key_positions: std::collections::HashMap<u8, f32> = std::collections::HashMap::new();
let mut white_count = 0;