1use crate::{AudioData, AudioDiff, AudioDiffReporter, audio_extension};
2use askama::Template;
3use image::{ImageError, ImageFormat, Rgba, RgbaImage};
4use semdiff_core::fs::FileLeaf;
5use semdiff_core::{DetailReporter, MayUnsupported};
6use semdiff_output::html::{HtmlReport, HtmlReportError};
7use std::io::Write;
8use thiserror::Error;
9
10const COMPARES_NAME: &str = "audio";
11
12#[derive(Debug, Error)]
13pub enum AudioDiffReportError {
14 #[error("html report error: {0}")]
15 HtmlReport(#[from] HtmlReportError),
16 #[error("image encode error: {0}")]
17 ImageEncode(#[from] ImageError),
18}
19
20#[derive(Template)]
21#[template(path = "audio_preview.html")]
22struct AudioPreviewTemplate {
23 body: AudioPreviewBody,
24}
25
26#[derive(Clone)]
27struct AudioPreviewImage {
28 src: String,
29 label: String,
30 kind: String,
31 width: u32,
32 height: u32,
33}
34
35struct PreviewImageFile {
36 path: String,
37 width: u32,
38 height: u32,
39}
40
41enum AudioPreviewBody {
42 Modified {
43 images: Vec<AudioPreviewImage>,
44 audio_src: String,
45 },
46 Single {
47 images: Vec<AudioPreviewImage>,
48 audio_src: String,
49 },
50}
51
52#[derive(Template)]
53#[template(path = "audio_detail.html")]
54struct AudioDetailTemplate {
55 detail: AudioDetailBody,
56}
57
58#[derive(Clone)]
59struct AudioDetailImage {
60 uri: String,
61 width: u32,
62 height: u32,
63}
64
65#[derive(Clone)]
66struct AudioDetailData {
67 label: String,
68 audio_src: String,
69 waveforms: Vec<AudioDetailImage>,
70 spectrograms: Vec<AudioDetailImage>,
71 sample_rate: u32,
72 channels: u16,
73 duration_seconds: f32,
74}
75
76enum AudioDetailBody {
77 Diff {
78 expected: AudioDetailData,
79 actual: AudioDetailData,
80 spectrogram_diff: Vec<AudioDetailImage>,
81 },
82 Single {
83 data: AudioDetailData,
84 },
85}
86
87impl DetailReporter<AudioDiff, FileLeaf, HtmlReport> for AudioDiffReporter {
88 type Error = AudioDiffReportError;
89
90 fn report_unchanged(
91 &self,
92 name: &str,
93 diff: &AudioDiff,
94 reporter: &HtmlReport,
95 ) -> Result<MayUnsupported<()>, Self::Error> {
96 let expected = diff.expected();
97 let Some(extension) = audio_extension(expected.mime()) else {
98 return Ok(MayUnsupported::Unsupported);
99 };
100 let audio_file = write_audio(reporter, name, "same", extension, expected.content())?;
101 let waveform_files = write_channel_images(reporter, name, "same_waveform", expected.waveform())?;
102 let spectrogram_files = write_channel_images(reporter, name, "same_spectrogram", expected.spectrogram())?;
103 let detail_data = build_detail_data("same", expected, &audio_file, &waveform_files, &spectrogram_files);
104 let preview_image = write_preview_image(reporter, name, "preview_waveform", expected.waveform())?;
105 let preview_images = preview_image
106 .as_ref()
107 .map(|file| build_preview_images(reporter, std::slice::from_ref(file), "waveform"))
108 .unwrap_or_default();
109 let preview_html = AudioPreviewTemplate {
110 body: AudioPreviewBody::Single {
111 images: preview_images,
112 audio_src: reporter.detail_asset_path(&audio_file),
113 },
114 };
115 let detail_html = AudioDetailTemplate {
116 detail: AudioDetailBody::Single { data: detail_data },
117 };
118 reporter.record_unchanged(name, COMPARES_NAME, preview_html, detail_html)?;
119 Ok(MayUnsupported::Ok(()))
120 }
121
122 fn report_modified(
123 &self,
124 name: &str,
125 diff: &AudioDiff,
126 reporter: &HtmlReport,
127 ) -> Result<MayUnsupported<()>, Self::Error> {
128 let expected = diff.expected();
129 let actual = diff.actual();
130 let Some(expected_ext) = audio_extension(expected.mime()) else {
131 return Ok(MayUnsupported::Unsupported);
132 };
133 let Some(actual_ext) = audio_extension(actual.mime()) else {
134 return Ok(MayUnsupported::Unsupported);
135 };
136 let expected_audio = write_audio(reporter, name, "expected", expected_ext, expected.content())?;
137 let actual_audio = write_audio(reporter, name, "actual", actual_ext, actual.content())?;
138 let expected_waveforms = write_channel_images(reporter, name, "expected_waveform", expected.waveform())?;
139 let actual_waveforms = write_channel_images(reporter, name, "actual_waveform", actual.waveform())?;
140 let expected_spectrograms =
141 write_channel_images(reporter, name, "expected_spectrogram", expected.spectrogram())?;
142 let actual_spectrograms = write_channel_images(reporter, name, "actual_spectrogram", actual.spectrogram())?;
143 let spectrogram_diff_detail = if let Some(detail) = diff.diff_detail() {
144 let spectrogram_diffs =
145 write_channel_images(reporter, name, "spectrogram_diff", detail.spectrogram_diff())?;
146
147 build_detail_images(&spectrogram_diffs, detail.spectrogram_diff())
148 } else {
149 Vec::new()
150 };
151
152 let (preview_image, preview_label) = if let Some(detail) = diff.diff_detail() {
153 (
154 write_preview_image(reporter, name, "preview_spectrogram_diff", detail.spectrogram_diff())?,
155 "spectrogram diff",
156 )
157 } else {
158 (
159 write_preview_image(reporter, name, "preview_waveform", actual.waveform())?,
160 "waveform",
161 )
162 };
163 let preview_images = preview_image
164 .as_ref()
165 .map(|file| build_preview_images(reporter, std::slice::from_ref(file), preview_label))
166 .unwrap_or_default();
167 let preview_html = AudioPreviewTemplate {
168 body: AudioPreviewBody::Modified {
169 images: preview_images,
170 audio_src: reporter.detail_asset_path(&actual_audio),
171 },
172 };
173 let detail_html = AudioDetailTemplate {
174 detail: AudioDetailBody::Diff {
175 expected: build_detail_data(
176 "expected",
177 expected,
178 &expected_audio,
179 &expected_waveforms,
180 &expected_spectrograms,
181 ),
182 actual: build_detail_data("actual", actual, &actual_audio, &actual_waveforms, &actual_spectrograms),
183 spectrogram_diff: spectrogram_diff_detail,
184 },
185 };
186 reporter.record_modified(name, COMPARES_NAME, preview_html, detail_html)?;
187 Ok(MayUnsupported::Ok(()))
188 }
189
190 fn report_added(
191 &self,
192 name: &str,
193 data: &FileLeaf,
194 reporter: &HtmlReport,
195 ) -> Result<MayUnsupported<()>, Self::Error> {
196 let Some(extension) = audio_extension(&data.kind) else {
197 return Ok(MayUnsupported::Unsupported);
198 };
199 let Ok(audio_data) = self.build_audio_data(data.kind.clone(), data.content.clone()) else {
200 return Ok(MayUnsupported::Unsupported);
201 };
202 let audio_file = write_audio(reporter, name, "added", extension, audio_data.content())?;
203 let waveform_files = write_channel_images(reporter, name, "added_waveform", audio_data.waveform())?;
204 let spectrogram_files = write_channel_images(reporter, name, "added_spectrogram", audio_data.spectrogram())?;
205 let preview_image = write_preview_image(reporter, name, "preview_waveform", audio_data.waveform())?;
206 let preview_images = preview_image
207 .as_ref()
208 .map(|file| build_preview_images(reporter, std::slice::from_ref(file), "waveform"))
209 .unwrap_or_default();
210 let preview_html = AudioPreviewTemplate {
211 body: AudioPreviewBody::Single {
212 images: preview_images,
213 audio_src: reporter.detail_asset_path(&audio_file),
214 },
215 };
216 let detail_html = AudioDetailTemplate {
217 detail: AudioDetailBody::Single {
218 data: build_detail_data("added", &audio_data, &audio_file, &waveform_files, &spectrogram_files),
219 },
220 };
221 reporter.record_added(name, COMPARES_NAME, preview_html, detail_html)?;
222 Ok(MayUnsupported::Ok(()))
223 }
224
225 fn report_deleted(
226 &self,
227 name: &str,
228 data: &FileLeaf,
229 reporter: &HtmlReport,
230 ) -> Result<MayUnsupported<()>, Self::Error> {
231 let Some(extension) = audio_extension(&data.kind) else {
232 return Ok(MayUnsupported::Unsupported);
233 };
234 let Ok(audio_data) = self.build_audio_data(data.kind.clone(), data.content.clone()) else {
235 return Ok(MayUnsupported::Unsupported);
236 };
237 let audio_file = write_audio(reporter, name, "deleted", extension, audio_data.content())?;
238 let waveform_files = write_channel_images(reporter, name, "deleted_waveform", audio_data.waveform())?;
239 let spectrogram_files = write_channel_images(reporter, name, "deleted_spectrogram", audio_data.spectrogram())?;
240 let preview_image = write_preview_image(reporter, name, "preview_waveform", audio_data.waveform())?;
241 let preview_images = preview_image
242 .as_ref()
243 .map(|file| build_preview_images(reporter, std::slice::from_ref(file), "waveform"))
244 .unwrap_or_default();
245 let preview_html = AudioPreviewTemplate {
246 body: AudioPreviewBody::Single {
247 images: preview_images,
248 audio_src: reporter.detail_asset_path(&audio_file),
249 },
250 };
251 let detail_html = AudioDetailTemplate {
252 detail: AudioDetailBody::Single {
253 data: build_detail_data("deleted", &audio_data, &audio_file, &waveform_files, &spectrogram_files),
254 },
255 };
256 reporter.record_deleted(name, COMPARES_NAME, preview_html, detail_html)?;
257 Ok(MayUnsupported::Ok(()))
258 }
259}
260
261fn build_detail_data(
262 label: &str,
263 data: &AudioData,
264 audio_uri: &str,
265 waveform_uris: &[String],
266 spectrogram_uris: &[String],
267) -> AudioDetailData {
268 AudioDetailData {
269 label: label.to_string(),
270 audio_src: audio_uri.to_string(),
271 waveforms: build_detail_images(waveform_uris, data.waveform()),
272 spectrograms: build_detail_images(spectrogram_uris, data.spectrogram()),
273 sample_rate: data.sample_rate(),
274 channels: data.channels(),
275 duration_seconds: data.duration_seconds(),
276 }
277}
278
279fn build_preview_images(
280 reporter: &HtmlReport,
281 image_files: &[PreviewImageFile],
282 label_prefix: &str,
283) -> Vec<AudioPreviewImage> {
284 let kind = label_prefix.replace(' ', "-");
285 image_files
286 .iter()
287 .enumerate()
288 .map(|(index, file)| AudioPreviewImage {
289 src: reporter.detail_asset_path(&file.path),
290 label: format!("{label_prefix} ch{}", index + 1),
291 kind: kind.clone(),
292 width: file.width,
293 height: file.height,
294 })
295 .collect()
296}
297
298fn write_preview_image(
299 reporter: &HtmlReport,
300 name: &str,
301 label: &str,
302 images: &[RgbaImage],
303) -> Result<Option<PreviewImageFile>, HtmlReportError> {
304 let Some(merged) = merge_channel_images(images) else {
305 return Ok(None);
306 };
307 let width = merged.width();
308 let height = merged.height();
309 let path = write_image(reporter, name, label, &merged)?;
310 Ok(Some(PreviewImageFile { path, width, height }))
311}
312
313fn merge_channel_images(images: &[RgbaImage]) -> Option<RgbaImage> {
314 let first = images.first()?;
315 let width = first.width();
316 let height = first.height();
317 let mut merged = RgbaImage::from_pixel(width, height, Rgba([255, 255, 255, 0]));
318 for image in images {
319 for y in 0..height {
320 for x in 0..width {
321 let pixel = image.get_pixel(x, y);
322 let current = merged.get_pixel(x, y);
323 merged.put_pixel(
324 x,
325 y,
326 Rgba([
327 current[0].max(pixel[0]),
328 current[1].max(pixel[1]),
329 current[2].max(pixel[2]),
330 current[3].max(pixel[3]),
331 ]),
332 );
333 }
334 }
335 }
336 Some(merged)
337}
338
339fn build_detail_images(image_files: &[String], images: &[RgbaImage]) -> Vec<AudioDetailImage> {
340 let count = image_files.len().min(images.len());
341 (0..count)
342 .map(|index| AudioDetailImage {
343 uri: image_files[index].clone(),
344 width: images[index].width(),
345 height: images[index].height(),
346 })
347 .collect()
348}
349
350fn write_channel_images(
351 reporter: &HtmlReport,
352 name: &str,
353 label_prefix: &str,
354 images: &[RgbaImage],
355) -> Result<Vec<String>, HtmlReportError> {
356 let mut files = Vec::with_capacity(images.len());
357 for (index, image) in images.iter().enumerate() {
358 let label = format!("{label_prefix}_ch{}", index + 1);
359 files.push(write_image(reporter, name, &label, image)?);
360 }
361 Ok(files)
362}
363
364fn write_image(reporter: &HtmlReport, name: &str, label: &str, image: &RgbaImage) -> Result<String, HtmlReportError> {
365 reporter.write_detail_asset(name, label, "png", |w| match image.write_to(w, ImageFormat::Png) {
366 Ok(()) => Ok(()),
367 Err(ImageError::IoError(err)) => Err(err.into()),
368 Err(err) => panic!("Unexpected error writing audio image: {}", err),
369 })
370}
371
372fn write_audio(
373 reporter: &HtmlReport,
374 name: &str,
375 label: &str,
376 extension: &str,
377 content: &[u8],
378) -> Result<String, HtmlReportError> {
379 reporter.write_detail_asset(name, label, extension, |w| {
380 w.write_all(content)?;
381 Ok(())
382 })
383}