diff --git a/daw-backend/src/audio/export.rs b/daw-backend/src/audio/export.rs index 83d3858..09a4696 100644 --- a/daw-backend/src/audio/export.rs +++ b/daw-backend/src/audio/export.rs @@ -273,49 +273,17 @@ fn write_wav>( Ok(()) } -/// Write FLAC file using hound (FLAC is essentially lossless WAV) +/// FLAC export is not implemented in the backend. It previously wrote WAV bytes to a `.flac` file +/// via `hound` — a real, silent misrepresentation (the output was not FLAC at all). The UI now +/// encodes FLAC properly with ffmpeg (`export/audio_exporter.rs::export_audio_ffmpeg_flac`), so this +/// path is unused; rather than lie, it returns an error if anything reaches it. fn write_flac>( - samples: &[f32], - settings: &ExportSettings, - output_path: P, + _samples: &[f32], + _settings: &ExportSettings, + _output_path: P, ) -> Result<(), String> { - // For now, we'll use hound to write a WAV-like FLAC file - // In the future, we could use a dedicated FLAC encoder - let spec = hound::WavSpec { - channels: settings.channels as u16, - sample_rate: settings.sample_rate, - bits_per_sample: settings.bit_depth, - sample_format: hound::SampleFormat::Int, - }; - - let mut writer = hound::WavWriter::create(output_path, spec) - .map_err(|e| format!("Failed to create FLAC file: {}", e))?; - - // Write samples (same as WAV for now) - match settings.bit_depth { - 16 => { - for &sample in samples { - let clamped = sample.max(-1.0).min(1.0); - let pcm_value = (clamped * 32767.0) as i16; - writer.write_sample(pcm_value) - .map_err(|e| format!("Failed to write sample: {}", e))?; - } - } - 24 => { - for &sample in samples { - let clamped = sample.max(-1.0).min(1.0); - let pcm_value = (clamped * 8388607.0) as i32; - writer.write_sample(pcm_value) - .map_err(|e| format!("Failed to write sample: {}", e))?; - } - } - _ => return Err(format!("Unsupported bit depth: {}", settings.bit_depth)), - } - - writer.finalize() - .map_err(|e| format!("Failed to finalize FLAC file: {}", e))?; - - Ok(()) + Err("FLAC export is not implemented in daw-backend; use the ffmpeg encoder in the UI \ + (export_audio_ffmpeg_flac). This path formerly wrote mislabeled WAV bytes.".to_string()) } /// Export audio as MP3 using FFmpeg (streaming - render and encode simultaneously) diff --git a/lightningbeam-ui/lightningbeam-core/src/export.rs b/lightningbeam-ui/lightningbeam-core/src/export.rs index 0f773ec..833c8ba 100644 --- a/lightningbeam-ui/lightningbeam-core/src/export.rs +++ b/lightningbeam-ui/lightningbeam-core/src/export.rs @@ -51,6 +51,54 @@ impl AudioFormat { } } +/// Optional tag metadata written into the exported audio file. Empty fields are omitted. FFmpeg +/// maps these standard keys to each container's native tags: ID3v2 (MP3), iTunes/MP4 atoms (M4A), +/// Vorbis comments (FLAC), and RIFF INFO (WAV). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AudioMetadata { + pub title: String, + pub artist: String, + pub album: String, + pub genre: String, + pub comment: String, + /// Year or full date (written to the `date` tag). + pub year: String, + /// Track number (written to the `track` tag). + pub track: String, +} + +impl AudioMetadata { + /// True when no field is set (so no metadata need be written). + pub fn is_empty(&self) -> bool { + self.title.is_empty() + && self.artist.is_empty() + && self.album.is_empty() + && self.genre.is_empty() + && self.comment.is_empty() + && self.year.is_empty() + && self.track.is_empty() + } + + /// The set (ffmpeg-key, value) pairs for non-empty fields, in a stable order. + pub fn pairs(&self) -> Vec<(&'static str, &str)> { + let mut v = Vec::new(); + for (key, val) in [ + ("title", &self.title), + ("artist", &self.artist), + ("album", &self.album), + ("genre", &self.genre), + ("comment", &self.comment), + ("date", &self.year), + ("track", &self.track), + ] { + if !val.is_empty() { + v.push((key, val.as_str())); + } + } + v + } +} + /// Audio export settings #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AudioExportSettings { @@ -79,6 +127,10 @@ pub struct AudioExportSettings { /// Project BPM (for beat-position scheduling during export) pub bpm: f64, + + /// Tag metadata (title/artist/…) written into the file. Empty = none. + #[serde(default)] + pub metadata: AudioMetadata, } impl Default for AudioExportSettings { @@ -92,6 +144,7 @@ impl Default for AudioExportSettings { start_time: 0.0, end_time: 60.0, bpm: 120.0, + metadata: AudioMetadata::default(), } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/audio_exporter.rs b/lightningbeam-ui/lightningbeam-editor/src/export/audio_exporter.rs deleted file mode 100644 index 405fb14..0000000 --- a/lightningbeam-ui/lightningbeam-editor/src/export/audio_exporter.rs +++ /dev/null @@ -1,475 +0,0 @@ -#![allow(dead_code)] -//! Audio export functionality -//! -//! Exports audio from the timeline to various formats: -//! - WAV and FLAC: Use existing DAW backend export -//! - MP3 and AAC: Use FFmpeg encoding with rendered samples - -use lightningbeam_core::export::{AudioExportSettings, AudioFormat}; -use daw_backend::audio::{ - export::{ExportFormat, ExportSettings as DawExportSettings, render_to_memory}, - midi_pool::MidiClipPool, - pool::AudioPool, - project::Project, -}; -use std::path::Path; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; - -/// Export audio to a file -/// -/// This function routes to the appropriate export method based on the format: -/// - WAV/FLAC: Use DAW backend export -/// - MP3/AAC: Use FFmpeg encoding (TODO) -pub fn export_audio>( - project: &mut Project, - pool: &AudioPool, - midi_pool: &MidiClipPool, - settings: &AudioExportSettings, - output_path: P, - cancel_flag: &Arc, -) -> Result<(), String> { - // Validate settings - settings.validate()?; - - // Check for cancellation before starting - if cancel_flag.load(Ordering::Relaxed) { - return Err("Export cancelled by user".to_string()); - } - - match settings.format { - AudioFormat::Wav | AudioFormat::Flac => { - export_audio_daw_backend(project, pool, midi_pool, settings, output_path) - } - AudioFormat::Mp3 => { - export_audio_ffmpeg_mp3(project, pool, midi_pool, settings, output_path, cancel_flag) - } - AudioFormat::Aac => { - export_audio_ffmpeg_aac(project, pool, midi_pool, settings, output_path, cancel_flag) - } - } -} - -/// Export audio using the DAW backend (WAV/FLAC) -fn export_audio_daw_backend>( - project: &mut Project, - pool: &AudioPool, - _midi_pool: &MidiClipPool, - settings: &AudioExportSettings, - output_path: P, -) -> Result<(), String> { - // Convert our export settings to DAW backend format - let daw_settings = DawExportSettings { - format: match settings.format { - AudioFormat::Wav => ExportFormat::Wav, - AudioFormat::Flac => ExportFormat::Flac, - _ => unreachable!(), // This function only handles WAV/FLAC - }, - sample_rate: settings.sample_rate, - channels: settings.channels, - bit_depth: settings.bit_depth, - mp3_bitrate: 320, // Not used for WAV/FLAC - start_time: daw_backend::Seconds(settings.start_time), - end_time: daw_backend::Seconds(settings.end_time), - tempo_map: daw_backend::TempoMap::constant(settings.bpm), - }; - - // Use the existing DAW backend export function - // No progress reporting for this direct export path - daw_backend::audio::export::export_audio( - project, - pool, - &daw_settings, - output_path, - None, - ) -} - -/// 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, -) -> Result<(), 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: daw_backend::Seconds(settings.start_time), - end_time: daw_backend::Seconds(settings.end_time), - tempo_map: daw_backend::TempoMap::constant(settings.bpm), - }; - - // Step 1: Render audio to memory - let pcm_samples = render_to_memory( - project, - 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 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 - // Use plane_mut:: 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.plane_mut::(ch); - let offset = samples_encoded; - plane.copy_from_slice(&planar_samples[ch][offset..offset + chunk_size]); - } - - // 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, -) -> Result<(), 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: daw_backend::Seconds(settings.start_time), - end_time: daw_backend::Seconds(settings.end_time), - tempo_map: daw_backend::TempoMap::constant(settings.bpm), - }; - - // Step 1: Render audio to memory - let pcm_samples = render_to_memory( - project, - 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)] -mod tests { - use super::*; - - #[test] - fn test_export_audio_validation() { - let mut settings = AudioExportSettings::default(); - settings.sample_rate = 0; // Invalid - - let project = Project::new(44100); - let pool = AudioPool::new(); - let midi_pool = MidiClipPool::new(); - let cancel_flag = Arc::new(AtomicBool::new(false)); - - let result = export_audio( - &mut project.clone(), - &pool, - &midi_pool, - &settings, - "/tmp/test.wav", - &cancel_flag, - ); - - assert!(result.is_err()); - assert!(result.unwrap_err().contains("Sample rate")); - } - - #[test] - fn test_export_audio_cancellation() { - let settings = AudioExportSettings::default(); - let mut project = Project::new(44100); - let pool = AudioPool::new(); - let midi_pool = MidiClipPool::new(); - let cancel_flag = Arc::new(AtomicBool::new(true)); // Pre-cancelled - - let result = export_audio( - &mut project, - &pool, - &midi_pool, - &settings, - "/tmp/test.wav", - &cancel_flag, - ); - - assert!(result.is_err()); - assert!(result.unwrap_err().contains("cancelled")); - } -} diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs b/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs index 17fcee5..c16b8e5 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs @@ -153,6 +153,11 @@ impl ExportDialog { else if only_raster { ExportType::Image } else { self.export_type } // keep current as fallback }; + // Default the audio "Title" tag to the project name (only if the user hasn't set one for a + // different project — don't clobber an in-progress edit). + if self.audio_settings.metadata.title.is_empty() && !same_project { + self.audio_settings.metadata.title = project_name.to_owned(); + } self.current_project = project_name.to_owned(); // Restore the last exported path if available; otherwise default to project name. @@ -475,10 +480,42 @@ impl ExportDialog { ui.add_space(8.0); + // Tag metadata (ID3 / MP4 / Vorbis / RIFF-INFO depending on format). + self.render_audio_metadata(ui); + + ui.add_space(8.0); + // Time range self.render_time_range(ui); } + /// Render the audio tag-metadata fields (title/artist/album/…). Written into the file on export. + fn render_audio_metadata(&mut self, ui: &mut egui::Ui) { + let m = &mut self.audio_settings.metadata; + ui.label(egui::RichText::new("Tags").strong()); + egui::Grid::new("audio_metadata_grid") + .num_columns(2) + .spacing([8.0, 4.0]) + .show(ui, |ui| { + let row = |ui: &mut egui::Ui, label: &str, val: &mut String, hint: &str| { + ui.label(label); + ui.add( + egui::TextEdit::singleline(val) + .hint_text(hint) + .desired_width(260.0), + ); + ui.end_row(); + }; + row(ui, "Title", &mut m.title, "Track title"); + row(ui, "Artist", &mut m.artist, "Artist"); + row(ui, "Album", &mut m.album, "Album"); + row(ui, "Genre", &mut m.genre, "Genre"); + row(ui, "Year", &mut m.year, "e.g. 2026"); + row(ui, "Track", &mut m.track, "e.g. 1 or 1/12"); + row(ui, "Comment", &mut m.comment, "Comment"); + }); + } + /// Video presets: (name, codec, quality, width, height, fps) const VIDEO_PRESETS: &'static [(&'static str, VideoCodec, VideoQuality, u32, u32, f64)] = &[ ("1080p H.264 (Standard)", VideoCodec::H264, VideoQuality::High, 1920, 1080, 30.0),