quadio-core 0.2.0

QUADIO common library
Documentation
use cuet::{ChunkWriter, CuePoint, LabeledText};
use hound::{WavSpec, WavWriter};
use std::fs::OpenOptions;
use std::io::{BufWriter, Read, Seek, SeekFrom};
use std::ops::Range;
use std::path::Path;

// (Presumed) minimum audible frequency
const MIN_FREQ: u32 = 40u32;

#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum SampleFmt {
    Unsigned8,
    Signed16,
}

pub struct Project {
    samples: Vec<i16>,
    sample_rate: u32,
    sample_loop: Option<Range<u32>>,
    render_format: SampleFmt,
}

impl Project {
    pub fn from_reader<R: Read + Seek>(
        mut reader: crate::QWaveReader<R>,
    ) -> Result<Self, String> {
        let (samples, metadata) =
            { (reader.collect_samples()?, reader.metadata()) };

        let sample_loop = metadata
            .loop_start
            .map(|start| -> Result<_, std::num::TryFromIntError> {
                if let Some(end) = metadata.end {
                    Ok(start..end)
                } else {
                    Ok(start..samples.len().try_into()?)
                }
            })
            .transpose()
            .map_err(|e| e.to_string())?;

        let sample_fmt = if metadata.bits_per_sample == 8 {
            SampleFmt::Unsigned8
        } else if metadata.bits_per_sample == 16 {
            SampleFmt::Signed16
        } else {
            return Err(String::from("beans"));
        };

        Ok(Project {
            samples,
            sample_rate: metadata.sample_rate,
            sample_loop,
            render_format: sample_fmt,
        })
    }

    pub fn set_loop(&mut self, sample_loop: Option<Range<u32>>) {
        self.sample_loop = sample_loop;
    }

    pub fn sample_rate(&self) -> u32 {
        self.sample_rate
    }

    pub fn samples(&self) -> &[i16] {
        &self.samples
    }

    pub fn blend(&mut self, window_sz: u32) -> Result<(), String> {
        self.validate()?;

        if let Some(sample_loop) = &self.sample_loop {
            let loop_width = sample_loop.end - sample_loop.start;

            if loop_width == 0 {
                return Err(String::from("Invalid loop"));
            }

            if window_sz > sample_loop.start {
                return Err(String::from(
                    "Insufficient lead before loop for blend",
                ));
            }

            if window_sz > loop_width {
                return Err(String::from("Blend window longer than loop"));
            }

            let window_a_start =
                sample_loop.start as usize - window_sz as usize;
            let window_b_start = sample_loop.end as usize - window_sz as usize;

            for i in 0..window_sz as usize {
                let weight = cube_step(i as f64 / f64::from(window_sz));
                let sample_a = self.samples[i + window_a_start] as f64;
                let sample_b = self.samples[i + window_b_start] as f64;
                let new_sample = weight * sample_a + (1.0 - weight) * sample_b;
                self.samples[i + window_b_start] = new_sample.round() as i16;
            }
        } else {
            return Err(String::from("No loop to blend"));
        }

        self.pad_loop();

        // hound can produce WAV files with odd-sized RIFF chunks; AFAIK such
        // files are malformed, so manually pad with a duplicate sample if
        // samples are an odd number of bytes
        if self.render_format == SampleFmt::Unsigned8
            && self.samples.len() & 1 == 1
        {
            self.samples.push(self.samples[self.samples.len() - 1]);
        }

        Ok(())
    }

    pub fn blend_default_window(&mut self) -> Result<(), String> {
        self.blend(self.min_window_size())
    }

    pub fn write_to(&self, outpath: &impl AsRef<Path>) -> Result<(), String> {
        let outfile = OpenOptions::new()
            .read(true)
            .write(true)
            .create(true)
            .truncate(true)
            .open(outpath)
            .map_err(|e| e.to_string())?;
        let mut writer = BufWriter::new(outfile);

        let wave_spec = WavSpec {
            channels: 1,
            sample_format: hound::SampleFormat::Int,
            sample_rate: self.sample_rate,
            bits_per_sample: match self.render_format {
                SampleFmt::Unsigned8 => 8,
                SampleFmt::Signed16 => 16,
            },
        };

        {
            let mut wav_writer = WavWriter::new(&mut writer, wave_spec)
                .map_err(|e| e.to_string())?;

            let samples = self.samples.iter().map(match self.render_format {
                SampleFmt::Unsigned8 => |&s| s >> 8,
                SampleFmt::Signed16 => |&s| s,
            });

            for s in samples {
                wav_writer.write_sample(s).map_err(|e| e.to_string())?;
            }

            wav_writer.finalize().map_err(|e| e.to_string())?;
        }

        let mut outfile = writer.into_inner().map_err(|e| e.to_string())?;

        if let Some(sample_loop) = &self.sample_loop {
            outfile
                .seek(SeekFrom::Start(0))
                .map_err(|e| e.to_string())?;

            let mut chunk_writer =
                ChunkWriter::new(outfile).map_err(|e| e.to_string())?;

            let cue = [CuePoint::from_sample_offset(0, sample_loop.start)];
            chunk_writer
                .append_cue_chunk(&cue)
                .map_err(|e| e.to_string())?;

            if self
                .samples
                .len()
                .try_into()
                .map(|len: u32| len != sample_loop.end)
                .unwrap_or(true)
            {
                let length = sample_loop
                    .end
                    .checked_sub(sample_loop.start)
                    .ok_or("Loop ends before it begins")?;

                let labeled_text = [LabeledText::from_cue_length(0, length)];
                chunk_writer
                    .append_label_chunk(&labeled_text)
                    .map_err(|e| e.to_string())?;
            }
        }

        Ok(())
    }

    pub fn validate(&self) -> Result<(), String> {
        let len: u32 = self
            .samples
            .len()
            .try_into()
            .map_err(|_| "Too many samples")?;

        if len == 0 {
            return Err(String::from("No audio samples"));
        }

        if let Some(sample_loop) = &self.sample_loop {
            if sample_loop.end > len {
                return Err(String::from("Loop extends beyond file end"));
            }

            if sample_loop.end < sample_loop.start {
                return Err(String::from("Loop ends before it begins"));
            }

            if sample_loop.end == sample_loop.start {
                return Err(String::from("Loop length is 0 samples"));
            }
        }

        Ok(())
    }

    // Precondition: self.sample_loop is Some(_)
    fn pad_loop(&mut self) {
        let sample_loop = self.sample_loop.clone().unwrap();
        let extra_sample_ct = self.min_window_size();
        let loop_width = sample_loop.end - sample_loop.start;
        self.samples.truncate(sample_loop.end as usize);

        let extra_samples = (0..extra_sample_ct)
            .map(|index| {
                let index = ((index % loop_width) + sample_loop.start) as usize;
                self.samples[index]
            })
            .collect::<Vec<i16>>();

        self.samples.extend(extra_samples);
    }

    fn min_window_size(&self) -> u32 {
        self.sample_rate / MIN_FREQ
    }
}

fn cube_step(t: f64) -> f64 {
    t * t * (3.0 - 2.0 * t)
}