Skip to main content

devalang_wasm/services/build/outputs/audio/
builder.rs

1#![cfg(feature = "cli")]
2
3use crate::engine::audio::settings::{AudioBitDepth, AudioChannels, AudioFormat, ResampleQuality};
4use crate::language::syntax::ast::Statement;
5use crate::tools::logger::Logger;
6use anyhow::{Context, Result};
7use std::path::{Path, PathBuf};
8use std::sync::Arc;
9use std::time::{Duration, Instant};
10
11use crate::services::build::outputs::audio::helpers::calculate_rms;
12use crate::services::build::outputs::audio::writer::write_wav;
13
14#[derive(Debug, Clone)]
15pub struct AudioRenderSummary {
16    pub path: PathBuf,
17    pub format: AudioFormat,
18    pub bit_depth: AudioBitDepth,
19    pub rms: f32,
20    pub render_time: Duration,
21    pub audio_length: Duration,
22}
23
24#[derive(Debug, Clone)]
25pub struct MultiFormatRenderSummary {
26    pub primary_path: PathBuf,
27    pub primary_format: AudioFormat,
28    pub exported_formats: Vec<(AudioFormat, PathBuf)>,
29    pub bit_depth: AudioBitDepth,
30    pub rms: f32,
31    pub render_time: Duration,
32    pub audio_length: Duration,
33}
34
35#[derive(Clone)]
36pub struct AudioBuilder {
37    _logger: Arc<Logger>,
38}
39
40impl AudioBuilder {
41    pub fn new(
42        _log_writer: crate::services::build::outputs::logs::LogWriter,
43        logger: Arc<Logger>,
44    ) -> Self {
45        Self { _logger: logger }
46    }
47
48    #[allow(clippy::too_many_arguments)]
49    pub fn render_all_formats(
50        &self,
51        statements: &[Statement],
52        _entry_path: &Path,
53        output_root: &Path,
54        module_name: &str,
55        requested_formats: &[AudioFormat],
56        requested_bit_depth: AudioBitDepth,
57        channels: AudioChannels,
58        sample_rate: u32,
59        _resample: ResampleQuality,
60        bpm: f32,
61    ) -> Result<MultiFormatRenderSummary> {
62        let start = Instant::now();
63
64        let formats = if requested_formats.is_empty() {
65            vec![AudioFormat::Wav]
66        } else {
67            requested_formats.to_vec()
68        };
69
70        let mut summaries = Vec::new();
71        for format in formats {
72            summaries.push(self.render(
73                statements,
74                _entry_path,
75                output_root,
76                module_name,
77                format,
78                requested_bit_depth,
79                channels,
80                sample_rate,
81                ResampleQuality::Sinc24,
82                bpm,
83            )?);
84        }
85
86        let total_time = start.elapsed();
87        let primary = summaries
88            .first()
89            .cloned()
90            .context("no audio formats were rendered")?;
91        let exported = summaries
92            .iter()
93            .map(|summary| (summary.format, summary.path.clone()))
94            .collect();
95
96        Ok(MultiFormatRenderSummary {
97            primary_path: primary.path,
98            primary_format: primary.format,
99            exported_formats: exported,
100            bit_depth: primary.bit_depth,
101            rms: primary.rms,
102            render_time: total_time,
103            audio_length: primary.audio_length,
104        })
105    }
106
107    pub fn render(
108        &self,
109        statements: &[Statement],
110        _entry_path: &Path,
111        output_root: impl AsRef<Path>,
112        module_name: &str,
113        requested_format: AudioFormat,
114        requested_bit_depth: AudioBitDepth,
115        channels: AudioChannels,
116        sample_rate: u32,
117        _resample: ResampleQuality,
118        bpm: f32,
119    ) -> Result<AudioRenderSummary> {
120        use crate::engine::audio::interpreter::driver::AudioInterpreter;
121
122        let mut interpreter = AudioInterpreter::new(sample_rate);
123        // During offline rendering we must not emit prints to stdout/stderr immediately.
124        // Schedule prints into the interpreter event list and (optionally) replay them
125        // in realtime during the render so the user can see PRINT messages as if
126        // the audio was playing.
127        interpreter.suppress_print = true;
128
129        // During offline rendering we schedule prints into the interpreter.events.logs
130        // and write them to a sidecar `.printlog` file for later replay by the
131        // live playback engine. We do NOT replay prints in real-time during the
132        // build here to avoid duplicate prints when the live player replays the
133        // same scheduled logs.
134
135        let buffer = interpreter.interpret(statements)?;
136
137        let output_root = output_root.as_ref();
138        let audio_dir = output_root.join("audio");
139
140        // module_name may be a relative path like "demos/hello-sound/index"
141        // Split it into directory part and file part
142        let module_path = Path::new(module_name);
143        let (output_subdir, file_name) = if let Some(parent) = module_path.parent() {
144            let parent_str = parent.to_string_lossy();
145            if parent_str.is_empty() || parent_str == "." {
146                (String::new(), module_name.to_string())
147            } else {
148                let file_name = module_path
149                    .file_name()
150                    .map(|s| s.to_string_lossy().to_string())
151                    .unwrap_or_else(|| module_name.to_string());
152                (parent_str.to_string(), file_name)
153            }
154        } else {
155            (String::new(), module_name.to_string())
156        };
157
158        let full_audio_dir = if output_subdir.is_empty() {
159            audio_dir.clone()
160        } else {
161            audio_dir.join(&output_subdir)
162        };
163
164        std::fs::create_dir_all(&full_audio_dir).with_context(|| {
165            format!(
166                "failed to create audio output directory: {}",
167                full_audio_dir.display()
168            )
169        })?;
170
171        let output_path = full_audio_dir.join(format!(
172            "{}.{}",
173            file_name,
174            requested_format.file_extension()
175        ));
176
177        let mut rms = 0.0f32;
178        let audio_length = if buffer.is_empty() {
179            Duration::from_secs(0)
180        } else {
181            rms = calculate_rms(&buffer);
182            let channel_count = channels.count() as usize;
183            let frames = if channel_count == 0 {
184                0
185            } else {
186                buffer.len() / channel_count
187            };
188            if sample_rate == 0 {
189                Duration::from_secs(0)
190            } else {
191                Duration::from_secs_f64(frames as f64 / sample_rate as f64)
192            }
193        };
194
195        let applied = match requested_format {
196            AudioFormat::Wav => {
197                if !buffer.is_empty() {
198                    write_wav(
199                        &output_path,
200                        &buffer,
201                        sample_rate,
202                        requested_bit_depth,
203                        channels,
204                    )?
205                } else {
206                    // Even if buffer is empty, create an empty WAV file
207                    write_wav(
208                        &output_path,
209                        &[],
210                        sample_rate,
211                        requested_bit_depth,
212                        channels,
213                    )?
214                }
215            }
216            AudioFormat::Mid => {
217                let midi_bpm = if interpreter.bpm.is_finite() && interpreter.bpm > 0.0 {
218                    interpreter.bpm
219                } else {
220                    bpm
221                };
222                crate::engine::audio::midi::export_midi_file(
223                    &interpreter.events.events,
224                    &output_path,
225                    midi_bpm,
226                )?;
227                requested_bit_depth
228            }
229            AudioFormat::Mp3 | AudioFormat::Flac => {
230                anyhow::bail!(
231                    "{:?} export is not implemented in the CLI build pipeline yet",
232                    requested_format
233                );
234            }
235        };
236
237        // Write scheduled print events sidecar for live playback to consume.
238        let log_path = output_path.with_file_name(format!("{}.printlog", file_name));
239        if !interpreter.events.logs.is_empty() {
240            if let Ok(mut f) = std::fs::File::create(&log_path) {
241                use std::io::Write;
242                for (t, msg) in &interpreter.events.logs {
243                    // time in seconds (float) TAB message NEWLINE
244                    let _ = writeln!(f, "{:.6}\t{}", t, msg);
245                }
246            }
247        }
248
249        Ok(AudioRenderSummary {
250            path: output_path,
251            format: requested_format,
252            bit_depth: applied,
253            rms,
254            render_time: Duration::from_secs(0),
255            audio_length,
256        })
257    }
258}