diff --git a/daw-backend/src/audio/export.rs b/daw-backend/src/audio/export.rs index 09a4696..789b9d8 100644 --- a/daw-backend/src/audio/export.rs +++ b/daw-backend/src/audio/export.rs @@ -50,6 +50,10 @@ pub struct ExportSettings { pub end_time: Seconds, /// Tempo map for beat-position scheduling pub tempo_map: TempoMap, + /// Tag metadata as (ffmpeg-key, value) pairs (e.g. ("title", "…"), ("artist", "…")). Written to + /// the container's native tags: ID3v2 (MP3), MP4 atoms (M4A), Vorbis comments (FLAC), RIFF INFO + /// (WAV). Empty = no tags. + pub metadata: Vec<(String, String)>, } impl Default for ExportSettings { @@ -63,10 +67,26 @@ impl Default for ExportSettings { start_time: Seconds::ZERO, end_time: Seconds(60.0), tempo_map: TempoMap::constant(120.0), + metadata: Vec::new(), } } } +/// Set tag metadata on an ffmpeg output context (before `write_header`). FFmpeg maps the standard +/// keys to each container's native tags. +fn apply_metadata(output: &mut ffmpeg_next::format::context::Output, metadata: &[(String, String)]) { + if metadata.is_empty() { + return; + } + let mut dict = ffmpeg_next::Dictionary::new(); + for (k, v) in metadata { + if !v.is_empty() { + dict.set(k, v); + } + } + output.set_metadata(dict); +} + /// Export the project to an audio file /// /// This performs offline rendering, processing the entire timeline @@ -108,17 +128,21 @@ pub fn export_audio>( // Route to appropriate export implementation based on format. // Ensure export mode is disabled even if an error occurs. let result = match settings.format { - ExportFormat::Wav | ExportFormat::Flac => { + ExportFormat::Wav => { let samples = render_to_memory(project, pool, settings, event_tx.as_mut().map(|tx| &mut **tx))?; - // Signal that rendering is done and we're now writing the file if let Some(ref mut tx) = event_tx { let _ = tx.push(AudioEvent::ExportFinalizing); } - match settings.format { - ExportFormat::Wav => write_wav(&samples, settings, &output_path), - ExportFormat::Flac => write_flac(&samples, settings, &output_path), - _ => unreachable!(), + write_wav(&samples, settings, &output_path) + // hound writes no metadata; append a RIFF INFO chunk for tags. + .and_then(|_| append_wav_info_chunk(output_path.as_ref(), &settings.metadata)) + } + ExportFormat::Flac => { + let samples = render_to_memory(project, pool, settings, event_tx.as_mut().map(|tx| &mut **tx))?; + if let Some(ref mut tx) = event_tx { + let _ = tx.push(AudioEvent::ExportFinalizing); } + export_flac(&samples, settings, &output_path) } ExportFormat::Mp3 => { export_mp3(project, pool, settings, output_path, event_tx) @@ -273,17 +297,175 @@ fn write_wav>( Ok(()) } -/// 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, +/// Export real FLAC via ffmpeg from already-rendered interleaved f32 samples (Vorbis-comment +/// metadata). Replaces the former `write_flac`, which wrote WAV bytes to a `.flac` file. 16-bit +/// uses S16; 24-bit uses S32 (ffmpeg's flac encoder emits `bits_per_raw_sample = 24` for S32, +/// taking the top 24 bits). +fn export_flac>( + samples: &[f32], + settings: &ExportSettings, + output_path: P, ) -> Result<(), String> { - 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()) + use ffmpeg_next as ffmpeg; + + ffmpeg::init().map_err(|e| format!("Failed to initialize FFmpeg: {}", e))?; + + let codec = ffmpeg::encoder::find(ffmpeg::codec::Id::FLAC) + .ok_or("FLAC encoder not found in this ffmpeg build")?; + let mut output = ffmpeg::format::output(&output_path) + .map_err(|e| format!("Failed to create output file: {}", e))?; + + 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)), + }; + + // FLAC accepts packed S16 or S32; S32 → 24-bit output. + let use_24 = settings.bit_depth >= 24; + let sample_fmt = if use_24 { + ffmpeg::format::Sample::I32(ffmpeg::format::sample::Type::Packed) + } else { + ffmpeg::format::Sample::I16(ffmpeg::format::sample::Type::Packed) + }; + + let mut encoder = ffmpeg::codec::Context::new_with_codec(codec) + .encoder() + .audio() + .map_err(|e| format!("Failed to create FLAC encoder: {}", e))?; + encoder.set_rate(settings.sample_rate as i32); + encoder.set_channel_layout(channel_layout); + encoder.set_format(sample_fmt); + encoder.set_time_base(ffmpeg::Rational(1, settings.sample_rate as i32)); + let mut encoder = encoder.open_as(codec) + .map_err(|e| format!("Failed to open FLAC encoder: {}", e))?; + + { + let mut stream = output.add_stream(codec) + .map_err(|e| format!("Failed to add stream: {}", e))?; + stream.set_parameters(&encoder); + } + apply_metadata(&mut output, &settings.metadata); + output.write_header() + .map_err(|e| format!("Failed to write FLAC header: {}", e))?; + + let channels = settings.channels as usize; + let num_frames = samples.len() / channels; + let frame_size = if encoder.frame_size() > 0 { encoder.frame_size() as usize } else { 4096 }; + + let mut done = 0usize; + while done < num_frames { + let n = (num_frames - done).min(frame_size); + let mut frame = ffmpeg::frame::Audio::new(sample_fmt, n, channel_layout); + frame.set_rate(settings.sample_rate); + frame.set_pts(Some(done as i64)); // samples; the FLAC muxer requires PTS + + let buf = frame.data_mut(0); // packed interleaved → plane 0 + let base = done * channels; + if use_24 { + for i in 0..n * channels { + let s = samples[base + i].clamp(-1.0, 1.0); + let v = (s as f64 * 2_147_483_647.0) as i32; // full-scale S32; encoder takes top 24 + buf[i * 4..i * 4 + 4].copy_from_slice(&v.to_le_bytes()); + } + } else { + for i in 0..n * channels { + let s = samples[base + i].clamp(-1.0, 1.0); + let v = (s * 32767.0) as i16; + buf[i * 2..i * 2 + 2].copy_from_slice(&v.to_le_bytes()); + } + } + + encoder.send_frame(&frame).map_err(|e| format!("Failed to send FLAC frame: {}", e))?; + flac_write_packets(&mut encoder, &mut output)?; + done += n; + } + encoder.send_eof().map_err(|e| format!("Failed to flush FLAC encoder: {}", e))?; + flac_write_packets(&mut encoder, &mut output)?; + output.write_trailer().map_err(|e| format!("Failed to finalize FLAC: {}", e))?; + Ok(()) +} + +/// Drain encoded FLAC packets and write them (non-interleaved). Skips the trailing empty flush +/// packet, which the FLAC muxer otherwise rejects as "Invalid data". Rescales packet ts from the +/// encoder time base to the stream's. +fn flac_write_packets( + encoder: &mut ffmpeg_next::encoder::Audio, + output: &mut ffmpeg_next::format::context::Output, +) -> Result<(), String> { + let mut pkt = ffmpeg_next::Packet::empty(); + let enc_tb = encoder.time_base(); + let stream_tb = output.stream(0).map(|s| s.time_base()).unwrap_or(enc_tb); + while encoder.receive_packet(&mut pkt).is_ok() { + if pkt.size() == 0 { + continue; + } + pkt.set_stream(0); + pkt.rescale_ts(enc_tb, stream_tb); + pkt.write(output).map_err(|e| format!("Failed to write FLAC packet: {}", e))?; + } + Ok(()) +} + +/// Append a RIFF `LIST`/`INFO` metadata chunk to a finished WAV file (hound writes no tags), then +/// fix up the top-level RIFF size. Maps ffmpeg-style keys to RIFF INFO sub-chunk IDs. Trailing INFO +/// chunks are ignored by players that don't read them. +fn append_wav_info_chunk(path: &Path, metadata: &[(String, String)]) -> Result<(), String> { + use std::io::{Seek, SeekFrom, Write}; + + let riff_id = |key: &str| -> Option<&'static [u8; 4]> { + match key { + "title" => Some(b"INAM"), + "artist" => Some(b"IART"), + "album" => Some(b"IPRD"), + "genre" => Some(b"IGNR"), + "comment" => Some(b"ICMT"), + "date" => Some(b"ICRD"), + "track" => Some(b"ITRK"), + _ => None, + } + }; + + let mut info: Vec = Vec::new(); + info.extend_from_slice(b"INFO"); + for (key, val) in metadata { + if val.is_empty() { + continue; + } + let Some(id) = riff_id(key) else { continue }; + let mut bytes = val.as_bytes().to_vec(); + bytes.push(0); // NUL-terminate + if bytes.len() % 2 == 1 { + bytes.push(0); // pad to even + } + info.extend_from_slice(id); + info.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + info.extend_from_slice(&bytes); + } + if info.len() <= 4 { + return Ok(()); // nothing but the "INFO" tag + } + + let mut list: Vec = Vec::with_capacity(info.len() + 8); + list.extend_from_slice(b"LIST"); + list.extend_from_slice(&(info.len() as u32).to_le_bytes()); + list.extend_from_slice(&info); + + let mut f = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(path) + .map_err(|e| format!("Failed to open WAV for tagging: {}", e))?; + let end = f.seek(SeekFrom::End(0)).map_err(|e| e.to_string())?; + if end % 2 == 1 { + f.write_all(&[0]).map_err(|e| e.to_string())?; + } + f.write_all(&list).map_err(|e| format!("Failed to write WAV tags: {}", e))?; + let new_len = f.seek(SeekFrom::End(0)).map_err(|e| e.to_string())?; + f.seek(SeekFrom::Start(4)).map_err(|e| e.to_string())?; + f.write_all(&((new_len - 8) as u32).to_le_bytes()) + .map_err(|e| format!("Failed to update RIFF size: {}", e))?; + Ok(()) } /// Export audio as MP3 using FFmpeg (streaming - render and encode simultaneously) @@ -330,6 +512,7 @@ fn export_mp3>( stream.set_parameters(&encoder); } + apply_metadata(&mut output, &settings.metadata); output.write_header() .map_err(|e| format!("Failed to write header: {}", e))?; @@ -499,6 +682,7 @@ fn export_aac>( stream.set_parameters(&encoder); } + apply_metadata(&mut output, &settings.metadata); output.write_header() .map_err(|e| format!("Failed to write header: {}", e))?; @@ -806,4 +990,61 @@ mod tests { assert_eq!(ExportFormat::Wav.extension(), "wav"); assert_eq!(ExportFormat::Flac.extension(), "flac"); } + + fn tagged_settings(format: ExportFormat) -> ExportSettings { + ExportSettings { + format, + sample_rate: 48000, + channels: 2, + bit_depth: 24, + mp3_bitrate: 192, + start_time: Seconds::ZERO, + end_time: Seconds(0.2), // tiny render + tempo_map: TempoMap::constant(120.0), + metadata: vec![ + ("title".to_string(), "Test Title".to_string()), + ("artist".to_string(), "Test Artist".to_string()), + ], + } + } + + /// FLAC export must be a real FLAC container (not WAV bytes) carrying Vorbis-comment tags. + #[test] + fn flac_export_is_real_flac_with_tags() { + let settings = tagged_settings(ExportFormat::Flac); + let mut project = Project::new(48000); + let pool = AudioPool::new(); + let path = std::env::temp_dir().join("lb_be_flac_test.flac"); + + export_audio(&mut project, &pool, &settings, &path, None).expect("FLAC export failed"); + let bytes = std::fs::read(&path).unwrap(); + + assert_eq!(&bytes[0..4], b"fLaC", "not real FLAC (got {:?})", &bytes[0..4]); + let s = String::from_utf8_lossy(&bytes); + assert!(s.contains("Test Title"), "title tag missing from FLAC"); + assert!(s.contains("Test Artist"), "artist tag missing from FLAC"); + std::fs::remove_file(&path).ok(); + } + + /// WAV export keeps a valid RIFF container and gains a LIST/INFO tag chunk with a fixed-up size. + #[test] + fn wav_export_has_info_chunk() { + let settings = tagged_settings(ExportFormat::Wav); + let mut project = Project::new(48000); + let pool = AudioPool::new(); + let path = std::env::temp_dir().join("lb_be_wav_test.wav"); + + export_audio(&mut project, &pool, &settings, &path, None).expect("WAV export failed"); + let bytes = std::fs::read(&path).unwrap(); + + assert_eq!(&bytes[0..4], b"RIFF"); + assert_eq!(&bytes[8..12], b"WAVE"); + let riff_size = u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]) as usize; + assert_eq!(riff_size, bytes.len() - 8, "RIFF size not fixed up after tagging"); + let s = String::from_utf8_lossy(&bytes); + assert!(s.contains("LIST") && s.contains("INFO") && s.contains("INAM"), + "no RIFF INFO chunk"); + assert!(s.contains("Test Title"), "title not in WAV INFO chunk"); + std::fs::remove_file(&path).ok(); + } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/config.rs b/lightningbeam-ui/lightningbeam-editor/src/config.rs index 24c976e..a724288 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/config.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/config.rs @@ -70,6 +70,14 @@ pub struct AppConfig { /// sooner; larger = smaller pyramid, wider re-decode span. Default 256. #[serde(default = "defaults::waveform_floor_samples_per_texel")] pub waveform_floor_samples_per_texel: u32, + + /// Last-used audio-export "Artist" tag, remembered so it prefills next time. + #[serde(default)] + pub last_audio_artist: String, + + /// Last-used audio-export "Album" tag, remembered so it prefills next time. + #[serde(default)] + pub last_audio_album: String, } impl Default for AppConfig { @@ -90,6 +98,8 @@ impl Default for AppConfig { keybindings: KeybindingConfig::default(), large_media_default: LargeMediaMode::default(), waveform_floor_samples_per_texel: defaults::waveform_floor_samples_per_texel(), + last_audio_artist: String::new(), + last_audio_album: String::new(), } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs b/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs index c16b8e5..e924486 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/dialog.rs @@ -11,6 +11,36 @@ use lightningbeam_core::export::{ }; use std::path::PathBuf; +/// The OS username (`$USER` / `%USERNAME%`), or empty if unavailable. Used as a default Artist tag. +fn os_username() -> String { + std::env::var("USER") + .or_else(|_| std::env::var("USERNAME")) + .unwrap_or_default() +} + +/// Current civil year (UTC) computed from the system clock — avoids pulling in a date crate. +fn current_year() -> i64 { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) as i64; + year_from_unix_secs(secs) +} + +/// Civil year (UTC) for a Unix timestamp, via Howard Hinnant's `civil_from_days` algorithm. +fn year_from_unix_secs(secs: i64) -> i64 { + let days = secs.div_euclid(86_400); + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; // [0, 146096] + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] + y + if m <= 2 { 1 } else { 0 } +} + /// Hint about document content, used to pick a smart default export type. pub struct DocumentHint { pub has_video: bool, @@ -129,7 +159,14 @@ impl Default for ExportDialog { impl ExportDialog { /// Open the dialog with default settings, using `hint` to pick a smart default tab. - pub fn open(&mut self, timeline_duration: f64, project_name: &str, hint: &DocumentHint) { + pub fn open( + &mut self, + timeline_duration: f64, + project_name: &str, + hint: &DocumentHint, + last_artist: &str, + last_album: &str, + ) { self.open = true; self.audio_settings.end_time = timeline_duration; self.video_settings.end_time = timeline_duration; @@ -153,10 +190,23 @@ 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(); + // Sensible tag defaults, only filled when empty so a user's edits are never clobbered: + // • Title → project name (on a project switch) + // • Year → current year + // • Artist → last-used artist, else the OS username + // • Album → last-used album + let meta = &mut self.audio_settings.metadata; + if meta.title.is_empty() && !same_project { + meta.title = project_name.to_owned(); + } + if meta.year.is_empty() { + meta.year = current_year().to_string(); + } + if meta.artist.is_empty() { + meta.artist = if !last_artist.is_empty() { last_artist.to_owned() } else { os_username() }; + } + if meta.album.is_empty() && !last_album.is_empty() { + meta.album = last_album.to_owned(); } self.current_project = project_name.to_owned(); @@ -493,6 +543,14 @@ impl ExportDialog { fn render_audio_metadata(&mut self, ui: &mut egui::Ui) { let m = &mut self.audio_settings.metadata; ui.label(egui::RichText::new("Tags").strong()); + // Placeholder styling: italic + a clearly faded color so an empty field's hint never reads + // as a real value (the theme's default weak color is too close to the text color). An + // explicit color overrides egui's weak-color fallback for hint text. + let hint_color = { + let t = ui.visuals().text_color(); + egui::Color32::from_rgba_unmultiplied(t.r(), t.g(), t.b(), 100) + }; + let year_hint = format!("e.g. {}", current_year()); egui::Grid::new("audio_metadata_grid") .num_columns(2) .spacing([8.0, 4.0]) @@ -501,18 +559,18 @@ impl ExportDialog { ui.label(label); ui.add( egui::TextEdit::singleline(val) - .hint_text(hint) + .hint_text(egui::RichText::new(hint).italics().color(hint_color)) .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, "Title", &mut m.title, "e.g. My Song"); + row(ui, "Artist", &mut m.artist, "e.g. Jane Doe"); + row(ui, "Album", &mut m.album, "e.g. Greatest Hits"); + row(ui, "Genre", &mut m.genre, "e.g. Electronic"); + row(ui, "Year", &mut m.year, &year_hint); row(ui, "Track", &mut m.track, "e.g. 1 or 1/12"); - row(ui, "Comment", &mut m.comment, "Comment"); + row(ui, "Comment", &mut m.comment, "Optional notes…"); }); } @@ -973,3 +1031,30 @@ impl ExportProgressDialog { should_cancel } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn year_from_unix_secs_known_values() { + assert_eq!(year_from_unix_secs(0), 1970); // Unix epoch + assert_eq!(year_from_unix_secs(946_684_800), 2000); // 2000-01-01 + assert_eq!(year_from_unix_secs(1_735_689_600), 2025); // 2025-01-01 + assert_eq!(year_from_unix_secs(1_767_225_599), 2025); // 2025-12-31 23:59:59 + assert_eq!(year_from_unix_secs(1_767_225_600), 2026); // 2026-01-01 + + // Post-2038: these timestamps exceed i32::MAX (2_147_483_647) — and the last exceeds + // u32::MAX — so a 32-bit time_t would wrap here. i64 math handles them correctly. + assert_eq!(year_from_unix_secs(2_148_595_200), 2038); // 2038-02-01 (> i32::MAX) + assert_eq!(year_from_unix_secs(2_223_331_200), 2040); // 2040-06-15 + assert_eq!(year_from_unix_secs(4_102_444_800), 2100); // 2100-01-01 (not a leap year) + assert_eq!(year_from_unix_secs(9_214_646_400), 2262); // 2262-01-01 (> u32::MAX) + } + + #[test] + fn current_year_is_plausible() { + let y = current_year(); + assert!((2020..3000).contains(&y), "implausible year: {y}"); + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs index 803abfd..135ba06 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/export/mod.rs @@ -3,7 +3,6 @@ //! This module provides the export orchestrator and progress tracking //! for exporting audio and video from the timeline. -pub mod audio_exporter; pub mod dialog; pub mod gif_exporter; pub mod image_exporter; @@ -1008,6 +1007,12 @@ impl ExportOrchestrator { start_time: daw_backend::Seconds(settings.start_time), end_time: daw_backend::Seconds(settings.end_time), tempo_map: daw_backend::TempoMap::constant(settings.bpm), + metadata: settings + .metadata + .pairs() + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), }; // Use DAW backend export for all formats diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index b01a4de..04ea76a 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -3443,7 +3443,13 @@ impl EditorApp { h }; - self.export_dialog.open(timeline_endpoint, &project_name, &hint); + self.export_dialog.open( + timeline_endpoint, + &project_name, + &hint, + &self.config.last_audio_artist, + &self.config.last_audio_album, + ); } MenuAction::Quit => { println!("Menu: Quit"); @@ -6369,6 +6375,15 @@ impl eframe::App for EditorApp { ExportResult::AudioOnly(settings, output_path) => { println!("🎵 [MAIN] Starting audio-only export: {}", output_path.display()); + // Remember Artist/Album so they prefill next time. + if !settings.metadata.artist.is_empty() { + self.config.last_audio_artist = settings.metadata.artist.clone(); + } + if !settings.metadata.album.is_empty() { + self.config.last_audio_album = settings.metadata.album.clone(); + } + self.config.save(); + if let Some(audio_controller) = &self.audio_controller { orchestrator.start_audio_export( settings,