diff --git a/README.md b/README.md index 10a40cc..a36af27 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,9 @@ A free and open-source 2D multimedia editor combining vector animation, audio pr ## Screenshots ![Animation View](screenshots/animation.png) + ![Music Editing View](screenshots/music.png) + ![Video Editing View](screenshots/video.png) ## Current Features @@ -28,7 +30,7 @@ A free and open-source 2D multimedia editor combining vector animation, audio pr - **Frontend:** Vanilla JavaScript - **Backend:** Rust (Tauri framework) - **Audio:** cpal + dasp for audio processing -- **Video:** FFmpeg for decode +- **Video:** FFmpeg for encode/decode ## Project Status diff --git a/daw-backend/Cargo.lock b/daw-backend/Cargo.lock index c30f2ac..58ba99d 100644 --- a/daw-backend/Cargo.lock +++ b/daw-backend/Cargo.lock @@ -457,6 +457,7 @@ dependencies = [ "dasp_rms", "dasp_sample", "dasp_signal", + "hound", "midir", "midly", "pathdiff", @@ -578,6 +579,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + [[package]] name = "indexmap" version = "1.9.3" diff --git a/daw-backend/Cargo.toml b/daw-backend/Cargo.toml index 7a67cc8..4482a83 100644 --- a/daw-backend/Cargo.toml +++ b/daw-backend/Cargo.toml @@ -16,6 +16,11 @@ rand = "0.8" base64 = "0.22" 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 + # Node-based audio graph dependencies dasp_graph = "0.11" dasp_signal = "0.11" diff --git a/daw-backend/src/audio/engine.rs b/daw-backend/src/audio/engine.rs index e231d3a..5432f19 100644 --- a/daw-backend/src/audio/engine.rs +++ b/daw-backend/src/audio/engine.rs @@ -1619,6 +1619,14 @@ impl Engine { None => QueryResponse::PoolFileInfo(Err(format!("Pool index {} not found", pool_index))), } } + Query::ExportAudio(settings, output_path) => { + // Perform export directly - this will block the audio thread but that's okay + // since we're exporting and not playing back anyway + match crate::audio::export_audio(&mut self.project, &self.audio_pool, &settings, &output_path) { + Ok(()) => QueryResponse::AudioExported(Ok(())), + Err(e) => QueryResponse::AudioExported(Err(e)), + } + } }; // Send response back @@ -2556,4 +2564,25 @@ impl EngineController { Err("Query timeout".to_string()) } + + /// Export audio to a file + pub fn export_audio>(&mut self, settings: &crate::audio::ExportSettings, output_path: P) -> Result<(), String> { + // Send export query + if let Err(_) = self.query_tx.push(Query::ExportAudio(settings.clone(), output_path.as_ref().to_path_buf())) { + return Err("Failed to send export query - queue full".to_string()); + } + + // Wait for response (with longer timeout since export can take a while) + let start = std::time::Instant::now(); + let timeout = std::time::Duration::from_secs(300); // 5 minute timeout for export + + while start.elapsed() < timeout { + if let Ok(QueryResponse::AudioExported(result)) = self.query_response_rx.pop() { + return result; + } + std::thread::sleep(std::time::Duration::from_millis(100)); + } + + Err("Export timeout".to_string()) + } } diff --git a/daw-backend/src/audio/export.rs b/daw-backend/src/audio/export.rs new file mode 100644 index 0000000..c3ed147 --- /dev/null +++ b/daw-backend/src/audio/export.rs @@ -0,0 +1,262 @@ +use super::buffer_pool::BufferPool; +use super::pool::AudioPool; +use super::project::Project; +use std::path::Path; + +/// Supported export formats +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExportFormat { + Wav, + Flac, + // TODO: Add MP3 support +} + +impl ExportFormat { + /// Get the file extension for this format + pub fn extension(&self) -> &'static str { + match self { + ExportFormat::Wav => "wav", + ExportFormat::Flac => "flac", + } + } +} + +/// Export settings for rendering audio +#[derive(Debug, Clone)] +pub struct ExportSettings { + /// Output format + pub format: ExportFormat, + /// Sample rate for export + pub sample_rate: u32, + /// Number of channels (1 = mono, 2 = stereo) + pub channels: u32, + /// Bit depth (16 or 24) - only for WAV/FLAC + pub bit_depth: u16, + /// MP3 bitrate in kbps (128, 192, 256, 320) + pub mp3_bitrate: u32, + /// Start time in seconds + pub start_time: f64, + /// End time in seconds + pub end_time: f64, +} + +impl Default for ExportSettings { + fn default() -> Self { + Self { + format: ExportFormat::Wav, + sample_rate: 44100, + channels: 2, + bit_depth: 16, + mp3_bitrate: 320, + start_time: 0.0, + end_time: 60.0, + } + } +} + +/// Export the project to an audio file +/// +/// This performs offline rendering, processing the entire timeline +/// in chunks to generate the final audio file. +pub fn export_audio>( + project: &mut Project, + pool: &AudioPool, + settings: &ExportSettings, + output_path: P, +) -> Result<(), String> { + // Render the project to memory + let samples = render_to_memory(project, pool, settings)?; + + // Write to file based on format + match settings.format { + ExportFormat::Wav => write_wav(&samples, settings, output_path)?, + ExportFormat::Flac => write_flac(&samples, settings, output_path)?, + } + + Ok(()) +} + +/// Render the project to memory +fn render_to_memory( + project: &mut Project, + pool: &AudioPool, + settings: &ExportSettings, +) -> Result, String> { + // Calculate total number of frames + let duration = settings.end_time - settings.start_time; + let total_frames = (duration * settings.sample_rate as f64).round() as usize; + let total_samples = total_frames * settings.channels as usize; + + println!("Export: duration={:.3}s, total_frames={}, total_samples={}, channels={}", + duration, total_frames, total_samples, settings.channels); + + // Render in chunks to avoid memory issues + const CHUNK_FRAMES: usize = 4096; + let chunk_samples = CHUNK_FRAMES * settings.channels as usize; + + // Create buffer for rendering + let mut render_buffer = vec![0.0f32; chunk_samples]; + let mut buffer_pool = BufferPool::new(16, chunk_samples); + + // Collect all rendered samples + let mut all_samples = Vec::with_capacity(total_samples); + + let mut playhead = settings.start_time; + let chunk_duration = CHUNK_FRAMES as f64 / settings.sample_rate as f64; + + // Render the entire timeline in chunks + while playhead < settings.end_time { + // Clear the render buffer + render_buffer.fill(0.0); + + // Render this chunk + project.render( + &mut render_buffer, + pool, + &mut buffer_pool, + playhead, + settings.sample_rate, + settings.channels, + ); + + // Calculate how many samples we actually need from this chunk + let remaining_time = settings.end_time - playhead; + let samples_needed = if remaining_time < chunk_duration { + // Calculate frames needed and ensure it's a whole number + let frames_needed = (remaining_time * settings.sample_rate as f64).round() as usize; + let samples = frames_needed * settings.channels as usize; + // Ensure we don't exceed chunk size + samples.min(chunk_samples) + } else { + chunk_samples + }; + + // Append to output + all_samples.extend_from_slice(&render_buffer[..samples_needed]); + + playhead += chunk_duration; + } + + println!("Export: rendered {} samples total", all_samples.len()); + + // Verify the sample count is a multiple of channels + if all_samples.len() % settings.channels as usize != 0 { + return Err(format!( + "Sample count {} is not a multiple of channel count {}", + all_samples.len(), + settings.channels + )); + } + + Ok(all_samples) +} + +/// Write WAV file using hound +fn write_wav>( + samples: &[f32], + settings: &ExportSettings, + output_path: P, +) -> Result<(), String> { + 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 WAV file: {}", e))?; + + // Write samples + 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 WAV file: {}", e))?; + + Ok(()) +} + +/// Write FLAC file using hound (FLAC is essentially lossless WAV) +fn write_flac>( + 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(()) +} + +// TODO: Add MP3 export support with a better library + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_export_settings_default() { + let settings = ExportSettings::default(); + assert_eq!(settings.format, ExportFormat::Wav); + assert_eq!(settings.sample_rate, 44100); + assert_eq!(settings.channels, 2); + assert_eq!(settings.bit_depth, 16); + } + + #[test] + fn test_format_extension() { + assert_eq!(ExportFormat::Wav.extension(), "wav"); + assert_eq!(ExportFormat::Flac.extension(), "flac"); + } +} diff --git a/daw-backend/src/audio/mod.rs b/daw-backend/src/audio/mod.rs index c2e8bdc..5ddc44e 100644 --- a/daw-backend/src/audio/mod.rs +++ b/daw-backend/src/audio/mod.rs @@ -3,6 +3,7 @@ pub mod bpm_detector; pub mod buffer_pool; pub mod clip; pub mod engine; +pub mod export; pub mod metronome; pub mod midi; pub mod node_graph; @@ -16,6 +17,7 @@ pub use automation::{AutomationLane, AutomationLaneId, AutomationPoint, CurveTyp pub use buffer_pool::BufferPool; pub use clip::{Clip, ClipId}; pub use engine::{Engine, EngineController}; +pub use export::{export_audio, ExportFormat, ExportSettings}; pub use metronome::Metronome; pub use midi::{MidiClip, MidiClipId, MidiEvent}; pub use pool::{AudioFile as PoolAudioFile, AudioPool}; diff --git a/daw-backend/src/command/types.rs b/daw-backend/src/command/types.rs index a45cda2..7f190f6 100644 --- a/daw-backend/src/command/types.rs +++ b/daw-backend/src/command/types.rs @@ -253,6 +253,8 @@ pub enum Query { GetPoolWaveform(usize, usize), /// Get file info from audio pool (pool_index) - returns (duration, sample_rate, channels) GetPoolFileInfo(usize), + /// Export audio to file (settings, output_path) + ExportAudio(crate::audio::ExportSettings, std::path::PathBuf), } /// Oscilloscope data from a node @@ -310,4 +312,6 @@ pub enum QueryResponse { PoolWaveform(Result, String>), /// Pool file info (duration, sample_rate, channels) PoolFileInfo(Result<(f64, u32, u32), String>), + /// Audio exported + AudioExported(Result<(), String>), } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bb73abb..11da83c 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1102,6 +1102,7 @@ dependencies = [ "dasp_rms", "dasp_sample", "dasp_signal", + "hound", "midir", "midly", "pathdiff", @@ -1939,6 +1940,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hound" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f" + [[package]] name = "html5ever" version = "0.26.0" diff --git a/src-tauri/src/audio.rs b/src-tauri/src/audio.rs index 3482517..b08ac45 100644 --- a/src-tauri/src/audio.rs +++ b/src-tauri/src/audio.rs @@ -1656,3 +1656,45 @@ pub async fn audio_load_track_graph( Err("Audio not initialized".to_string()) } } + +#[tauri::command] +pub async fn audio_export( + state: tauri::State<'_, Arc>>, + output_path: String, + format: String, + sample_rate: u32, + channels: u32, + bit_depth: u16, + mp3_bitrate: u32, + start_time: f64, + end_time: f64, +) -> Result<(), String> { + let mut audio_state = state.lock().unwrap(); + + if let Some(controller) = &mut audio_state.controller { + // Parse format + let export_format = match format.as_str() { + "wav" => daw_backend::audio::ExportFormat::Wav, + "flac" => daw_backend::audio::ExportFormat::Flac, + _ => return Err(format!("Unsupported format: {}", format)), + }; + + // Create export settings + let settings = daw_backend::audio::ExportSettings { + format: export_format, + sample_rate, + channels, + bit_depth, + mp3_bitrate, + start_time, + end_time, + }; + + // Call export through controller + controller.export_audio(&settings, &output_path)?; + + Ok(()) + } else { + Err("Audio not initialized".to_string()) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 14b03fd..d023263 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -303,6 +303,7 @@ pub fn run() { audio::audio_resolve_missing_file, audio::audio_serialize_track_graph, audio::audio_load_track_graph, + audio::audio_export, video::video_load_file, video::video_get_frame, video::video_get_frames_batch, diff --git a/src/main.js b/src/main.js index bc38ebc..7bd51b2 100644 --- a/src/main.js +++ b/src/main.js @@ -3212,6 +3212,124 @@ async function render() { document.querySelector("body").style.cursor = "default"; } +async function exportAudio() { + // Get the project duration from context + const duration = context.activeObject.duration || 60; + + // Show a simple dialog to get export settings + const dialog = document.createElement('div'); + dialog.style.cssText = ` + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: var(--bg-color, #2a2a2a); + border: 1px solid var(--border-color, #555); + padding: 20px; + border-radius: 8px; + z-index: 10000; + color: var(--text-color, #eee); + min-width: 400px; + `; + + dialog.innerHTML = ` + +

Export Audio

+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ `; + + document.body.appendChild(dialog); + + return new Promise((resolve) => { + dialog.querySelector('#export-cancel').addEventListener('click', () => { + document.body.removeChild(dialog); + resolve(null); + }); + + dialog.querySelector('#export-ok').addEventListener('click', async () => { + const format = dialog.querySelector('#export-format').value; + const sampleRate = parseInt(dialog.querySelector('#export-sample-rate').value); + const bitDepth = parseInt(dialog.querySelector('#export-bit-depth').value); + const endTime = parseFloat(dialog.querySelector('#export-end-time').value); + + document.body.removeChild(dialog); + + // Show file save dialog + const path = await saveFileDialog({ + filters: [ + { + name: format.toUpperCase() + " files", + extensions: [format], + }, + ], + defaultPath: await join(await documentDir(), `export.${format}`), + }); + + if (path) { + try { + document.querySelector("body").style.cursor = "wait"; + + await invoke('audio_export', { + outputPath: path, + format: format, + sampleRate: sampleRate, + channels: 2, + bitDepth: bitDepth, + mp3Bitrate: 320, + startTime: 0.0, + endTime: endTime, + }); + + document.querySelector("body").style.cursor = "default"; + alert('Audio exported successfully!'); + } catch (error) { + document.querySelector("body").style.cursor = "default"; + console.error('Export failed:', error); + alert('Export failed: ' + error); + } + } + + resolve(); + }); + }); +} + function updateScrollPosition(zoomFactor) { if (context.mousePos) { for (let canvas of canvases) { @@ -6726,11 +6844,16 @@ async function renderMenu() { accelerator: getShortcut("import"), }, { - text: "Export...", + text: "Export Video...", enabled: true, action: render, accelerator: getShortcut("export"), }, + { + text: "Export Audio...", + enabled: true, + action: exportAudio, + }, { text: "Quit", enabled: true,