From 2cd768239904ec3814a4c324184d15e117f74e85 Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Thu, 4 Dec 2025 15:58:37 -0500 Subject: [PATCH] mp3 and aac export --- daw-backend/Cargo.toml | 3 +- daw-backend/src/audio/export.rs | 341 ++++++++++++++- .../lightningbeam-core/Cargo.toml | 2 +- .../lightningbeam-editor/Cargo.toml | 2 +- .../examples/ffmpeg_test.rs | 413 ++++++++++++++++++ .../src/export/audio_exporter.rs | 355 ++++++++++++++- .../lightningbeam-editor/src/export/mod.rs | 120 ++--- 7 files changed, 1145 insertions(+), 91 deletions(-) create mode 100644 lightningbeam-ui/lightningbeam-editor/examples/ffmpeg_test.rs diff --git a/daw-backend/Cargo.toml b/daw-backend/Cargo.toml index 4482a83..951acc0 100644 --- a/daw-backend/Cargo.toml +++ b/daw-backend/Cargo.toml @@ -18,8 +18,7 @@ pathdiff = "0.2" # Audio export hound = "3.5" -# TODO: Add MP3 support with a different crate -# mp3lame-encoder API is too complex, need to find a better option +ffmpeg-next = "8.0" # For MP3/AAC encoding # Node-based audio graph dependencies dasp_graph = "0.11" diff --git a/daw-backend/src/audio/export.rs b/daw-backend/src/audio/export.rs index 63a1f7e..04694ef 100644 --- a/daw-backend/src/audio/export.rs +++ b/daw-backend/src/audio/export.rs @@ -10,7 +10,8 @@ use std::path::Path; pub enum ExportFormat { Wav, Flac, - // TODO: Add MP3 support + Mp3, + Aac, } impl ExportFormat { @@ -19,6 +20,8 @@ impl ExportFormat { match self { ExportFormat::Wav => "wav", ExportFormat::Flac => "flac", + ExportFormat::Mp3 => "mp3", + ExportFormat::Aac => "m4a", } } } @@ -72,13 +75,23 @@ pub fn export_audio>( mut event_tx: Option<&mut rtrb::Producer>, ) -> Result<(), String> { - // Render the project to memory - let samples = render_to_memory(project, pool, midi_pool, settings, event_tx)?; - - // Write to file based on format + // Route to appropriate export implementation based on format match settings.format { - ExportFormat::Wav => write_wav(&samples, settings, output_path)?, - ExportFormat::Flac => write_flac(&samples, settings, output_path)?, + ExportFormat::Wav | ExportFormat::Flac => { + // Render to memory then write (existing path) + let samples = render_to_memory(project, pool, midi_pool, settings, event_tx)?; + match settings.format { + ExportFormat::Wav => write_wav(&samples, settings, output_path)?, + ExportFormat::Flac => write_flac(&samples, settings, output_path)?, + _ => unreachable!(), + } + } + ExportFormat::Mp3 => { + export_mp3(project, pool, midi_pool, settings, output_path, event_tx)?; + } + ExportFormat::Aac => { + export_aac(project, pool, midi_pool, settings, output_path, event_tx)?; + } } Ok(()) @@ -270,7 +283,319 @@ fn write_flac>( Ok(()) } -// TODO: Add MP3 export support with a better library +/// Export audio as MP3 using FFmpeg +fn export_mp3>( + project: &mut Project, + pool: &AudioPool, + midi_pool: &MidiClipPool, + settings: &ExportSettings, + output_path: P, + mut event_tx: Option<&mut rtrb::Producer>, +) -> Result<(), String> { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + // FFmpeg encoding doesn't support cancellation in this implementation + let cancel_flag = Arc::new(AtomicBool::new(false)); + + // Initialize FFmpeg + ffmpeg_next::init().map_err(|e| format!("Failed to initialize FFmpeg: {}", e))?; + + // Step 1: Render audio to memory + let pcm_samples = render_to_memory(project, pool, midi_pool, settings, event_tx)?; + + // Check for cancellation + if cancel_flag.load(Ordering::Relaxed) { + return Err("Export cancelled".to_string()); + } + + // Step 2: Set up FFmpeg encoder + let encoder_codec = ffmpeg_next::encoder::find(ffmpeg_next::codec::Id::MP3) + .ok_or("MP3 encoder (libmp3lame) not found")?; + + // Create output file + let mut output = ffmpeg_next::format::output(&output_path) + .map_err(|e| format!("Failed to create output file: {}", e))?; + + // Create encoder + let mut encoder = ffmpeg_next::codec::Context::new_with_codec(encoder_codec) + .encoder() + .audio() + .map_err(|e| format!("Failed to create encoder: {}", e))?; + + // Configure encoder + let channel_layout = match settings.channels { + 1 => ffmpeg_next::channel_layout::ChannelLayout::MONO, + 2 => ffmpeg_next::channel_layout::ChannelLayout::STEREO, + _ => return Err(format!("Unsupported channel count: {}", settings.channels)), + }; + + encoder.set_rate(settings.sample_rate as i32); + encoder.set_channel_layout(channel_layout); + encoder.set_format(ffmpeg_next::format::Sample::I16(ffmpeg_next::format::sample::Type::Planar)); + encoder.set_bit_rate((settings.mp3_bitrate * 1000) as usize); + encoder.set_time_base(ffmpeg_next::Rational(1, settings.sample_rate as i32)); + + // Open encoder + let mut encoder = encoder.open_as(encoder_codec) + .map_err(|e| format!("Failed to open MP3 encoder: {}", e))?; + + // Add stream and set parameters + { + let mut stream = output.add_stream(encoder_codec) + .map_err(|e| format!("Failed to add stream: {}", e))?; + stream.set_parameters(&encoder); + } + + // Write header + output.write_header() + .map_err(|e| format!("Failed to write header: {}", e))?; + + // Step 3: Encode frames and write to output + let num_frames = pcm_samples.len() / settings.channels as usize; + let planar_samples = convert_to_planar_i16(&pcm_samples, settings.channels); + + // Get encoder frame size + let frame_size = encoder.frame_size(); + let samples_per_frame = if frame_size > 0 { + frame_size as usize + } else { + 1152 // Default MP3 frame size + }; + + // Encode in chunks + let mut samples_encoded = 0; + while samples_encoded < num_frames { + if cancel_flag.load(Ordering::Relaxed) { + return Err("Export cancelled".to_string()); + } + + let samples_remaining = num_frames - samples_encoded; + let chunk_size = samples_remaining.min(samples_per_frame); + + // Create audio frame + let mut frame = ffmpeg_next::frame::Audio::new( + ffmpeg_next::format::Sample::I16(ffmpeg_next::format::sample::Type::Planar), + chunk_size, + channel_layout, + ); + frame.set_rate(settings.sample_rate); + + // Copy planar samples to frame + unsafe { + for ch in 0..settings.channels as usize { + let plane = frame.data_mut(ch); + let offset = samples_encoded; + let src = &planar_samples[ch][offset..offset + chunk_size]; + + std::ptr::copy_nonoverlapping( + src.as_ptr() as *const u8, + plane.as_mut_ptr(), + chunk_size * std::mem::size_of::(), + ); + } + } + + // 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(&mut encoder, &mut output)?; + + samples_encoded += chunk_size; + } + + // Flush encoder + encoder.send_eof() + .map_err(|e| format!("Failed to send EOF: {}", e))?; + receive_and_write_packets(&mut encoder, &mut output)?; + + // Write trailer + output.write_trailer() + .map_err(|e| format!("Failed to write trailer: {}", e))?; + + Ok(()) +} + +/// Export audio as AAC using FFmpeg +fn export_aac>( + project: &mut Project, + pool: &AudioPool, + midi_pool: &MidiClipPool, + settings: &ExportSettings, + output_path: P, + mut event_tx: Option<&mut rtrb::Producer>, +) -> Result<(), String> { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + let cancel_flag = Arc::new(AtomicBool::new(false)); + + // Initialize FFmpeg + ffmpeg_next::init().map_err(|e| format!("Failed to initialize FFmpeg: {}", e))?; + + // Step 1: Render audio to memory + let pcm_samples = render_to_memory(project, pool, midi_pool, settings, event_tx)?; + + // Check for cancellation + if cancel_flag.load(Ordering::Relaxed) { + return Err("Export cancelled".to_string()); + } + + // Step 2: Set up FFmpeg encoder + let encoder_codec = ffmpeg_next::encoder::find(ffmpeg_next::codec::Id::AAC) + .ok_or("AAC encoder not found")?; + + // Create output file + let mut output = ffmpeg_next::format::output(&output_path) + .map_err(|e| format!("Failed to create output file: {}", e))?; + + // Create encoder + let mut encoder = ffmpeg_next::codec::Context::new_with_codec(encoder_codec) + .encoder() + .audio() + .map_err(|e| format!("Failed to create encoder: {}", e))?; + + // Configure encoder + let channel_layout = match settings.channels { + 1 => ffmpeg_next::channel_layout::ChannelLayout::MONO, + 2 => ffmpeg_next::channel_layout::ChannelLayout::STEREO, + _ => return Err(format!("Unsupported channel count: {}", settings.channels)), + }; + + encoder.set_rate(settings.sample_rate as i32); + encoder.set_channel_layout(channel_layout); + encoder.set_format(ffmpeg_next::format::Sample::F32(ffmpeg_next::format::sample::Type::Planar)); + encoder.set_bit_rate((settings.mp3_bitrate * 1000) as usize); + encoder.set_time_base(ffmpeg_next::Rational(1, settings.sample_rate as i32)); + + // Open encoder + let mut encoder = encoder.open_as(encoder_codec) + .map_err(|e| format!("Failed to open AAC encoder: {}", e))?; + + // Add stream and set parameters + { + let mut stream = output.add_stream(encoder_codec) + .map_err(|e| format!("Failed to add stream: {}", e))?; + stream.set_parameters(&encoder); + } + + // Write header + output.write_header() + .map_err(|e| format!("Failed to write header: {}", e))?; + + // Step 3: Encode frames and write to output + let num_frames = pcm_samples.len() / settings.channels as usize; + let planar_samples = convert_to_planar_f32(&pcm_samples, settings.channels); + + // Get encoder frame size + let frame_size = encoder.frame_size(); + let samples_per_frame = if frame_size > 0 { + frame_size as usize + } else { + 1024 // Default AAC frame size + }; + + // Encode in chunks + let mut samples_encoded = 0; + while samples_encoded < num_frames { + if cancel_flag.load(Ordering::Relaxed) { + return Err("Export cancelled".to_string()); + } + + let samples_remaining = num_frames - samples_encoded; + let chunk_size = samples_remaining.min(samples_per_frame); + + // Create audio frame + let mut frame = ffmpeg_next::frame::Audio::new( + ffmpeg_next::format::Sample::F32(ffmpeg_next::format::sample::Type::Planar), + chunk_size, + channel_layout, + ); + frame.set_rate(settings.sample_rate); + + // Copy planar samples to frame + unsafe { + for ch in 0..settings.channels as usize { + let plane = frame.data_mut(ch); + let offset = samples_encoded; + let src = &planar_samples[ch][offset..offset + chunk_size]; + + std::ptr::copy_nonoverlapping( + src.as_ptr() as *const u8, + plane.as_mut_ptr(), + chunk_size * std::mem::size_of::(), + ); + } + } + + // 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(&mut encoder, &mut output)?; + + samples_encoded += chunk_size; + } + + // Flush encoder + encoder.send_eof() + .map_err(|e| format!("Failed to send EOF: {}", e))?; + receive_and_write_packets(&mut encoder, &mut output)?; + + // Write trailer + output.write_trailer() + .map_err(|e| format!("Failed to write trailer: {}", e))?; + + Ok(()) +} + +/// Convert interleaved f32 samples to planar i16 format +fn convert_to_planar_i16(interleaved: &[f32], channels: u32) -> Vec> { + let num_frames = interleaved.len() / channels as usize; + let mut planar = vec![vec![0i16; num_frames]; channels as usize]; + + for (i, chunk) in interleaved.chunks(channels as usize).enumerate() { + for (ch, &sample) in chunk.iter().enumerate() { + let clamped = sample.max(-1.0).min(1.0); + planar[ch][i] = (clamped * 32767.0) as i16; + } + } + + planar +} + +/// Convert interleaved f32 samples to planar f32 format +fn convert_to_planar_f32(interleaved: &[f32], channels: u32) -> Vec> { + let num_frames = interleaved.len() / channels as usize; + let mut planar = vec![vec![0.0f32; num_frames]; channels as usize]; + + for (i, chunk) in interleaved.chunks(channels as usize).enumerate() { + for (ch, &sample) in chunk.iter().enumerate() { + planar[ch][i] = sample; + } + } + + planar +} + +/// Receive encoded packets and write to output +fn receive_and_write_packets( + encoder: &mut ffmpeg_next::encoder::Audio, + output: &mut ffmpeg_next::format::context::Output, +) -> Result<(), String> { + let mut encoded = ffmpeg_next::Packet::empty(); + + while encoder.receive_packet(&mut encoded).is_ok() { + encoded.set_stream(0); + encoded.write_interleaved(output) + .map_err(|e| format!("Failed to write packet: {}", e))?; + } + + Ok(()) +} #[cfg(test)] mod tests { diff --git a/lightningbeam-ui/lightningbeam-core/Cargo.toml b/lightningbeam-ui/lightningbeam-core/Cargo.toml index bfc4889..e4f0d04 100644 --- a/lightningbeam-ui/lightningbeam-core/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-core/Cargo.toml @@ -24,7 +24,7 @@ uuid = { version = "1.0", features = ["v4", "serde"] } daw-backend = { path = "../../daw-backend" } # Video decoding -ffmpeg-next = "7.0" +ffmpeg-next = "8.0" lru = "0.12" # File I/O diff --git a/lightningbeam-ui/lightningbeam-editor/Cargo.toml b/lightningbeam-ui/lightningbeam-editor/Cargo.toml index c404d33..b8452c8 100644 --- a/lightningbeam-ui/lightningbeam-editor/Cargo.toml +++ b/lightningbeam-ui/lightningbeam-editor/Cargo.toml @@ -8,7 +8,7 @@ lightningbeam-core = { path = "../lightningbeam-core" } daw-backend = { path = "../../daw-backend" } rtrb = "0.3" cpal = "0.15" -ffmpeg-next = "7.0" +ffmpeg-next = "8.0" # UI Framework eframe = { workspace = true } diff --git a/lightningbeam-ui/lightningbeam-editor/examples/ffmpeg_test.rs b/lightningbeam-ui/lightningbeam-editor/examples/ffmpeg_test.rs new file mode 100644 index 0000000..8eec46f --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/examples/ffmpeg_test.rs @@ -0,0 +1,413 @@ +/// Minimal test program to validate FFmpeg audio encoding workflow +/// +/// This program tests encoding raw PCM samples to MP3 using ffmpeg-next. +/// Run with: cargo run --example ffmpeg_test + +use std::path::Path; + +fn main() -> Result<(), String> { + println!("Testing FFmpeg audio encoding..."); + + // Initialize FFmpeg + ffmpeg_next::init().map_err(|e| format!("Failed to initialize FFmpeg: {}", e))?; + + // Test 1: List available encoders + println!("\nAvailable MP3 encoders:"); + if let Some(encoder) = ffmpeg_next::encoder::find(ffmpeg_next::codec::Id::MP3) { + println!(" - Found MP3 encoder: {}", encoder.name()); + } else { + println!(" - No MP3 encoder found!"); + } + + println!("\nAvailable AAC encoders:"); + if let Some(encoder) = ffmpeg_next::encoder::find(ffmpeg_next::codec::Id::AAC) { + println!(" - Found AAC encoder: {}", encoder.name()); + } else { + println!(" - No AAC encoder found!"); + } + + // Test 2: Create a simple MP3 encoder and encode silence + test_mp3_encoding()?; + + // Test 3: Create a simple AAC encoder and encode silence + test_aac_encoding()?; + + println!("\n✅ All tests passed!"); + Ok(()) +} + +fn test_mp3_encoding() -> Result<(), String> { + println!("\nTest: Encoding 1 second of silence to MP3..."); + + // Output file + let output_path = "/tmp/test_silence.mp3"; + + // Generate 1 second of stereo silence at 44.1 kHz + let sample_rate = 44100; + let channels = 2; + let duration_secs = 1.0; + let num_samples = (sample_rate as f64 * duration_secs * channels as f64) as usize; + let pcm_samples: Vec = vec![0.0; num_samples]; // Silence + + println!(" Generated {} PCM samples ({}Hz, {} channels, {:.1}s)", + num_samples, sample_rate, channels, duration_secs); + + // Encode to MP3 + encode_pcm_to_mp3(&pcm_samples, sample_rate, channels, 320, output_path)?; + + // Check output file exists + if Path::new(output_path).exists() { + let metadata = std::fs::metadata(output_path).unwrap(); + println!(" ✅ Created MP3 file: {} ({} bytes)", output_path, metadata.len()); + } else { + return Err("MP3 file was not created!".to_string()); + } + + Ok(()) +} + +fn test_aac_encoding() -> Result<(), String> { + println!("\nTest: Encoding 1 second of silence to AAC..."); + + // Output file + let output_path = "/tmp/test_silence.m4a"; + + // Generate 1 second of stereo silence at 44.1 kHz + let sample_rate = 44100; + let channels = 2; + let duration_secs = 1.0; + let num_samples = (sample_rate as f64 * duration_secs * channels as f64) as usize; + let pcm_samples: Vec = vec![0.0; num_samples]; // Silence + + println!(" Generated {} PCM samples ({}Hz, {} channels, {:.1}s)", + num_samples, sample_rate, channels, duration_secs); + + // Encode to AAC + encode_pcm_to_aac(&pcm_samples, sample_rate, channels, 192, output_path)?; + + // Check output file exists + if Path::new(output_path).exists() { + let metadata = std::fs::metadata(output_path).unwrap(); + println!(" ✅ Created AAC file: {} ({} bytes)", output_path, metadata.len()); + } else { + return Err("AAC file was not created!".to_string()); + } + + Ok(()) +} + +/// Encode raw PCM samples to MP3 using ffmpeg-next +fn encode_pcm_to_mp3( + samples: &[f32], + sample_rate: u32, + channels: u32, + bitrate_kbps: u32, + output_path: &str, +) -> Result<(), String> { + use ffmpeg_next as ffmpeg; + + // Find MP3 encoder + let encoder_codec = ffmpeg::encoder::find(ffmpeg::codec::Id::MP3) + .ok_or("MP3 encoder not found")?; + + println!(" Using encoder: {}", encoder_codec.name()); + + // Create output format context FIRST (like transcode example) + let mut output = ffmpeg::format::output(&output_path) + .map_err(|e| format!("Failed to create output file: {}", e))?; + + // Don't use stream parameters - create encoder directly + // The stream was just added but has no parameters set yet + let mut encoder = ffmpeg::codec::Context::new_with_codec(encoder_codec) + .encoder() + .audio() + .map_err(|e| format!("Failed to create encoder: {}", e))?; + + println!(" Created encoder directly from codec"); + + // Determine channel layout first + let channel_layout = match channels { + 1 => ffmpeg::channel_layout::ChannelLayout::MONO, + 2 => ffmpeg::channel_layout::ChannelLayout::STEREO, + _ => return Err(format!("Unsupported channel count: {}", channels)), + }; + + // Configure encoder with explicit format (required in ffmpeg-next 8.0) + encoder.set_rate(sample_rate as i32); + encoder.set_channel_layout(channel_layout); + + // Set format to S16 Planar (s16p) which libmp3lame supports + use ffmpeg_next::format::sample::Type; + use ffmpeg_next::format::Sample; + encoder.set_format(Sample::I16(Type::Planar)); + + encoder.set_bit_rate((bitrate_kbps * 1000) as usize); + encoder.set_time_base(ffmpeg::Rational(1, sample_rate as i32)); + + println!(" Encoder configured: {}Hz, {} channels, {} kbps", + sample_rate, channels, bitrate_kbps); + println!(" Format before open: {:?}", encoder.format()); + + // Open encoder (like transcode-audio example) + let mut encoder = encoder.open_as(encoder_codec) + .map_err(|e| format!("Failed to open encoder: {}", e))?; + + println!(" ✅ Encoder opened successfully!"); + println!(" Opened encoder format: {:?}", encoder.format()); + + // Now add stream and set its parameters from the opened encoder + let mut stream = output.add_stream(encoder_codec) + .map_err(|e| format!("Failed to add stream: {}", e))?; + stream.set_parameters(&encoder); + + // Write header + output.write_header() + .map_err(|e| format!("Failed to write header: {}", e))?; + + println!(" Encoding {} samples...", samples.len()); + + // Convert interleaved f32 to planar i16 + let num_frames = samples.len() / channels as usize; + let planar_samples = convert_to_planar_i16(samples, channels); + + // Get encoder frame size + let frame_size = encoder.frame_size(); + let samples_per_frame = if frame_size > 0 { + frame_size as usize + } else { + 1152 // Default MP3 frame size + }; + + println!(" Frame size: {} samples", samples_per_frame); + + // Encode in chunks + let mut samples_encoded = 0; + while samples_encoded < num_frames { + let samples_remaining = num_frames - samples_encoded; + let chunk_size = samples_remaining.min(samples_per_frame); + + // Create audio frame + let mut frame = ffmpeg::frame::Audio::new( + ffmpeg::format::Sample::I16(ffmpeg::format::sample::Type::Planar), + chunk_size, + channel_layout, + ); + frame.set_rate(sample_rate); + + // Copy planar samples to frame + unsafe { + for ch in 0..channels as usize { + let plane = frame.data_mut(ch); + let offset = samples_encoded; + let src = &planar_samples[ch][offset..offset + chunk_size]; + + std::ptr::copy_nonoverlapping( + src.as_ptr() as *const u8, + plane.as_mut_ptr(), + chunk_size * std::mem::size_of::(), + ); + } + } + + // 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(&mut encoder, &mut output)?; + + samples_encoded += chunk_size; + } + + // Flush encoder + encoder.send_eof() + .map_err(|e| format!("Failed to send EOF: {}", e))?; + receive_and_write_packets(&mut encoder, &mut output)?; + + // Write trailer + output.write_trailer() + .map_err(|e| format!("Failed to write trailer: {}", e))?; + + println!(" Encoding complete - {} frames encoded", num_frames); + + Ok(()) +} + +/// Convert interleaved f32 samples to planar i16 format +fn convert_to_planar_i16(interleaved: &[f32], channels: u32) -> Vec> { + let num_frames = interleaved.len() / channels as usize; + let mut planar = vec![vec![0i16; num_frames]; channels as usize]; + + for (i, chunk) in interleaved.chunks(channels as usize).enumerate() { + for (ch, &sample) in chunk.iter().enumerate() { + // Clamp and convert f32 (-1.0 to 1.0) to i16 + let clamped = sample.max(-1.0).min(1.0); + planar[ch][i] = (clamped * 32767.0) as i16; + } + } + + planar +} + +/// Receive encoded packets and write to output +fn receive_and_write_packets( + encoder: &mut ffmpeg_next::encoder::Audio, + output: &mut ffmpeg_next::format::context::Output, +) -> Result<(), String> { + let mut encoded = ffmpeg_next::Packet::empty(); + + while encoder.receive_packet(&mut encoded).is_ok() { + encoded.set_stream(0); + encoded.write_interleaved(output) + .map_err(|e| format!("Failed to write packet: {}", e))?; + } + + Ok(()) +} + +/// Encode raw PCM samples to AAC using ffmpeg-next +fn encode_pcm_to_aac( + samples: &[f32], + sample_rate: u32, + channels: u32, + bitrate_kbps: u32, + output_path: &str, +) -> Result<(), String> { + use ffmpeg_next as ffmpeg; + + // Find AAC encoder + let encoder_codec = ffmpeg::encoder::find(ffmpeg::codec::Id::AAC) + .ok_or("AAC encoder not found")?; + + println!(" Using encoder: {}", encoder_codec.name()); + + // Create output format context + let mut output = ffmpeg::format::output(&output_path) + .map_err(|e| format!("Failed to create output file: {}", e))?; + + // Create encoder directly from codec + let mut encoder = ffmpeg::codec::Context::new_with_codec(encoder_codec) + .encoder() + .audio() + .map_err(|e| format!("Failed to create encoder: {}", e))?; + + println!(" Created encoder directly from codec"); + + // Determine channel layout + let channel_layout = match channels { + 1 => ffmpeg::channel_layout::ChannelLayout::MONO, + 2 => ffmpeg::channel_layout::ChannelLayout::STEREO, + _ => return Err(format!("Unsupported channel count: {}", channels)), + }; + + // Configure encoder - AAC supports F32 Planar (fltp) + encoder.set_rate(sample_rate as i32); + encoder.set_channel_layout(channel_layout); + encoder.set_format(ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Planar)); + encoder.set_bit_rate((bitrate_kbps * 1000) as usize); + encoder.set_time_base(ffmpeg::Rational(1, sample_rate as i32)); + + println!(" Encoder configured: {}Hz, {} channels, {} kbps", + sample_rate, channels, bitrate_kbps); + println!(" Format before open: {:?}", encoder.format()); + + // Open encoder + let mut encoder = encoder.open_as(encoder_codec) + .map_err(|e| format!("Failed to open encoder: {}", e))?; + + println!(" ✅ Encoder opened successfully!"); + println!(" Opened encoder format: {:?}", encoder.format()); + + // Add stream and set parameters + { + let mut stream = output.add_stream(encoder_codec) + .map_err(|e| format!("Failed to add stream: {}", e))?; + stream.set_parameters(&encoder); + } + + // Write header + output.write_header() + .map_err(|e| format!("Failed to write header: {}", e))?; + + println!(" Encoding {} samples...", samples.len()); + + // Convert interleaved f32 to planar f32 + let num_frames = samples.len() / channels as usize; + let planar_samples = convert_to_planar_f32(samples, channels); + + // Get encoder frame size + let frame_size = encoder.frame_size(); + let samples_per_frame = if frame_size > 0 { + frame_size as usize + } else { + 1024 // Default AAC frame size + }; + + println!(" Frame size: {} samples", samples_per_frame); + + // Encode in chunks + let mut samples_encoded = 0; + while samples_encoded < num_frames { + let samples_remaining = num_frames - samples_encoded; + let chunk_size = samples_remaining.min(samples_per_frame); + + // Create audio frame + let mut frame = ffmpeg::frame::Audio::new( + ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Planar), + chunk_size, + channel_layout, + ); + frame.set_rate(sample_rate); + + // Copy planar samples to frame + unsafe { + for ch in 0..channels as usize { + let plane = frame.data_mut(ch); + let offset = samples_encoded; + let src = &planar_samples[ch][offset..offset + chunk_size]; + + std::ptr::copy_nonoverlapping( + src.as_ptr() as *const u8, + plane.as_mut_ptr(), + chunk_size * std::mem::size_of::(), + ); + } + } + + // 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(&mut encoder, &mut output)?; + + samples_encoded += chunk_size; + } + + // Flush encoder + encoder.send_eof() + .map_err(|e| format!("Failed to send EOF: {}", e))?; + receive_and_write_packets(&mut encoder, &mut output)?; + + // Write trailer + output.write_trailer() + .map_err(|e| format!("Failed to write trailer: {}", e))?; + + println!(" Encoding complete - {} frames encoded", num_frames); + + Ok(()) +} + +/// Convert interleaved f32 samples to planar f32 format +fn convert_to_planar_f32(interleaved: &[f32], channels: u32) -> Vec> { + let num_frames = interleaved.len() / channels as usize; + let mut planar = vec![vec![0.0f32; num_frames]; channels as usize]; + + for (i, chunk) in interleaved.chunks(channels as usize).enumerate() { + for (ch, &sample) in chunk.iter().enumerate() { + planar[ch][i] = sample; + } + } + + planar +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/audio_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/audio_exporter.rs index 828195e..a8ca034 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/audio_exporter.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/audio_exporter.rs @@ -86,32 +86,347 @@ fn export_audio_daw_backend>( /// Export audio as MP3 using FFmpeg fn export_audio_ffmpeg_mp3>( - _project: &mut Project, - _pool: &AudioPool, - _midi_pool: &MidiClipPool, - _settings: &AudioExportSettings, - _output_path: P, - _cancel_flag: &Arc, + project: &mut Project, + pool: &AudioPool, + midi_pool: &MidiClipPool, + settings: &AudioExportSettings, + output_path: P, + cancel_flag: &Arc, ) -> Result<(), String> { - // TODO: Implement MP3 export using FFmpeg - // The FFmpeg encoder API is complex and needs more investigation - // For now, users can export as WAV or FLAC (both fully working) - Err("MP3 export is not yet implemented. Please use WAV or FLAC format for now, or export as WAV and convert using an external tool.".to_string()) + use ffmpeg_next as ffmpeg; + + // Initialize FFmpeg + ffmpeg::init().map_err(|e| format!("Failed to initialize FFmpeg: {}", e))?; + + // Convert settings to DAW backend format + let daw_settings = DawExportSettings { + format: ExportFormat::Wav, // Unused, but required + sample_rate: settings.sample_rate, + channels: settings.channels, + bit_depth: 16, // Unused + mp3_bitrate: settings.bitrate_kbps, + start_time: settings.start_time, + end_time: settings.end_time, + }; + + // Step 1: Render audio to memory + let pcm_samples = render_to_memory( + project, + pool, + midi_pool, + &daw_settings, + None, // No progress events for now + )?; + + // Check for cancellation + if cancel_flag.load(Ordering::Relaxed) { + return Err("Export cancelled".to_string()); + } + + // Step 2: Set up FFmpeg encoder + let encoder_codec = ffmpeg::encoder::find(ffmpeg::codec::Id::MP3) + .ok_or("MP3 encoder (libmp3lame) not found")?; + + // Create output file + let mut output = ffmpeg::format::output(&output_path) + .map_err(|e| format!("Failed to create output file: {}", e))?; + + // Create encoder + let mut encoder = ffmpeg::codec::Context::new_with_codec(encoder_codec) + .encoder() + .audio() + .map_err(|e| format!("Failed to create encoder: {}", e))?; + + // Configure encoder + let channel_layout = match settings.channels { + 1 => ffmpeg::channel_layout::ChannelLayout::MONO, + 2 => ffmpeg::channel_layout::ChannelLayout::STEREO, + _ => return Err(format!("Unsupported channel count: {}", settings.channels)), + }; + + encoder.set_rate(settings.sample_rate as i32); + encoder.set_channel_layout(channel_layout); + encoder.set_format(ffmpeg::format::Sample::I16(ffmpeg::format::sample::Type::Planar)); + encoder.set_bit_rate((settings.bitrate_kbps * 1000) as usize); + encoder.set_time_base(ffmpeg::Rational(1, settings.sample_rate as i32)); + + // Open encoder + let mut encoder = encoder.open_as(encoder_codec) + .map_err(|e| format!("Failed to open MP3 encoder: {}", e))?; + + // Add stream and set parameters + { + let mut stream = output.add_stream(encoder_codec) + .map_err(|e| format!("Failed to add stream: {}", e))?; + stream.set_parameters(&encoder); + } // Drop stream here to release the borrow + + // Write header + output.write_header() + .map_err(|e| format!("Failed to write header: {}", e))?; + + // Step 3: Encode frames and write to output + // Convert interleaved f32 samples to planar i16 format + let num_frames = pcm_samples.len() / settings.channels as usize; + let mut planar_samples = convert_to_planar_i16(&pcm_samples, settings.channels); + + // Get encoder frame size + let frame_size = encoder.frame_size(); + let samples_per_frame = if frame_size > 0 { + frame_size as usize + } else { + 1152 // Default MP3 frame size + }; + + // Encode in chunks + let mut samples_encoded = 0; + while samples_encoded < num_frames { + if cancel_flag.load(Ordering::Relaxed) { + return Err("Export cancelled".to_string()); + } + + let samples_remaining = num_frames - samples_encoded; + let chunk_size = samples_remaining.min(samples_per_frame); + + // Create audio frame + let mut frame = ffmpeg::frame::Audio::new( + ffmpeg::format::Sample::I16(ffmpeg::format::sample::Type::Planar), + chunk_size, + channel_layout, + ); + frame.set_rate(settings.sample_rate); + + // Copy planar samples to frame + unsafe { + for ch in 0..settings.channels as usize { + let plane = frame.data_mut(ch); + let offset = samples_encoded; + let src = &planar_samples[ch][offset..offset + chunk_size]; + + std::ptr::copy_nonoverlapping( + src.as_ptr() as *const u8, + plane.as_mut_ptr(), + chunk_size * std::mem::size_of::(), + ); + } + } + + // 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(&mut encoder, &mut output)?; + + samples_encoded += chunk_size; + } + + // Flush encoder + encoder.send_eof() + .map_err(|e| format!("Failed to send EOF: {}", e))?; + receive_and_write_packets(&mut encoder, &mut output)?; + + // Write trailer + output.write_trailer() + .map_err(|e| format!("Failed to write trailer: {}", e))?; + + Ok(()) +} + +/// Convert interleaved f32 samples to planar i16 format +fn convert_to_planar_i16(interleaved: &[f32], channels: u32) -> Vec> { + let num_frames = interleaved.len() / channels as usize; + let mut planar = vec![vec![0i16; num_frames]; channels as usize]; + + for (i, chunk) in interleaved.chunks(channels as usize).enumerate() { + for (ch, &sample) in chunk.iter().enumerate() { + // Clamp and convert f32 (-1.0 to 1.0) to i16 + let clamped = sample.max(-1.0).min(1.0); + planar[ch][i] = (clamped * 32767.0) as i16; + } + } + + planar +} + +/// Convert interleaved f32 samples to planar f32 format +fn convert_to_planar_f32(interleaved: &[f32], channels: u32) -> Vec> { + let num_frames = interleaved.len() / channels as usize; + let mut planar = vec![vec![0.0f32; num_frames]; channels as usize]; + + for (i, chunk) in interleaved.chunks(channels as usize).enumerate() { + for (ch, &sample) in chunk.iter().enumerate() { + planar[ch][i] = sample; + } + } + + planar +} + +/// Receive encoded packets and write to output +fn receive_and_write_packets( + encoder: &mut ffmpeg_next::encoder::Audio, + output: &mut ffmpeg_next::format::context::Output, +) -> Result<(), String> { + let mut encoded = ffmpeg_next::Packet::empty(); + + while encoder.receive_packet(&mut encoded).is_ok() { + encoded.set_stream(0); + encoded.write_interleaved(output) + .map_err(|e| format!("Failed to write packet: {}", e))?; + } + + Ok(()) } /// Export audio as AAC using FFmpeg fn export_audio_ffmpeg_aac>( - _project: &mut Project, - _pool: &AudioPool, - _midi_pool: &MidiClipPool, - _settings: &AudioExportSettings, - _output_path: P, - _cancel_flag: &Arc, + project: &mut Project, + pool: &AudioPool, + midi_pool: &MidiClipPool, + settings: &AudioExportSettings, + output_path: P, + cancel_flag: &Arc, ) -> Result<(), String> { - // TODO: Implement AAC export using FFmpeg - // The FFmpeg encoder API is complex and needs more investigation - // For now, users can export as WAV or FLAC (both fully working) - Err("AAC export is not yet implemented. Please use WAV or FLAC format for now, or export as WAV and convert using an external tool.".to_string()) + use ffmpeg_next as ffmpeg; + + // Initialize FFmpeg + ffmpeg::init().map_err(|e| format!("Failed to initialize FFmpeg: {}", e))?; + + // Convert settings to DAW backend format + let daw_settings = DawExportSettings { + format: ExportFormat::Wav, // Unused, but required + sample_rate: settings.sample_rate, + channels: settings.channels, + bit_depth: 16, // Unused + mp3_bitrate: settings.bitrate_kbps, + start_time: settings.start_time, + end_time: settings.end_time, + }; + + // Step 1: Render audio to memory + let pcm_samples = render_to_memory( + project, + pool, + midi_pool, + &daw_settings, + None, // No progress events for now + )?; + + // Check for cancellation + if cancel_flag.load(Ordering::Relaxed) { + return Err("Export cancelled".to_string()); + } + + // Step 2: Set up FFmpeg encoder + let encoder_codec = ffmpeg::encoder::find(ffmpeg::codec::Id::AAC) + .ok_or("AAC encoder not found")?; + + // Create output file + let mut output = ffmpeg::format::output(&output_path) + .map_err(|e| format!("Failed to create output file: {}", e))?; + + // Create encoder + let mut encoder = ffmpeg::codec::Context::new_with_codec(encoder_codec) + .encoder() + .audio() + .map_err(|e| format!("Failed to create encoder: {}", e))?; + + // Configure encoder + let channel_layout = match settings.channels { + 1 => ffmpeg::channel_layout::ChannelLayout::MONO, + 2 => ffmpeg::channel_layout::ChannelLayout::STEREO, + _ => return Err(format!("Unsupported channel count: {}", settings.channels)), + }; + + encoder.set_rate(settings.sample_rate as i32); + encoder.set_channel_layout(channel_layout); + // AAC encoder supports FLTP (F32 Planar) format + encoder.set_format(ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Planar)); + encoder.set_bit_rate((settings.bitrate_kbps * 1000) as usize); + encoder.set_time_base(ffmpeg::Rational(1, settings.sample_rate as i32)); + + // Open encoder + let mut encoder = encoder.open_as(encoder_codec) + .map_err(|e| format!("Failed to open AAC encoder: {}", e))?; + + // Add stream and set parameters + { + let mut stream = output.add_stream(encoder_codec) + .map_err(|e| format!("Failed to add stream: {}", e))?; + stream.set_parameters(&encoder); + } // Drop stream here to release the borrow + + // Write header + output.write_header() + .map_err(|e| format!("Failed to write header: {}", e))?; + + // Step 3: Encode frames and write to output + // Convert interleaved f32 samples to planar f32 format (no conversion needed, just rearrange) + let num_frames = pcm_samples.len() / settings.channels as usize; + let planar_samples = convert_to_planar_f32(&pcm_samples, settings.channels); + + // Get encoder frame size + let frame_size = encoder.frame_size(); + let samples_per_frame = if frame_size > 0 { + frame_size as usize + } else { + 1024 // Default AAC frame size + }; + + // Encode in chunks + let mut samples_encoded = 0; + while samples_encoded < num_frames { + if cancel_flag.load(Ordering::Relaxed) { + return Err("Export cancelled".to_string()); + } + + let samples_remaining = num_frames - samples_encoded; + let chunk_size = samples_remaining.min(samples_per_frame); + + // Create audio frame + let mut frame = ffmpeg::frame::Audio::new( + ffmpeg::format::Sample::F32(ffmpeg::format::sample::Type::Planar), + chunk_size, + channel_layout, + ); + frame.set_rate(settings.sample_rate); + + // Copy planar samples to frame + unsafe { + for ch in 0..settings.channels as usize { + let plane = frame.data_mut(ch); + let offset = samples_encoded; + let src = &planar_samples[ch][offset..offset + chunk_size]; + + std::ptr::copy_nonoverlapping( + src.as_ptr() as *const u8, + plane.as_mut_ptr(), + chunk_size * std::mem::size_of::(), + ); + } + } + + // 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(&mut encoder, &mut output)?; + + samples_encoded += chunk_size; + } + + // Flush encoder + encoder.send_eof() + .map_err(|e| format!("Failed to send EOF: {}", e))?; + receive_and_write_packets(&mut encoder, &mut output)?; + + // Write trailer + output.write_trailer() + .map_err(|e| format!("Failed to write trailer: {}", e))?; + + Ok(()) } #[cfg(test)] diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs index 1fc403a..d8cf382 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs @@ -142,79 +142,33 @@ impl ExportOrchestrator { return; } + println!("🧵 [EXPORT THREAD] Starting export for format: {:?}", settings.format); + // Convert settings to DAW backend format let daw_settings = daw_backend::audio::ExportSettings { format: match settings.format { lightningbeam_core::export::AudioFormat::Wav => daw_backend::audio::ExportFormat::Wav, lightningbeam_core::export::AudioFormat::Flac => daw_backend::audio::ExportFormat::Flac, - lightningbeam_core::export::AudioFormat::Mp3 | - lightningbeam_core::export::AudioFormat::Aac => { - // MP3/AAC not supported yet - progress_tx - .send(ExportProgress::Error { - message: format!("{} export not yet implemented. Please use WAV or FLAC format.", settings.format.name()), - }) - .ok(); - return; - } + lightningbeam_core::export::AudioFormat::Mp3 => daw_backend::audio::ExportFormat::Mp3, + lightningbeam_core::export::AudioFormat::Aac => daw_backend::audio::ExportFormat::Aac, }, sample_rate: settings.sample_rate, channels: settings.channels, bit_depth: settings.bit_depth, - mp3_bitrate: 320, // Not used for WAV/FLAC + mp3_bitrate: settings.bitrate_kbps, start_time: settings.start_time, end_time: settings.end_time, }; - println!("🧵 [EXPORT THREAD] Starting non-blocking export..."); + // Use DAW backend export for all formats + let result = Self::run_daw_backend_export( + &daw_settings, + &output_path, + &audio_controller, + &cancel_flag, + ); - // Start the export (non-blocking - just sends the query) - { - let mut controller = audio_controller.lock().unwrap(); - println!("🧵 [EXPORT THREAD] Sending export query..."); - if let Err(e) = controller.start_export_audio(&daw_settings, &output_path) { - println!("🧵 [EXPORT THREAD] Failed to start export: {}", e); - progress_tx.send(ExportProgress::Error { message: e }).ok(); - return; - } - println!("🧵 [EXPORT THREAD] Export query sent, lock released"); - } - - // Poll for completion without holding the lock for extended periods - let duration = settings.end_time - settings.start_time; - let start_time = std::time::Instant::now(); - let result = loop { - if cancel_flag.load(Ordering::Relaxed) { - break Err("Export cancelled by user".to_string()); - } - - // Sleep before polling to avoid spinning - std::thread::sleep(std::time::Duration::from_millis(100)); - - // Brief lock to poll for completion - let poll_result = { - let mut controller = audio_controller.lock().unwrap(); - controller.poll_export_completion() - }; - - match poll_result { - Ok(Some(result)) => { - // Export completed - println!("🧵 [EXPORT THREAD] Export completed: {:?}", result.is_ok()); - break result; - } - Ok(None) => { - // Still in progress - actual progress comes via AudioEvent::ExportProgress - // No need to send progress here - } - Err(e) => { - // Polling error (shouldn't happen) - println!("🧵 [EXPORT THREAD] Poll error: {}", e); - break Err(e); - } - } - }; - println!("🧵 [EXPORT THREAD] Export loop finished"); + println!("🧵 [EXPORT THREAD] Export finished"); // Send completion or error match result { @@ -232,6 +186,54 @@ impl ExportOrchestrator { } } } + + /// Run export using DAW backend (for all formats) + fn run_daw_backend_export( + settings: &daw_backend::audio::ExportSettings, + output_path: &PathBuf, + audio_controller: &Arc>, + cancel_flag: &Arc, + ) -> Result<(), String> { + println!("🧵 [EXPORT THREAD] Starting DAW backend export..."); + + // Start the export (non-blocking - just sends the query) + { + let mut controller = audio_controller.lock().unwrap(); + println!("🧵 [EXPORT THREAD] Sending export query..."); + controller.start_export_audio(settings, output_path)?; + println!("🧵 [EXPORT THREAD] Export query sent, lock released"); + } + + // Poll for completion without holding the lock for extended periods + loop { + if cancel_flag.load(Ordering::Relaxed) { + return Err("Export cancelled by user".to_string()); + } + + // Sleep before polling to avoid spinning + std::thread::sleep(std::time::Duration::from_millis(100)); + + // Brief lock to poll for completion + let poll_result = { + let mut controller = audio_controller.lock().unwrap(); + controller.poll_export_completion() + }; + + match poll_result { + Ok(Some(result)) => { + println!("🧵 [EXPORT THREAD] DAW backend export completed: {:?}", result.is_ok()); + return result; + } + Ok(None) => { + // Still in progress + } + Err(e) => { + println!("🧵 [EXPORT THREAD] Poll error: {}", e); + return Err(e); + } + } + } + } } impl Default for ExportOrchestrator {