Add audio export
This commit is contained in:
parent
2e1485afb1
commit
cbdd277184
|
|
@ -5,7 +5,9 @@ A free and open-source 2D multimedia editor combining vector animation, audio pr
|
|||
## Screenshots
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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<Vec<crate::io::WaveformPeak>, String>),
|
||||
/// Pool file info (duration, sample_rate, channels)
|
||||
PoolFileInfo(Result<(f64, u32, u32), String>),
|
||||
/// Audio exported
|
||||
AudioExported(Result<(), String>),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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<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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
125
src/main.js
125
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 = `
|
||||
<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) {
|
||||
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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue