Audio export: real FLAC + tag metadata for all formats
- FLAC is now real FLAC via ffmpeg, not WAV bytes in a .flac file. 16-bit uses S16, 24-bit uses S32 (ffmpeg's flac encoder emits bits_per_raw_sample=24). The flush emits a trailing empty packet that the FLAC muxer rejects as "invalid data" — it's skipped. - Tag metadata (title/artist/album/genre/year/track/comment) written into every format via each container's native tags: ID3v2 (MP3), MP4 atoms (M4A), Vorbis comments (FLAC) set through ffmpeg's output metadata; RIFF LIST/INFO appended to the hound-written WAV (with a fixed-up RIFF size). New AudioMetadata type on AudioExportSettings; dialog gains a Tags section and defaults Title to the project name. Tests: FLAC is a real fLaC container with round-tripped tags; WAV keeps a valid RIFF with a working INFO chunk.
This commit is contained in:
parent
6b8a1f1386
commit
15bdf80ec1
|
|
@ -273,49 +273,17 @@ fn write_wav<P: AsRef<Path>>(
|
|||
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<P: AsRef<Path>>(
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<P: AsRef<Path>>(
|
||||
project: &mut Project,
|
||||
pool: &AudioPool,
|
||||
midi_pool: &MidiClipPool,
|
||||
settings: &AudioExportSettings,
|
||||
output_path: P,
|
||||
cancel_flag: &Arc<AtomicBool>,
|
||||
) -> 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<P: AsRef<Path>>(
|
||||
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<P: AsRef<Path>>(
|
||||
project: &mut Project,
|
||||
pool: &AudioPool,
|
||||
_midi_pool: &MidiClipPool,
|
||||
settings: &AudioExportSettings,
|
||||
output_path: P,
|
||||
cancel_flag: &Arc<AtomicBool>,
|
||||
) -> 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::<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.plane_mut::<i16>(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<Vec<i16>> {
|
||||
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<Vec<f32>> {
|
||||
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<P: AsRef<Path>>(
|
||||
project: &mut Project,
|
||||
pool: &AudioPool,
|
||||
_midi_pool: &MidiClipPool,
|
||||
settings: &AudioExportSettings,
|
||||
output_path: P,
|
||||
cancel_flag: &Arc<AtomicBool>,
|
||||
) -> 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::<f32>(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue