microwave 0.33.0

Make xenharmonic music and explore musical tunings.
use std::{
    collections::HashSet,
    fmt::{self, Write},
    ops::Range,
};

use geom::Range as NannouRange;
use nannou::prelude::*;
use tune::{
    math,
    note::Note,
    pitch::{Pitch, Pitched, Ratio},
    scala::KbmRoot,
    tuning::Scale,
};
use tune_cli::shared::midi::TuningMethod;

use crate::{
    control::LiveParameter, fluid::FluidInfo, midi::MidiInfo, synth::WaveformInfo, KeyColor, Model,
};

pub trait ViewModel: Send + 'static {
    fn pitch_range(&self) -> Option<Range<Pitch>>;

    fn write_info(&self, target: &mut String) -> fmt::Result;
}

pub type DynViewModel = Box<dyn ViewModel>;

impl<T: ViewModel> From<T> for DynViewModel {
    fn from(data: T) -> Self {
        Box::new(data)
    }
}

pub fn view(app: &App, model: &Model, frame: Frame) {
    let draw = app.draw();
    let window_rect = app.window_rect();
    let total_range =
        Ratio::between_pitches(model.pitch_at_left_border, model.pitch_at_right_border);
    let octave_width = Ratio::octave().num_equal_steps_of_size(total_range) as f32;

    let kbm_root = model.kbm.kbm_root();
    let selected_tuning = (&model.scl, kbm_root);
    let reference_tuning = (
        &model.reference_scl,
        KbmRoot::from(Note::from_piano_key(kbm_root.ref_key)),
    );

    let render_second_keyboard = !model.scl_key_colors.is_empty();
    let keyboard_rect = if render_second_keyboard {
        Rect::from_w_h(window_rect.w(), window_rect.h() / 4.0)
    } else {
        Rect::from_w_h(window_rect.w(), window_rect.h() / 2.0)
    };
    let lower_keyboard_rect = keyboard_rect.align_bottom_of(window_rect);

    draw.background().color(DIMGRAY);
    render_scale_lines(model, &draw, window_rect, octave_width, selected_tuning);
    render_keyboard(
        model,
        &draw,
        lower_keyboard_rect,
        octave_width,
        reference_tuning,
        |key| get_12edo_key_color(key + kbm_root.ref_key.midi_number()),
    );

    if render_second_keyboard {
        let upper_keyboard_rect = keyboard_rect.above(lower_keyboard_rect);
        render_keyboard(
            model,
            &draw,
            upper_keyboard_rect,
            octave_width,
            selected_tuning,
            |key| {
                model.scl_key_colors[Into::<usize>::into(math::i32_rem_u(
                    key,
                    u16::try_from(model.scl_key_colors.len()).unwrap(),
                ))]
            },
        );
    }

    render_just_ratios_with_deviations(model, &draw, window_rect, octave_width);
    render_recording_indicator(model, &draw, window_rect);
    render_hud(model, &draw, window_rect);
    draw.to_frame(app, &frame).unwrap();
}

fn render_scale_lines(
    model: &Model,
    draw: &Draw,
    window_rect: Rect,
    octave_width: f32,
    tuning: impl Scale,
) {
    let leftmost_degree = tuning
        .find_by_pitch_sorted(model.pitch_at_left_border)
        .approx_value;
    let rightmost_degree = tuning
        .find_by_pitch_sorted(model.pitch_at_right_border)
        .approx_value;

    let pitch_range = model.view_model.as_ref().and_then(|m| m.pitch_range());

    for degree in leftmost_degree..=rightmost_degree {
        let pitch = tuning.sorted_pitch_of(degree);

        let pitch_position = Ratio::between_pitches(model.pitch_at_left_border, pitch).as_octaves()
            as f32
            * octave_width;

        let pitch_position_on_screen = (pitch_position - 0.5) * window_rect.w();

        let line_color = match pitch_range.as_ref().filter(|r| !r.contains(&pitch)) {
            None => GRAY,
            Some(_) => INDIANRED,
        };

        let line_color = match degree {
            0 => SALMON,
            _ => line_color,
        };

        draw.line()
            .start(Point2::new(pitch_position_on_screen, window_rect.top()))
            .end(Point2::new(pitch_position_on_screen, window_rect.bottom()))
            .color(line_color)
            .weight(2.0);
    }
}

fn render_just_ratios_with_deviations(
    model: &Model,
    draw: &Draw,
    window_rect: Rect,
    octave_width: f32,
) {
    let mut freqs_hz = model
        .pressed_keys
        .iter()
        .map(|(_, pressed_key)| pressed_key.pitch)
        .collect::<Vec<_>>();
    freqs_hz.sort_by(|a, b| a.partial_cmp(b).unwrap());

    let mut curr_slice_window = freqs_hz.as_slice();
    while let Some((second, others)) = curr_slice_window.split_last() {
        let pitch_position = Ratio::between_pitches(model.pitch_at_left_border, *second)
            .as_octaves() as f32
            * octave_width;

        let pitch_position_on_screen = (pitch_position - 0.5) * window_rect.w();

        draw.line()
            .start(Point2::new(pitch_position_on_screen, window_rect.top()))
            .end(Point2::new(pitch_position_on_screen, window_rect.bottom()))
            .color(WHITE)
            .weight(2.0);

        let mut curr_rect = Rect {
            x: NannouRange::new(pitch_position_on_screen, pitch_position_on_screen + 1000.0),
            y: NannouRange::from_pos_and_len(0.0, 24.0),
        }
        .align_top_of(window_rect);

        draw.text(&format!("{:.0} Hz", second.as_hz()))
            .xy(curr_rect.xy())
            .wh(curr_rect.wh())
            .left_justify()
            .color(RED)
            .font_size(24);

        for first in others.iter() {
            let approximation =
                Ratio::between_pitches(*first, *second).nearest_fraction(model.odd_limit);

            let width =
                approximation.deviation.as_octaves() as f32 * octave_width * window_rect.w();
            let deviation_bar_rect = Rect {
                x: NannouRange::new(pitch_position_on_screen - width, pitch_position_on_screen),
                y: NannouRange::from_pos_and_len(0.0, 24.0),
            }
            .below(curr_rect);

            draw.rect()
                .xy(deviation_bar_rect.xy())
                .wh(deviation_bar_rect.wh())
                .color(DEEPSKYBLUE);

            let deviation_text_rect = curr_rect.below(curr_rect);

            draw.text(&format!(
                "{}/{} [{:.0}c]",
                approximation.numer,
                approximation.denom,
                approximation.deviation.as_cents().abs()
            ))
            .xy(deviation_text_rect.xy())
            .wh(deviation_text_rect.wh())
            .left_justify()
            .color(BLACK)
            .font_size(24);

            curr_rect = deviation_text_rect;
        }
        curr_slice_window = others;
    }
}

fn render_keyboard(
    model: &Model,
    draw: &Draw,
    rect: Rect,
    octave_width: f32,
    tuning: impl Scale,
    get_key_color: impl Fn(i32) -> KeyColor,
) {
    let highlighted_keys: HashSet<_> = model
        .pressed_keys
        .values()
        .map(|pressed_key| tuning.find_by_pitch_sorted(pressed_key.pitch).approx_value)
        .collect();

    let leftmost_key = tuning
        .find_by_pitch_sorted(model.pitch_at_left_border)
        .approx_value;
    let rightmost_key = tuning
        .find_by_pitch_sorted(model.pitch_at_right_border)
        .approx_value;

    let (mut mid, mut right) = Default::default();

    for iterated_key in (leftmost_key - 1)..=(rightmost_key + 1) {
        let pitch = tuning.sorted_pitch_of(iterated_key);
        let coord = Ratio::between_pitches(model.pitch_at_left_border, pitch).as_octaves() as f32
            * octave_width;

        let left = mid;
        mid = right;
        right = Some(coord);

        if let (Some(left), Some(mid), Some(right)) = (left, mid, right) {
            let drawn_key = iterated_key - 1;

            let mut key_color = match get_key_color(drawn_key) {
                KeyColor::White => LIGHTGRAY,
                KeyColor::Black => BLACK,
                KeyColor::Red => DARKRED,
                KeyColor::Green => FORESTGREEN,
                KeyColor::Blue => MEDIUMBLUE,
                KeyColor::Cyan => LIGHTSEAGREEN,
                KeyColor::Magenta => MEDIUMVIOLETRED,
                KeyColor::Yellow => GOLDENROD,
            }
            .into_format::<f32>()
            .into_linear();

            if highlighted_keys.contains(&drawn_key) {
                let gray = DIMGRAY.into_format::<f32>().into_linear();
                key_color = (key_color + gray * 2.0) / 3.0;
            }

            let pos = (left + right) / 4.0 + mid / 2.0;
            let width = (left - right) / 2.0;

            let key_rect = Rect::from_x_y_w_h(
                rect.left() + pos * rect.w(),
                rect.y(),
                width * rect.w(),
                rect.h(),
            );

            if drawn_key == 0 {
                draw.rect().color(RED).xy(key_rect.xy()).wh(key_rect.wh());
            }

            draw.rect()
                .color(key_color)
                .xy(key_rect.xy())
                .w(0.9 * key_rect.w())
                .h(0.98 * key_rect.h());
        }
    }
}

fn render_recording_indicator(model: &Model, draw: &Draw, window_rect: Rect) {
    let rect = Rect::from_w_h(100.0, 100.0)
        .top_right_of(window_rect)
        .pad(10.0);
    if model.storage.is_active(LiveParameter::Foot) {
        draw.ellipse().xy(rect.xy()).wh(rect.wh()).color(FIREBRICK);
    }
}

fn render_hud(model: &Model, draw: &Draw, window_rect: Rect) {
    let mut hud_text = String::new();

    writeln!(
        hud_text,
        "Scale: {scale}\n\
         Reference Note [Alt+Left/Right]: {ref_note}\n\
         Scale Offset [Left/Right]: {offset:+}",
        scale = model.scl.description(),
        ref_note = model.kbm.kbm_root().ref_key.midi_number(),
        offset = model.kbm.kbm_root().root_offset
    )
    .unwrap();

    if let Some(view_data) = &model.view_model {
        view_data.write_info(&mut hud_text).unwrap();
    }

    let effects = [
        LiveParameter::Sound1,
        LiveParameter::Sound2,
        LiveParameter::Sound3,
        LiveParameter::Sound4,
        LiveParameter::Sound5,
        LiveParameter::Sound6,
        LiveParameter::Sound7,
        LiveParameter::Sound8,
        LiveParameter::Sound9,
        LiveParameter::Sound10,
    ]
    .into_iter()
    .enumerate()
    .filter(|&(_, p)| model.storage.is_active(p))
    .map(|(i, p)| format!("{} (cc {})", i + 1, model.mapper.get_ccn(p).unwrap()))
    .collect::<Vec<_>>();

    writeln!(
        hud_text,
        "Tuning Mode [Alt+T]: {tuning_mode:?}\n\
         Legato [Alt+L]: {legato}\n\
         Effects [F1-F10]: {effects}\n\
         Recording [Space]: {recording}\n\
         Range [Alt+/Scroll]: {from:.0}..{to:.0} Hz",
        tuning_mode = model.tuning_mode,
        effects = effects.join(", "),
        legato = if model.storage.is_active(LiveParameter::Legato) {
            format!(
                "ON (cc {})",
                model.mapper.get_ccn(LiveParameter::Legato).unwrap()
            )
        } else {
            "OFF".to_owned()
        },
        recording = if model.storage.is_active(LiveParameter::Foot) {
            format!(
                "ON (cc {})",
                model.mapper.get_ccn(LiveParameter::Foot).unwrap()
            )
        } else {
            "OFF".to_owned()
        },
        from = model.pitch_at_left_border.as_hz(),
        to = model.pitch_at_right_border.as_hz(),
    )
    .unwrap();

    let hud_rect = window_rect.shift_y(window_rect.h() / 2.0);

    draw.text(&hud_text)
        .xy(hud_rect.xy())
        .wh(hud_rect.wh())
        .align_text_bottom()
        .left_justify()
        .color(LIGHTGREEN)
        .font_size(24);
}

fn get_12edo_key_color(key: i32) -> KeyColor {
    if [1, 3, 6, 8, 10].contains(&key.rem_euclid(12)) {
        KeyColor::Black
    } else {
        KeyColor::White
    }
}

impl ViewModel for WaveformInfo {
    fn pitch_range(&self) -> Option<Range<Pitch>> {
        None
    }

    fn write_info(&self, target: &mut String) -> fmt::Result {
        writeln!(
            target,
            "Output [Alt+O]: Waveform\n\
             Waveform [Up/Down]: {waveform_number} - {waveform_name}\n\
             Envelope [Alt+E]: {envelope_name}{is_default_indicator}",
            waveform_number = self.waveform_number,
            waveform_name = self.waveform_name,
            envelope_name = self.envelope_name,
            is_default_indicator = if self.is_default_envelope {
                ""
            } else {
                " (default) "
            }
        )
    }
}

impl ViewModel for FluidInfo {
    fn pitch_range(&self) -> Option<Range<Pitch>> {
        Some(Note::from_midi_number(0).pitch()..Note::from_midi_number(127).pitch())
    }

    fn write_info(&self, target: &mut String) -> fmt::Result {
        let tuning_method = match self.is_tuned {
            true => "Single Note Tuning Change",
            false => "None. Tuning channels exceeded! Change tuning mode.",
        };

        writeln!(
            target,
            "Output [Alt+O]: Soundfont\n\
             Soundfont File: {soundfont_file}\n\
             Tuning method: {tuning_method}\n\
             Program [Up/Down]: {program_number} - {program_name}",
            soundfont_file = self.soundfont_file_location.as_deref().unwrap_or("Unknown"),
            program_number = self
                .program
                .map(|p| p.to_string())
                .as_deref()
                .unwrap_or("Unknown"),
            program_name = self.program_name.as_deref().unwrap_or("Unknown"),
        )
    }
}

impl ViewModel for MidiInfo {
    fn pitch_range(&self) -> Option<Range<Pitch>> {
        Some(Note::from_midi_number(0).pitch()..Note::from_midi_number(127).pitch())
    }

    fn write_info(&self, target: &mut String) -> fmt::Result {
        let tuning_method = match self.tuning_method {
            Some(TuningMethod::FullKeyboard) => "Single Note Tuning Change",
            Some(TuningMethod::FullKeyboardRt) => "Single Note Tuning Change (realtime)",
            Some(TuningMethod::Octave1) => "Scale/Octave Tuning (1-Byte)",
            Some(TuningMethod::Octave1Rt) => "Scale/Octave Tuning (1-Byte) (realtime)",
            Some(TuningMethod::Octave2) => "Scale/Octave Tuning (2-Byte)",
            Some(TuningMethod::Octave2Rt) => "Scale/Octave Tuning (2-Byte) (realtime)",
            Some(TuningMethod::ChannelFineTuning) => "Channel Fine Tuning",
            Some(TuningMethod::PitchBend) => "Pitch Bend",
            None => "None. Tuning channels exceeded! Change tuning mode.",
        };

        writeln!(
            target,
            "Output [Alt+O]: MIDI\n\
             Device: {device}\n\
             Tuning method: {tuning_method}\n\
             Program [Up/Down]: {program_number}",
            device = self.device,
            program_number = self.program_number,
        )
    }
}

impl ViewModel for () {
    fn pitch_range(&self) -> Option<Range<Pitch>> {
        None
    }

    fn write_info(&self, target: &mut String) -> fmt::Result {
        writeln!(target, "Output [Alt+O]: No Audio")
    }
}