Add audio export

This commit is contained in:
Skyler Lehmkuhl 2025-11-24 01:03:31 -05:00
parent 2e1485afb1
commit cbdd277184
11 changed files with 486 additions and 2 deletions

View File

@ -5,7 +5,9 @@ A free and open-source 2D multimedia editor combining vector animation, audio pr
## Screenshots ## Screenshots
![Animation View](screenshots/animation.png) ![Animation View](screenshots/animation.png)
![Music Editing View](screenshots/music.png) ![Music Editing View](screenshots/music.png)
![Video Editing View](screenshots/video.png) ![Video Editing View](screenshots/video.png)
## Current Features ## Current Features
@ -28,7 +30,7 @@ A free and open-source 2D multimedia editor combining vector animation, audio pr
- **Frontend:** Vanilla JavaScript - **Frontend:** Vanilla JavaScript
- **Backend:** Rust (Tauri framework) - **Backend:** Rust (Tauri framework)
- **Audio:** cpal + dasp for audio processing - **Audio:** cpal + dasp for audio processing
- **Video:** FFmpeg for decode - **Video:** FFmpeg for encode/decode
## Project Status ## Project Status

View File

@ -457,6 +457,7 @@ dependencies = [
"dasp_rms", "dasp_rms",
"dasp_sample", "dasp_sample",
"dasp_signal", "dasp_signal",
"hound",
"midir", "midir",
"midly", "midly",
"pathdiff", "pathdiff",
@ -578,6 +579,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hound"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "1.9.3" version = "1.9.3"

View File

@ -16,6 +16,11 @@ rand = "0.8"
base64 = "0.22" base64 = "0.22"
pathdiff = "0.2" 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 # Node-based audio graph dependencies
dasp_graph = "0.11" dasp_graph = "0.11"
dasp_signal = "0.11" dasp_signal = "0.11"

View File

@ -1619,6 +1619,14 @@ impl Engine {
None => QueryResponse::PoolFileInfo(Err(format!("Pool index {} not found", pool_index))), 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 // Send response back
@ -2556,4 +2564,25 @@ impl EngineController {
Err("Query timeout".to_string()) Err("Query timeout".to_string())
} }
/// Export audio to a file
pub fn export_audio<P: AsRef<std::path::Path>>(&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())
}
} }

View File

@ -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<P: AsRef<Path>>(
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<Vec<f32>, 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<P: AsRef<Path>>(
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<P: AsRef<Path>>(
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");
}
}

View File

@ -3,6 +3,7 @@ pub mod bpm_detector;
pub mod buffer_pool; pub mod buffer_pool;
pub mod clip; pub mod clip;
pub mod engine; pub mod engine;
pub mod export;
pub mod metronome; pub mod metronome;
pub mod midi; pub mod midi;
pub mod node_graph; pub mod node_graph;
@ -16,6 +17,7 @@ pub use automation::{AutomationLane, AutomationLaneId, AutomationPoint, CurveTyp
pub use buffer_pool::BufferPool; pub use buffer_pool::BufferPool;
pub use clip::{Clip, ClipId}; pub use clip::{Clip, ClipId};
pub use engine::{Engine, EngineController}; pub use engine::{Engine, EngineController};
pub use export::{export_audio, ExportFormat, ExportSettings};
pub use metronome::Metronome; pub use metronome::Metronome;
pub use midi::{MidiClip, MidiClipId, MidiEvent}; pub use midi::{MidiClip, MidiClipId, MidiEvent};
pub use pool::{AudioFile as PoolAudioFile, AudioPool}; pub use pool::{AudioFile as PoolAudioFile, AudioPool};

View File

@ -253,6 +253,8 @@ pub enum Query {
GetPoolWaveform(usize, usize), GetPoolWaveform(usize, usize),
/// Get file info from audio pool (pool_index) - returns (duration, sample_rate, channels) /// Get file info from audio pool (pool_index) - returns (duration, sample_rate, channels)
GetPoolFileInfo(usize), GetPoolFileInfo(usize),
/// Export audio to file (settings, output_path)
ExportAudio(crate::audio::ExportSettings, std::path::PathBuf),
} }
/// Oscilloscope data from a node /// Oscilloscope data from a node
@ -310,4 +312,6 @@ pub enum QueryResponse {
PoolWaveform(Result<Vec<crate::io::WaveformPeak>, String>), PoolWaveform(Result<Vec<crate::io::WaveformPeak>, String>),
/// Pool file info (duration, sample_rate, channels) /// Pool file info (duration, sample_rate, channels)
PoolFileInfo(Result<(f64, u32, u32), String>), PoolFileInfo(Result<(f64, u32, u32), String>),
/// Audio exported
AudioExported(Result<(), String>),
} }

7
src-tauri/Cargo.lock generated
View File

@ -1102,6 +1102,7 @@ dependencies = [
"dasp_rms", "dasp_rms",
"dasp_sample", "dasp_sample",
"dasp_signal", "dasp_signal",
"hound",
"midir", "midir",
"midly", "midly",
"pathdiff", "pathdiff",
@ -1939,6 +1940,12 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hound"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
[[package]] [[package]]
name = "html5ever" name = "html5ever"
version = "0.26.0" version = "0.26.0"

View File

@ -1656,3 +1656,45 @@ pub async fn audio_load_track_graph(
Err("Audio not initialized".to_string()) Err("Audio not initialized".to_string())
} }
} }
#[tauri::command]
pub async fn audio_export(
state: tauri::State<'_, Arc<Mutex<AudioState>>>,
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())
}
}

View File

@ -303,6 +303,7 @@ pub fn run() {
audio::audio_resolve_missing_file, audio::audio_resolve_missing_file,
audio::audio_serialize_track_graph, audio::audio_serialize_track_graph,
audio::audio_load_track_graph, audio::audio_load_track_graph,
audio::audio_export,
video::video_load_file, video::video_load_file,
video::video_get_frame, video::video_get_frame,
video::video_get_frames_batch, video::video_get_frames_batch,

View File

@ -3212,6 +3212,124 @@ async function render() {
document.querySelector("body").style.cursor = "default"; 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 = `
<style>
#export-format option,
#export-sample-rate option,
#export-bit-depth option {
background: #333 !important;
color: #eee !important;
}
</style>
<h2 style="margin-top: 0;">Export Audio</h2>
<div style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 5px;">Format:</label>
<select id="export-format" style="width: 100%; padding: 5px; background: var(--input-bg, #333); color: var(--text-color, #eee); border: 1px solid var(--border-color, #555); border-radius: 4px;">
<option value="wav">WAV</option>
<option value="flac">FLAC</option>
</select>
</div>
<div style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 5px;">Sample Rate:</label>
<select id="export-sample-rate" style="width: 100%; padding: 5px; background: var(--input-bg, #333); color: var(--text-color, #eee); border: 1px solid var(--border-color, #555); border-radius: 4px;">
<option value="44100">44100 Hz</option>
<option value="48000" selected>48000 Hz</option>
<option value="96000">96000 Hz</option>
</select>
</div>
<div style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 5px;">Bit Depth:</label>
<select id="export-bit-depth" style="width: 100%; padding: 5px; background: var(--input-bg, #333); color: var(--text-color, #eee); border: 1px solid var(--border-color, #555); border-radius: 4px;">
<option value="16">16-bit</option>
<option value="24" selected>24-bit</option>
</select>
</div>
<div style="margin-bottom: 15px;">
<label style="display: block; margin-bottom: 5px;">End Time (seconds):</label>
<input type="number" id="export-end-time" value="${duration.toFixed(2)}" min="0.1" step="0.1" style="width: 100%; padding: 5px; background: var(--input-bg, #333); color: var(--text-color, #eee); border: 1px solid var(--border-color, #555); border-radius: 4px;">
</div>
<div style="display: flex; gap: 10px; justify-content: flex-end;">
<button id="export-cancel" style="padding: 8px 16px; background: var(--button-bg, #444); color: var(--text-color, #eee); border: 1px solid var(--border-color, #555); border-radius: 4px; cursor: pointer;">Cancel</button>
<button id="export-ok" style="padding: 8px 16px; background: var(--primary-color, #0078d7); color: white; border: none; border-radius: 4px; cursor: pointer;">Export</button>
</div>
`;
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) { function updateScrollPosition(zoomFactor) {
if (context.mousePos) { if (context.mousePos) {
for (let canvas of canvases) { for (let canvas of canvases) {
@ -6726,11 +6844,16 @@ async function renderMenu() {
accelerator: getShortcut("import"), accelerator: getShortcut("import"),
}, },
{ {
text: "Export...", text: "Export Video...",
enabled: true, enabled: true,
action: render, action: render,
accelerator: getShortcut("export"), accelerator: getShortcut("export"),
}, },
{
text: "Export Audio...",
enabled: true,
action: exportAudio,
},
{ {
text: "Quit", text: "Quit",
enabled: true, enabled: true,