use std::collections::{BTreeMap, BTreeSet};

/// Top-level song with tempo, sound definitions, and layers.
#[derive(Debug, Clone)]
pub struct Song {
    /// Starting tempo in beats per minute.
    pub tempo: f32,
    /// Tempo multiplier reached at the song's end (`1.0` = steady). The tempo
    /// rises linearly with the beat from `tempo` to `tempo × tempo_factor`, so
    /// its relative growth is constant over time (an exponential accelerando).
    /// Written as `@tempo <start> <end>`, stored as the factor so a re-entry at
    /// a new start tempo scales the end with it.
    pub tempo_factor: f32,
    /// `.track` files imported for their sounds and motifs.
    pub imports: Vec<Import>,
    /// All sounds available to tracks (locally defined and imported).
    pub sounds: BTreeMap<String, Sound>,
    /// Names of sounds that were defined directly in this song's source
    /// (not pulled in via `@import`).
    pub local_sound_names: BTreeSet<String>,
    /// Named motif definitions, each a sound-less list of notes.
    pub motifs: BTreeMap<String, Vec<MotifNote>>,
    /// Named scale definitions available to layers via `@tuning`.
    pub tunings: BTreeMap<String, Tuning>,
    /// Named custom waveforms (`# Waves` sections): one cycle drawn as chained
    /// vertices over `x = 0..=1`, played by `wave <name> <weight>` harmonics.
    pub waves: BTreeMap<String, Vec<WavePoint>>,
    /// For each imported wave, the path/URL of the file that defined it.
    pub wave_origins: BTreeMap<String, String>,
    /// For each imported sound, the path/URL of the file that defined it.
    /// Locally defined sounds have no entry. Filled by `resolve_imports`.
    pub sound_origins: BTreeMap<String, String>,
    /// For each imported motif, the path/URL of the file that defined it.
    pub motif_origins: BTreeMap<String, String>,
    /// For each imported tuning, the path/URL of the file that defined it.
    pub tuning_origins: BTreeMap<String, String>,
    /// Default values for named parameters (`@param`); overridable at playtime.
    pub params: BTreeMap<String, f32>,
    /// Simultaneously-mixed layers in display order.
    pub layers: Vec<Layer>,
    /// Explicit song length in beats (`@length`); `None` = derive from content.
    pub length: Option<f32>,
    /// Time signature (`@signature`), for bar grouping; default 4/4. Metadata
    /// only — playback counts beats regardless.
    pub signature: TimeSignature,
    /// Where to continue when the song ends (`@jump`), in order; the first whose
    /// condition holds is taken (an unconditional jump is a fallback). Empty =
    /// stop at song end.
    pub jumps: Vec<Jump>,
    /// Human-readable title (`# Meta` → `title`), for song browsers and pickers.
    /// Not used in playback.
    pub title: Option<String>,
    /// Human-readable description (`# Meta` → `description`). Not used in
    /// playback.
    pub description: Option<String>,
    /// Author or credit (`# Meta` → `author`). Not used in playback.
    pub author: Option<String>,
    /// Free-form tags (`# Meta` → `## Tags`, one per line) for categorizing and
    /// filtering songs. Not used in playback.
    pub tags: Vec<String>,
    /// Timed subtitle lines (`# Subtitles`), sorted by beat. An entry with empty
    /// text clears the previous one. Not used in playback; query the active line
    /// with `SongPlayer::current_subtitle`.
    pub subtitles: Vec<Subtitle>,
    /// Sections whose header this parser did not recognize, kept verbatim so
    /// consumers can round-trip unknown content.
    pub unknown: Vec<UnknownSection>,
}

/// One timed subtitle line: `text` is shown from `beat` until the next entry.
/// An entry with empty `text` is a clear marker (nothing shown after it).
#[derive(Debug, Clone, PartialEq)]
pub struct Subtitle {
    /// Beat at which this line appears.
    pub beat: f32,
    /// The line to show; empty clears the previous subtitle.
    pub text: String,
}

/// How a note attaches to the previous note on the same sound.
///
/// Written as a leading `'` in the note's text (`"'lo"`). Voice sounds sing a
/// legato run as one connected phrase; the attached note also continues the
/// previous note's word (no fresh word onset).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Articulation {
    /// Starts fresh — a new phrase and word.
    #[default]
    Separate,
    /// Attaches to the previous note on the same sound.
    Legato,
}

/// One vertex of a custom waveform in `(x, y)` coordinates, where `x` is the
/// phase within one cycle (`0..=1`) and `y` the amplitude (`-1..1`).
///
/// Vertices are sorted by `x` and chained: each spans to the next, so the
/// table is a function (one `y` per `x`) by construction. The last vertex
/// wraps to the first, closing the cycle seamlessly; a vertex at `x = 1`
/// instead holds a flat tail up to the period boundary. `curve` shapes the
/// span from this vertex to the next. A vertical jump (square, pulse) is two
/// vertices at the same `x`; the sample at that `x` takes the later one. The
/// default curve is `ease`, so a sine-like tone is three eased vertices and a
/// triangle the same marked `linear`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct WavePoint {
    /// Phase within one cycle (`0..=1`).
    pub x: f32,
    /// Amplitude at this vertex (`-1..1`).
    pub y: f32,
    /// Interpolation shape from this vertex to the next.
    pub curve: Curve,
}

/// Renders one cycle of a vertex-based wave into `resolution` samples.
///
/// Each sample lands on the segment `[x_i, x_{i+1})` containing its phase; the
/// region past the last vertex wraps to the first. An empty list yields an
/// empty buffer, a single vertex a constant.
pub fn sample_wave(points: &[WavePoint], resolution: usize) -> Vec<f32> {
    match points {
        [] => return Vec::new(),
        [single] => return vec![single.y; resolution],
        _ => {}
    }
    (0..resolution)
        .map(|index| sample_wave_at(points, index as f32 / resolution as f32))
        .collect()
}

/// Value of a chained, periodic wave at phase `x` in `0..1`.
fn sample_wave_at(points: &[WavePoint], x: f32) -> f32 {
    for pair in points.windows(2) {
        let &[start, end] = pair else { continue };
        if start.x <= x && x < end.x {
            return interpolate_vertex(start, end, x, end.x - start.x);
        }
    }
    let &[.., last] = points else { return 0.0 };
    let first = points[0];
    let span = first.x + 1.0 - last.x;
    let phase = if x < first.x { x + 1.0 } else { x };
    interpolate_vertex(last, first, phase, span)
}

fn interpolate_vertex(start: WavePoint, end: WavePoint, x: f32, span: f32) -> f32 {
    let ratio = if span > 0.0 {
        (x - start.x) / span
    } else {
        1.0
    };
    start.y + (end.y - start.y) * start.curve.apply(ratio)
}

/// Samples a non-periodic envelope of chained [`WavePoint`]s at `x` in `0..1`.
///
/// Unlike [`sample_wave`], the ends clamp rather than wrap: before the
/// first vertex holds its `y`, past the last holds its `y`. Shares the
/// per-segment easing with the wave sampler. An empty list is 0.
pub fn sample_envelope(points: &[WavePoint], x: f32) -> f32 {
    let [first, .., last] = points else {
        return points.first().map_or(0.0, |point| point.y);
    };
    if x <= first.x {
        return first.y;
    }
    for pair in points.windows(2) {
        let &[start, end] = pair else { continue };
        if x < end.x {
            return interpolate_vertex(start, end, x, end.x - start.x);
        }
    }
    last.y
}

/// Maps beat positions to seconds under a possibly-changing tempo.
///
/// The tempo rises linearly with the beat from `start` to `start × factor` over
/// `total_beats`, which makes its relative growth constant over time (from ×1
/// to ×4 the tempo doubles at the halfway *time* and doubles again). A `factor`
/// of 1 is a steady tempo. Both directions have a closed form, so scheduling a
/// note and seeking are cheap.
#[derive(Debug, Clone, Copy)]
pub struct Tempo {
    start: f32,
    factor: f32,
    total_beats: f32,
}

impl Tempo {
    /// A tempo curve from `start` bpm, reaching `start × factor` after
    /// `total_beats`.
    pub fn new(start: f32, factor: f32, total_beats: f32) -> Self {
        Self {
            start: start.max(1.0),
            factor: factor.max(0.001),
            total_beats: total_beats.max(f32::EPSILON),
        }
    }

    fn beats_per_second(self) -> f32 {
        self.start / 60.0
    }

    fn is_steady(self) -> bool {
        (self.factor - 1.0).abs() < 1e-4
    }

    /// Seconds elapsed from the song start to `beat`.
    pub fn seconds_at(self, beat: f32) -> f32 {
        let rate = self.beats_per_second();
        if self.is_steady() {
            return beat / rate;
        }
        let slope = self.factor - 1.0;
        self.total_beats / (rate * slope) * (beat * slope / self.total_beats).ln_1p()
    }

    /// The beat playing at `seconds` from the song start (inverse of
    /// [`Tempo::seconds_at`]).
    pub fn beat_at(self, seconds: f32) -> f32 {
        let rate = self.beats_per_second();
        if self.is_steady() {
            return seconds * rate;
        }
        let slope = self.factor - 1.0;
        self.total_beats / slope * (seconds * rate * slope / self.total_beats).exp_m1()
    }

    /// Seconds a note spans from `start` for `duration` beats (tempo-aware, so a
    /// note late in an accelerando is shorter than the same duration early on).
    pub fn span_seconds(self, start: f32, duration: f32) -> f32 {
        self.seconds_at(start + duration) - self.seconds_at(start)
    }
}

/// A time signature (`@signature`): how beats group into bars.
///
/// Metadata for consumers that draw bar lines (editors, notation); playback
/// counts beats and ignores it. Default is 4/4.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TimeSignature {
    /// Beats in one bar (the numerator).
    pub beats_per_bar: u32,
    /// Note value that gets one beat (the denominator: 4 = quarter, 8 = eighth).
    pub beat_unit: u32,
}

impl Default for TimeSignature {
    fn default() -> Self {
        Self {
            beats_per_bar: 4,
            beat_unit: 4,
        }
    }
}

/// Where a song continues when it reaches its end.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Jump {
    /// Path or URL of the target `.track` file (a self-reference loops).
    pub target: String,
    /// Beats to skip at the start of the target on arrival.
    pub skip: f32,
    /// Condition to take this jump (`None` = unconditional fallback).
    pub condition: Option<Condition>,
}

/// A comparison deciding whether a conditional jump is taken.
///
/// Read-only: the `.track` file cannot assign to any of these, keeping jumps a
/// finite-state graph rather than a program.
#[derive(Debug, Clone, PartialEq)]
pub struct Condition {
    /// What is being compared.
    pub source: ConditionSource,
    /// The comparison operator.
    pub compare: Compare,
    /// The constant compared against.
    pub value: f32,
}

/// The quantity a [`Condition`] tests.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConditionSource {
    /// A named playtime parameter (`@param`, set by the host).
    Param(String),
    /// How many times the current song has already played (starts at 0).
    Plays,
    /// A fresh uniform random draw in `0.0..1.0` each time it is evaluated.
    Random,
}

/// Comparison operators for a [`Condition`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Compare {
    /// Strictly less than.
    Less,
    /// Less than or equal.
    LessEqual,
    /// Strictly greater than.
    Greater,
    /// Greater than or equal.
    GreaterEqual,
    /// Equal.
    Equal,
    /// Not equal.
    NotEqual,
}

impl Compare {
    /// Applies the comparison to two values.
    pub fn test(self, left: f32, right: f32) -> bool {
        match self {
            Self::Less => left < right,
            Self::LessEqual => left <= right,
            Self::Greater => left > right,
            Self::GreaterEqual => left >= right,
            Self::Equal => left == right,
            Self::NotEqual => left != right,
        }
    }
}

/// One `@import` reference: a path or URL, optionally forcing a cache refresh.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct Import {
    /// Local path or URL to the imported `.track` file.
    pub path: String,
    /// If true, a cached copy of a URL import is bypassed and re-downloaded.
    pub refresh: bool,
}

/// Whether URL imports may use the local cache or must be re-fetched.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Refresh {
    /// Use a cached copy when present (per-import `refresh` still forces a fetch).
    #[default]
    Cached,
    /// Ignore the cache for every URL import and re-download.
    Force,
}

/// A section with an unrecognized header, preserved line by line.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct UnknownSection {
    /// Header text after the leading `#`, trimmed.
    pub header: String,
    /// Body lines under the header, trimmed, in source order.
    pub lines: Vec<String>,
}

impl Default for Song {
    fn default() -> Self {
        Self {
            tempo: 120.0,
            tempo_factor: 1.0,
            imports: Vec::new(),
            sounds: BTreeMap::new(),
            local_sound_names: BTreeSet::new(),
            motifs: BTreeMap::new(),
            tunings: BTreeMap::new(),
            waves: BTreeMap::new(),
            wave_origins: BTreeMap::new(),
            sound_origins: BTreeMap::new(),
            motif_origins: BTreeMap::new(),
            tuning_origins: BTreeMap::new(),
            params: BTreeMap::new(),
            layers: Vec::new(),
            length: None,
            signature: TimeSignature::default(),
            jumps: Vec::new(),
            title: None,
            description: None,
            author: None,
            tags: Vec::new(),
            subtitles: Vec::new(),
            unknown: Vec::new(),
        }
    }
}

impl Song {
    /// Returns true when the named sound is defined locally (not imported).
    pub fn is_local_sound(&self, name: &str) -> bool {
        self.local_sound_names.contains(name)
    }

    /// Multiplies every layer's volume by `factor`.
    pub fn with_volume(mut self, factor: f32) -> Self {
        for layer in &mut self.layers {
            layer.volume *= factor;
        }
        self
    }

    /// The song's tempo curve mapping beats to seconds (see [`Tempo`]).
    pub fn tempo_map(&self) -> Tempo {
        Tempo::new(self.tempo, self.tempo_factor, self.length())
    }

    /// Length of the song in beats: the explicit `@length` if set, otherwise
    /// the end of the latest note or clip across all layers (loops ignored).
    pub fn length(&self) -> f32 {
        if let Some(length) = self.length {
            return length;
        }
        self.layers
            .iter()
            .map(|layer| self.layer_content_end(layer))
            .fold(0.0, f32::max)
    }

    fn layer_content_end(&self, layer: &Layer) -> f32 {
        let notes = layer
            .notes
            .iter()
            .map(|note| note.start.time + note.duration());
        let motifs = layer.motif_instances.iter().map(|instance| {
            let motif_end = self.motifs.get(&instance.name).map_or(0.0, |notes| {
                notes
                    .iter()
                    .map(|note| note.start.time + note.duration())
                    .fold(0.0, f32::max)
            });
            instance.start + motif_end
        });
        notes.chain(motifs).fold(0.0, f32::max)
    }
}

/// The standard built-in waveform names, always available as tables without an
/// import. A `# Waves` definition of the same name overrides one.
pub const STANDARD_WAVEFORMS: [&str; 6] =
    ["sine", "triangle", "square", "saw", "soft_square", "hill"];

/// Samples one cycle of a built-in standard waveform into `resolution` values.
///
/// Returns `None` for an unknown name (see [`STANDARD_WAVEFORMS`]). The samples
/// are exact — each comes straight from its formula.
pub fn standard_waveform(name: &str, resolution: usize) -> Option<Vec<f32>> {
    use crate::waveform::{hill, saw, soft_square, square, triangle};
    let shape: fn(f32) -> f32 = match name {
        "sine" => f32::sin,
        "triangle" => triangle,
        "square" => square,
        "saw" => saw,
        "soft_square" => soft_square,
        "hill" => hill,
        _ => return None,
    };
    Some(
        (0..resolution)
            .map(|index| shape(std::f32::consts::TAU * index as f32 / resolution as f32))
            .collect(),
    )
}

/// A minimal built-in instrument for a standard waveform name.
///
/// Lets a note name `sine`/`saw`/… with no sound defined: a single harmonic of
/// that waveform with a gentle click-free envelope. `None` for other names; a
/// `# Sounds` definition (or import) of the same name overrides it.
pub fn default_instrument(name: &str) -> Option<Sound> {
    if !STANDARD_WAVEFORMS.contains(&name) {
        return None;
    }
    Some(Sound {
        harmonics: vec![Harmonic::wave(name, 1.0, 1.0)],
        attack: Envelope {
            duration: 0.004,
            curve: Curve::LINEAR,
        },
        decay: Envelope {
            duration: 0.04,
            curve: Curve::default(),
        },
        volume: 0.4,
        ..Sound::default()
    })
}

/// One spectral component of a sound.
///
/// The harmonic plays at `multiplier × fundamental_frequency`, scaled by
/// `weight`. Multiple harmonics sum to form the timbre.
#[derive(Debug, Clone)]
pub struct Harmonic {
    /// Frequency ratio relative to the note's fundamental
    /// (1.0 = fundamental, 2.0 = octave, 1.5 = fifth, 0.5 = sub).
    pub multiplier: f32,
    /// Mix weight (relative loudness within the timbre).
    pub weight: f32,
    /// Pulse width in `0..1` (0 = off). When set, the component plays a pulse
    /// of that duty instead of `wave`; NES-style values are 0.125/0.25/0.5.
    pub duty: f32,
    /// Waveform name: a built-in ([`STANDARD_WAVEFORMS`]) or a `# Waves`
    /// entry. Resolved when the sound is played.
    pub wave: String,
}

impl Harmonic {
    /// A harmonic playing the named waveform at `multiplier × fundamental`.
    pub fn wave(name: impl Into<String>, multiplier: f32, weight: f32) -> Self {
        Self {
            multiplier,
            weight,
            duty: 0.0,
            wave: name.into(),
        }
    }
}

/// Interpolation shape for a ramp, as independent easing at each end.
///
/// Easing flattens the tangent at that end (sinusoidal). No easing is linear;
/// easing both ends gives a full S-curve. The default is smooth (both ends
/// eased), since a linear ramp sounds harsh on most envelopes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Curve {
    /// Smooth (flat-tangent) start.
    pub ease_in: bool,
    /// Smooth (flat-tangent) end.
    pub ease_out: bool,
}

impl Default for Curve {
    fn default() -> Self {
        Self::SMOOTH
    }
}

impl Curve {
    /// A straight ramp (no easing at either end).
    pub const LINEAR: Self = Self {
        ease_in: false,
        ease_out: false,
    };
    /// An S-curve (both ends eased). The default.
    pub const SMOOTH: Self = Self {
        ease_in: true,
        ease_out: true,
    };

    /// Shapes a linear `0..1` ratio along this curve (sinusoidal easing at
    /// each flagged end).
    pub fn apply(self, ratio: f32) -> f32 {
        use std::f32::consts::{FRAC_PI_2, PI};
        match (self.ease_in, self.ease_out) {
            (false, false) => ratio,
            (false, true) => (ratio * FRAC_PI_2).sin(),
            (true, false) => 1.0 - (ratio * FRAC_PI_2).cos(),
            (true, true) => 0.5 * (1.0 - (ratio * PI).cos()),
        }
    }
}

/// One envelope segment: how long it takes and what shape its ramp has.
#[derive(Debug, Clone, Copy, Default)]
pub struct Envelope {
    /// Length of the segment in seconds.
    pub duration: f32,
    /// Ramp shape.
    pub curve: Curve,
}

/// A slow oscillation modulating pitch (vibrato) or amplitude (tremolo).
///
/// A `depth` of 0 means no modulation (the default).
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Modulation {
    /// Oscillation rate in Hz.
    pub rate: f32,
    /// Modulation depth (0 = off). For vibrato in semitones, tremolo in 0..1.
    pub depth: f32,
    /// Waveform name shaping the oscillation: a built-in ([`STANDARD_WAVEFORMS`])
    /// or a `# Waves` entry. Empty defaults to `sine`.
    pub wave: String,
}

/// Initial pitch offset that glides to the target pitch over a fixed time.
///
/// A `time` of 0 means no slide (the default).
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct Slide {
    /// Starting offset in semitones relative to the note's pitch.
    pub amount: f32,
    /// Time in seconds to reach the target pitch (0 = off).
    pub time: f32,
}

/// Waveform mix and envelope describing one synthesizable instrument voice.
#[derive(Debug, Clone)]
pub struct Sound {
    /// Spectral components summed to produce the voice.
    pub harmonics: Vec<Harmonic>,
    /// Tanh-saturation amount applied to the mixed signal.
    pub distortion: f32,
    /// Attack envelope at note start (`duration` 0 = no fade-in).
    pub attack: Envelope,
    /// Decay envelope fading out *within* the note, anchored so it reaches
    /// zero at the note end (`duration` 0 = no fade-out).
    pub decay: Envelope,
    /// Release envelope ringing out *past* the note end, from whatever level
    /// the note ends at down to zero (`duration` 0 = the note stops at its
    /// end). Piano/pad-style tails; combines with `decay` multiplicatively.
    pub release: Envelope,
    /// Sustain level held between attack and decay (1.0 = full).
    pub sustain: f32,
    /// White-noise weight mixed alongside the harmonics (0.0 = none).
    pub noise: f32,
    /// Pitch modulation (`depth` 0 = no vibrato).
    pub vibrato: Modulation,
    /// Amplitude modulation (`depth` 0 = no tremolo).
    pub tremolo: Modulation,
    /// Initial pitch glide (`time` 0 = none).
    pub slide: Slide,
    /// Voice volume multiplier applied after envelope and distortion.
    pub volume: f32,
    /// Time-quantization of each waveform period into `steps` equal stairs
    /// (GB/C64 wave-channel character; 0 = continuous, maximum quality).
    /// Applies to the synthesized harmonics, not to samples.
    pub steps: u32,
    /// Sample zones making this a sampler sound (empty = synthesized).
    /// A note picks the zone covering its pitch and plays that file, with
    /// pitch as playback speed (`0` = original; `12` = double speed/octave
    /// up). Sample notes ignore the note duration — the file plays out.
    pub samples: Vec<SampleZone>,
    /// Chip-style arpeggio cycling pitch offsets over held notes (empty
    /// offsets = off).
    pub arpeggio: Arpeggio,
    /// Random pitch spread in semitones (`0` = off): each note is detuned by a
    /// value in `±detune`, so repeats of the same sound never sound identical
    /// (footsteps, hits). Deterministic per note when rendering a song.
    pub detune: f32,
    /// Random volume spread as a fraction (`0` = off): each note's volume is
    /// scaled by `1 ± volume_jitter`.
    pub volume_jitter: f32,
    /// Singing-voice preset name (empty = regular synth sound). Notes played
    /// with a voice sound sing their `text` via `phoneme-synth` (feature
    /// `speech`); the synth fields above are ignored except `volume`.
    pub voice: String,
    /// Continuous voice parameters overriding the preset's values, applied in
    /// order (`voice female tilt 0.6 vibrato 0.5`).
    pub voice_overrides: Vec<VoiceOverride>,
    /// Number of singers when the voice is sung as an ensemble/choir (`voice
    /// female ensemble 6`); `0` or `1` is a solo voice. Each singer is a varied
    /// take of the phrase, mixed for a thick choral sound (feature `speech`).
    pub voice_ensemble: usize,
}

/// One continuous voice parameter of a singing-voice preset.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VoiceKnob {
    /// Vocal-tract length factor (1.0 male, 1.17 female, 1.35 child).
    FormantShift,
    /// Source rolloff 0..1: higher = darker/softer.
    Tilt,
    /// Aspiration noise mixed in, 0..1.
    Breathiness,
    /// Depth of the ~5 Hz pitch waver, 0..1.
    Vibrato,
}

/// Overrides one continuous voice parameter on top of the sound's preset.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct VoiceOverride {
    /// Which parameter to override.
    pub knob: VoiceKnob,
    /// The replacement value.
    pub value: f32,
}

impl Default for Sound {
    fn default() -> Self {
        Self {
            harmonics: Vec::new(),
            distortion: 0.0,
            attack: Envelope::default(),
            decay: Envelope::default(),
            release: Envelope::default(),
            sustain: 1.0,
            noise: 0.0,
            vibrato: Modulation::default(),
            tremolo: Modulation::default(),
            slide: Slide::default(),
            volume: 1.0,
            steps: 0,
            samples: Vec::new(),
            arpeggio: Arpeggio::default(),
            detune: 0.0,
            volume_jitter: 0.0,
            voice: String::new(),
            voice_overrides: Vec::new(),
            voice_ensemble: 0,
        }
    }
}

/// One file of a sampler sound, covering a pitch range.
///
/// Written as `sample "<file>" [@ <low>..<high>]` in a sound definition;
/// without a range the zone covers every pitch. Multiple zones let one
/// instrument switch files per register instead of stretching a single
/// recording across the whole keyboard.
#[derive(Debug, Clone, PartialEq)]
pub struct SampleZone {
    /// Audio file path, relative to the song file.
    pub file: String,
    /// Inclusive pitch range `[low, high]` this zone covers
    /// (`-infinity..infinity` when written without a range).
    pub pitches: [f32; 2],
}

impl SampleZone {
    /// A zone for `file` covering every pitch.
    pub fn full_range(file: impl Into<String>) -> Self {
        Self {
            file: file.into(),
            pitches: [f32::NEG_INFINITY, f32::INFINITY],
        }
    }

    /// Returns true when the zone covers every pitch (no `@` range written).
    pub fn covers_all(&self) -> bool {
        self.pitches == [f32::NEG_INFINITY, f32::INFINITY]
    }
}

/// A chip-style arpeggio playing a chord on a single voice.
///
/// The note's pitch cycles through `offsets` at `rate` steps per second
/// (`arpeggio 0 4 7 rate 30` cycles a major triad). Empty `offsets` disables
/// it.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Arpeggio {
    /// Pitch offsets in semitones, cycled in order.
    pub offsets: Vec<f32>,
    /// Steps per second.
    pub rate: f32,
}

impl Sound {
    /// Classic chiptune lead: triangle-heavy with subtle harmonics.
    pub fn chiptune() -> Self {
        Self {
            harmonics: vec![
                Harmonic::wave("triangle", 1.0, 0.6),
                Harmonic::wave("soft_square", 1.0, 0.2),
                Harmonic::wave("sine", 2.0, 0.1),
                Harmonic::wave("triangle", 0.5, 0.1),
            ],
            volume: 0.05,
            ..Self::default()
        }
    }

    /// Overdriven guitar with rich harmonics.
    pub fn guitar() -> Self {
        Self {
            harmonics: vec![
                Harmonic::wave("sine", 1.0, 0.3),
                Harmonic::wave("triangle", 1.0, 0.3),
                Harmonic::wave("soft_square", 1.0, 0.2),
                Harmonic::wave("sine", 2.0, 0.15),
                Harmonic::wave("sine", 1.5, 0.1),
            ],
            distortion: 3.0,
            volume: 0.05,
            ..Self::default()
        }
    }

    /// Deep bass: sine-heavy with sub-octave warmth.
    pub fn bass() -> Self {
        Self {
            harmonics: vec![
                Harmonic::wave("sine", 1.0, 0.5),
                Harmonic::wave("triangle", 1.0, 0.3),
                Harmonic::wave("triangle", 0.5, 0.2),
            ],
            volume: 0.06,
            ..Self::default()
        }
    }

    /// Buzzy bass: distorted with grit.
    pub fn bass_dirty() -> Self {
        Self {
            harmonics: vec![
                Harmonic::wave("sine", 1.0, 0.4),
                Harmonic::wave("triangle", 1.0, 0.2),
                Harmonic::wave("soft_square", 1.0, 0.1),
                Harmonic::wave("triangle", 0.5, 0.3),
            ],
            distortion: 2.0,
            volume: 0.05,
            ..Self::default()
        }
    }
}

/// One layer in a song: a simultaneously-mixed voice with mix settings,
/// patterns, audio clips, and effects.
#[derive(Debug, Clone)]
pub struct Layer {
    /// Display name.
    pub name: String,
    /// Per-layer volume multiplier (1.0 = unity). When `volume_param` is set,
    /// this is the starting value the parameter binding then overrides.
    pub volume: f32,
    /// Binds the layer volume to a playtime parameter (`@volume param <name>`),
    /// so the host fades the layer by setting the parameter. `None` = the
    /// static `volume` above.
    pub volume_param: Option<VolumeBinding>,
    /// Stereo pan from -1.0 (left) to 1.0 (right).
    pub pan: f32,
    /// Layer is silenced.
    pub muted: bool,
    /// Layer is in solo mode (silences all non-solo layers).
    pub solo: bool,
    /// If set, the layer's content repeats every N beats.
    pub loop_length: Option<f32>,
    /// Beats skipped at the start of every repeat after the first full pass
    /// (0 = loop the whole thing). Only meaningful with `loop_length`.
    pub loop_skip: f32,
    /// Synthesized notes on this layer, each carrying its own sound.
    pub notes: Vec<Note>,
    /// Motif placements on this layer (references, resolved at render time).
    pub motif_instances: Vec<MotifInstance>,
    /// Effects applied to the rendered layer buffer in order.
    pub effects: Vec<Effect>,
    /// Name of the tuning to interpret pitches with (`None` = 12-TET default).
    pub tuning: Option<String>,
    /// Frequency in Hz of pitch 0 (the scale root); default 440 (A4).
    pub root: f32,
}

/// Binds a layer's volume to a playtime parameter for vertical layering.
///
/// The effective volume is `param_value × scale` (clamped at 0), evaluated
/// continuously so the host fades a layer in and out by setting the parameter
/// (combat intensity, depth, tension). The parameter's `@param` default gives
/// the volume before the host sets anything.
#[derive(Debug, Clone, PartialEq)]
pub struct VolumeBinding {
    /// Name of the playtime parameter driving the volume.
    pub param: String,
    /// Multiplier applied to the parameter value (1.0 = use it directly).
    pub scale: f32,
}

/// Audio effect applied to a layer's rendered output.
#[derive(Debug, Clone, Copy)]
pub enum Effect {
    /// One-pole lowpass at the given cutoff in Hz.
    Lowpass {
        /// Cutoff frequency in Hz.
        cutoff: f32,
    },
    /// One-pole highpass at the given cutoff in Hz.
    Highpass {
        /// Cutoff frequency in Hz.
        cutoff: f32,
    },
    /// Echo with delay (seconds), feedback (0..1), and wet/dry mix (0..1).
    Echo {
        /// Delay length in seconds.
        delay: f32,
        /// Feedback fraction fed back into the delay line.
        feedback: f32,
        /// Wet mix (0 = dry, 1 = full echo).
        mix: f32,
    },
    /// Tremolo: amplitude modulation by a sine LFO.
    Tremolo {
        /// Rate of the LFO in Hz.
        rate: f32,
        /// Modulation depth (0 = off, 1 = full).
        depth: f32,
    },
    /// Bitcrusher: quantize samples to `bits` levels.
    Bitcrusher {
        /// Bit depth (1..16).
        bits: u8,
    },
}

impl Layer {
    /// Creates an empty layer with the given name and default mix settings.
    pub fn new(name: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            volume: 1.0,
            volume_param: None,
            pan: 0.0,
            muted: false,
            solo: false,
            loop_length: None,
            loop_skip: 0.0,
            notes: Vec::new(),
            motif_instances: Vec::new(),
            effects: Vec::new(),
            tuning: None,
            root: 440.0,
        }
    }
}

/// A pitch/time pair: the absolute anchor of a note's start, or the relative
/// advance of a [`NoteSegment`].
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct NoteInfo {
    /// Pitch in semitones: relative to A4 (0 = 440Hz, 12 = 880Hz) as an
    /// anchor, relative to the previous trajectory point as an offset.
    pub pitch: f32,
    /// Time in beats: the absolute position as an anchor, the span length as
    /// an offset.
    pub time: f32,
}

/// One span of a note's pitch trajectory: an eased ramp by `offset.pitch`
/// semitones over `offset.time` beats from where the previous span ended.
///
/// A zero-time segment is an instant jump, mirroring same-`x` wave vertices.
#[derive(Debug, Clone, Copy, PartialEq, Default)]
pub struct NoteSegment {
    /// Ramp shape of this span.
    pub ease: Curve,
    /// Pitch/time advance relative to the previous trajectory point.
    pub offset: NoteInfo,
}

impl NoteSegment {
    /// A flat span holding the current pitch for `time` beats.
    pub fn hold(time: f32) -> Self {
        Self {
            ease: Curve::default(),
            offset: NoteInfo { pitch: 0.0, time },
        }
    }
}

/// Total length in beats of a pitch trajectory (segments plus end).
fn trajectory_duration(segments: &[NoteSegment], end: NoteSegment) -> f32 {
    segments
        .iter()
        .map(|segment| segment.offset.time)
        .sum::<f32>()
        + end.offset.time
}

/// Renders a pitch trajectory as a normalized envelope: semitone offsets from
/// the anchor over `x` 0..1, sampleable with [`sample_envelope`]. A steady
/// trajectory (no segments, flat end) is the empty envelope.
fn trajectory_curve(segments: &[NoteSegment], end: NoteSegment) -> Vec<WavePoint> {
    if segments.is_empty() && end.offset.pitch == 0.0 {
        return Vec::new();
    }
    let total = trajectory_duration(segments, end);
    let normalize = |time: f32| if total > 0.0 { time / total } else { 0.0 };
    let mut points = Vec::with_capacity(segments.len() + 2);
    let mut pitch = 0.0;
    let mut time = 0.0;
    for span in segments.iter().chain(std::iter::once(&end)) {
        points.push(WavePoint {
            x: normalize(time),
            y: pitch,
            curve: span.ease,
        });
        pitch += span.offset.pitch;
        time += span.offset.time;
    }
    points.push(WavePoint {
        x: normalize(time),
        y: pitch,
        curve: Curve::LINEAR,
    });
    // A jump at the very start collapses into the first vertex: the envelope
    // holds its first `y` at the start, so the pre-jump anchor would shadow it.
    if points.len() >= 2 && points[1].x == 0.0 {
        points.remove(0);
    }
    points
}

/// Rebuilds a pitch trajectory of `duration` beats from a normalized envelope
/// (the inverse of [`trajectory_curve`]). A leading vertex above/below the
/// anchor becomes an instant jump; regions outside the vertices hold flat.
fn trajectory_from_curve(points: &[WavePoint], duration: f32) -> (Vec<NoteSegment>, NoteSegment) {
    let mut spans = Vec::new();
    let mut pitch = 0.0;
    let mut time = 0.0;
    for (index, point) in points.iter().enumerate() {
        let target = point.x * duration;
        if index == 0 {
            // The envelope holds its first y before the first vertex.
            if point.y != 0.0 {
                spans.push(NoteSegment {
                    ease: Curve::LINEAR,
                    offset: NoteInfo {
                        pitch: point.y,
                        time: 0.0,
                    },
                });
            }
            if target > 0.0 {
                spans.push(NoteSegment::hold(target));
            }
        } else {
            spans.push(NoteSegment {
                ease: points[index - 1].curve,
                offset: NoteInfo {
                    pitch: point.y - pitch,
                    time: target - time,
                },
            });
        }
        pitch = point.y;
        time = target;
    }
    if time < duration || spans.is_empty() {
        spans.push(NoteSegment::hold(duration - time));
    }
    let end = spans.pop().unwrap_or_else(|| NoteSegment::hold(duration));
    (spans, end)
}

/// One synthesized note on a track.
///
/// The note's pitch trajectory is its start anchor plus a chain of eased
/// spans: intermediate `segments` followed by the mandatory `end`. A plain
/// note is a single flat `end` span of the note's length; a glide is an `end`
/// span with a pitch offset; multi-step bends add `segments`. The duration is
/// the sum of all span times ([`duration`](Self::duration)).
#[derive(Debug, Clone, PartialEq)]
pub struct Note {
    /// Name of the sound to synthesize this note with.
    pub sound: String,
    /// Anchor: pitch in semitones relative to A4 and the absolute beat
    /// position where the note starts.
    pub start: NoteInfo,
    /// Final span of the pitch trajectory.
    pub end: NoteSegment,
    /// Intermediate trajectory spans before `end` (empty = plain note/glide).
    pub segments: Vec<NoteSegment>,
    /// Per-note volume multiplier (1.0 = unchanged).
    pub volume: f32,
    /// Volume breakpoints over the note as factors on `volume`, in normalized
    /// time (`x` 0..1); `y` 1 = `volume`. Empty = steady, a `[0,1]→[1,f]` pair
    /// = a ramp to `f ×` the volume. Sampled with [`sample_envelope`].
    pub volume_curve: Vec<WavePoint>,
    /// Sung syllable in `phoneme-synth` notation (empty = instrumental note).
    /// Only takes effect when the note's sound is a voice sound.
    pub text: String,
    /// Whether the note attaches legato to the previous note on the same
    /// sound (a leading `'` in the source text).
    pub articulation: Articulation,
    /// Per-note arpeggio overriding the sound's for this note (empty offsets =
    /// use the sound's arpeggio).
    pub arpeggio: Arpeggio,
}

impl Default for Note {
    fn default() -> Self {
        Self {
            sound: String::new(),
            start: NoteInfo::default(),
            end: NoteSegment::hold(0.0),
            segments: Vec::new(),
            volume: 1.0,
            volume_curve: Vec::new(),
            text: String::new(),
            articulation: Articulation::Separate,
            arpeggio: Arpeggio::default(),
        }
    }
}

impl Note {
    /// Length in beats: the sum of all trajectory span times.
    pub fn duration(&self) -> f32 {
        trajectory_duration(&self.segments, self.end)
    }

    /// The pitch trajectory as a normalized envelope of semitone offsets from
    /// `start.pitch` (`x` 0..1), for sampling with [`sample_envelope`]. Empty
    /// when the note is steady.
    pub fn pitch_curve(&self) -> Vec<WavePoint> {
        trajectory_curve(&self.segments, self.end)
    }

    /// Replaces the pitch trajectory from a normalized envelope, keeping the
    /// note's duration.
    pub fn set_pitch_curve(&mut self, points: &[WavePoint]) {
        (self.segments, self.end) = trajectory_from_curve(points, self.duration());
    }
}

/// One note inside a motif definition.
///
/// Like [`Note`] but without a sound: the sound comes from the
/// [`MotifInstance`] that places the motif on a track. Its `start.time` is
/// relative to the motif start.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct MotifNote {
    /// Anchor: pitch in semitones relative to A4 and the beat position
    /// relative to the motif start.
    pub start: NoteInfo,
    /// Final span of the pitch trajectory (see [`Note::end`]).
    pub end: NoteSegment,
    /// Intermediate trajectory spans before `end`.
    pub segments: Vec<NoteSegment>,
    /// Per-note volume multiplier (1.0 = unchanged).
    pub volume: f32,
    /// Volume breakpoints over the note as factors on `volume` (see
    /// [`Note::volume_curve`]).
    pub volume_curve: Vec<WavePoint>,
    /// Sung syllable in `phoneme-synth` notation (empty = instrumental note).
    /// Only takes effect when the instance's sound is a voice sound.
    pub text: String,
    /// Whether the note attaches legato to the previous note (leading `'`).
    pub articulation: Articulation,
    /// Per-note arpeggio overriding the instance sound's for this note (empty
    /// offsets = use the sound's arpeggio).
    pub arpeggio: Arpeggio,
}

impl MotifNote {
    /// Length in beats: the sum of all trajectory span times.
    pub fn duration(&self) -> f32 {
        trajectory_duration(&self.segments, self.end)
    }

    /// The pitch trajectory as a normalized envelope (see
    /// [`Note::pitch_curve`]).
    pub fn pitch_curve(&self) -> Vec<WavePoint> {
        trajectory_curve(&self.segments, self.end)
    }

    /// Replaces the pitch trajectory from a normalized envelope, keeping the
    /// note's duration.
    pub fn set_pitch_curve(&mut self, points: &[WavePoint]) {
        (self.segments, self.end) = trajectory_from_curve(points, self.duration());
    }
}

/// Places a named motif on a track with a chosen sound and transposition.
///
/// The instance keeps a reference to the motif by `name` rather than copying
/// its notes, so editing the motif definition updates every instance.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct MotifInstance {
    /// Name of the motif in [`Song::motifs`].
    pub name: String,
    /// Absolute beat position where the motif starts.
    pub start: f32,
    /// Sound to synthesize the motif's notes with.
    pub sound: String,
    /// Semitones added to every note's pitch.
    pub transpose: f32,
    /// Tuning to render this instance with, overriding the layer's tuning
    /// (`None` = use the layer's tuning). Enables per-instance scale shifts
    /// (e.g. the same motif in major and minor).
    pub tuning: Option<String>,
    /// Per-note value overrides for this placement, addressed by note index
    /// (see [`NoteOverride`]). An override for a missing index is ignored.
    pub overrides: Vec<NoteOverride>,
    /// Keeps only a contiguous range of the motif's notes for this placement
    /// (`None` = all). Trims leading/trailing notes without editing the motif.
    pub trim: Option<Trim>,
}

/// Overrides selected values of one motif note for a single placement.
///
/// Addressed by `index` into the motif's notes (in play order, sorted by
/// start). Each field is `None` when not overridden; otherwise it replaces the
/// motif definition's value before the instance's transpose/start offset apply.
#[derive(Debug, Clone, PartialEq, Default)]
pub struct NoteOverride {
    /// Index of the note to override, in the motif's play order.
    pub index: usize,
    /// Drops the note entirely for this placement (all other override values
    /// are ignored). Structural edits beyond skipping belong on the motif.
    pub skip: bool,
    /// Replacement pitch in semitones (still shifted by the instance transpose).
    pub pitch: Option<f32>,
    /// Replacement start beat relative to the motif (still offset by the start).
    pub start: Option<f32>,
    /// Replacement length in beats.
    pub duration: Option<f32>,
    /// Replacement glide in semitones (relative, unaffected by transpose). A
    /// full [`pitch_curve`](Self::pitch_curve) takes precedence over this.
    pub glide: Option<f32>,
    /// Replacement multi-point pitch curve (offsets in semitones over the note),
    /// overriding both the motif note's curve and `glide` when set.
    pub pitch_curve: Option<Vec<WavePoint>>,
    /// Replacement per-note volume multiplier.
    pub volume: Option<f32>,
    /// Replacement sung syllable (voice sounds).
    pub text: Option<String>,
}

/// A contiguous run of a motif's notes to keep in one placement.
///
/// `from` is the first note index (play order) to keep; `count` is how many.
/// Notes outside the run are dropped for that placement only.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Trim {
    /// First note index to keep (play order).
    pub from: usize,
    /// Number of notes to keep.
    pub count: usize,
}

impl MotifNote {
    /// Resolves this motif note into a playable [`Note`] using the instance's
    /// sound, start offset, and transposition. `overrides`, if given, replaces
    /// this note's values before the instance transform applies.
    pub fn resolve(&self, instance: &MotifInstance, overrides: Option<&NoteOverride>) -> Note {
        let pitch = overrides.and_then(|o| o.pitch).unwrap_or(self.start.pitch);
        let start = overrides.and_then(|o| o.start).unwrap_or(self.start.time);
        let duration = overrides
            .and_then(|o| o.duration)
            .unwrap_or_else(|| self.duration());
        let (segments, end) = if let Some(points) = overrides.and_then(|o| o.pitch_curve.as_deref())
        {
            trajectory_from_curve(points, duration)
        } else if let Some(glide) = overrides.and_then(|o| o.glide) {
            trajectory_from_curve(&ramp_curve(glide, Curve::default()), duration)
        } else if overrides.and_then(|o| o.duration).is_some() {
            // A changed duration stretches the note's own trajectory.
            trajectory_from_curve(&self.pitch_curve(), duration)
        } else {
            (self.segments.clone(), self.end)
        };
        let volume = overrides.and_then(|o| o.volume).unwrap_or(self.volume);
        Note {
            sound: instance.sound.clone(),
            start: NoteInfo {
                pitch: pitch + instance.transpose,
                time: instance.start + start,
            },
            end,
            segments,
            volume,
            volume_curve: self.volume_curve.clone(),
            text: overrides
                .and_then(|o| o.text.clone())
                .unwrap_or_else(|| self.text.clone()),
            articulation: self.articulation,
            arpeggio: self.arpeggio.clone(),
        }
    }
}

/// Builds a two-point offset envelope `[0,0] → [1, offset]` (a glide).
/// A zero offset yields an empty curve (steady).
pub fn ramp_curve(offset: f32, curve: Curve) -> Vec<WavePoint> {
    if offset == 0.0 {
        return Vec::new();
    }
    vec![
        WavePoint {
            x: 0.0,
            y: 0.0,
            curve,
        },
        WavePoint {
            x: 1.0,
            y: offset,
            curve: Curve::LINEAR,
        },
    ]
}

/// The two-point offset `[0,0] → [1, g]` of an envelope written as a plain
/// glide/ramp, or `None` when it is empty or a richer multi-point curve.
pub fn ramp_offset(points: &[WavePoint]) -> Option<(f32, Curve)> {
    match points {
        [] => Some((0.0, Curve::default())),
        [start, end] if start.x == 0.0 && start.y == 0.0 && (end.x - 1.0).abs() < f32::EPSILON => {
            Some((end.y, start.curve))
        }
        _ => None,
    }
}

/// Builds a two-point factor envelope `[0,1] → [1, target]` (a volume ramp:
/// from the note's volume to `target ×` it). A target of 1 yields an empty
/// curve (steady).
pub fn volume_ramp(target: f32, curve: Curve) -> Vec<WavePoint> {
    if target == 1.0 {
        return Vec::new();
    }
    vec![
        WavePoint {
            x: 0.0,
            y: 1.0,
            curve,
        },
        WavePoint {
            x: 1.0,
            y: target,
            curve: Curve::LINEAR,
        },
    ]
}

/// The end factor of a volume envelope written as a plain ramp `[0,1] →
/// [1, target]`, or `None` when it is a richer multi-point curve.
pub fn volume_ramp_target(points: &[WavePoint]) -> Option<(f32, Curve)> {
    match points {
        [] => Some((1.0, Curve::default())),
        [start, end] if start.x == 0.0 && start.y == 1.0 && (end.x - 1.0).abs() < f32::EPSILON => {
            Some((end.y, start.curve))
        }
        _ => None,
    }
}

/// A deterministic bit mix (`splitmix32`), turning a seed into a well-spread
/// pseudo-random `u32`.
fn splitmix(seed: u32) -> u32 {
    let mut z = seed.wrapping_add(0x9e37_79b9);
    z = (z ^ (z >> 16)).wrapping_mul(0x21f0_aaad);
    z = (z ^ (z >> 15)).wrapping_mul(0x735a_2d97);
    z ^ (z >> 15)
}

/// Randomized pitch and volume for one sound instance from a seed: a pitch
/// offset in `±detune` semitones and a volume scale of `1 ± volume_jitter`.
/// Same seed → same result (reproducible song renders); a fresh seed per
/// trigger varies runtime one-shots.
pub fn sound_jitter(detune: f32, volume_jitter: f32, seed: u32) -> (f32, f32) {
    let bipolar = |value: u32| (value as f32 / u32::MAX as f32).mul_add(2.0, -1.0);
    let first = splitmix(seed);
    let second = splitmix(first);
    (
        bipolar(first) * detune,
        bipolar(second).mul_add(volume_jitter, 1.0),
    )
}

/// Converts a pitch in semitones (0 = A4) to a frequency in Hz.
pub fn pitch_to_frequency(pitch: f32) -> f32 {
    440.0 * 2.0_f32.powf(pitch / 12.0)
}

/// A musical scale as frequency ratios relative to a root, repeating every
/// `period`.
///
/// `degrees[0]` is the root ratio (always 1.0); each further entry is that
/// scale step's ratio within one period. A pitch is a scale-degree index:
/// integer pitches land on degrees, fractional pitches interpolate
/// geometrically between neighbours. Reaching `degrees.len()` moves up one
/// period. The default is 12-tone equal temperament, so an unset tuning
/// reproduces [`pitch_to_frequency`].
#[derive(Debug, Clone, PartialEq)]
pub struct Tuning {
    /// Ratio between one period and the next (2.0 = octave).
    pub period: f32,
    /// Ratio of each scale degree within a period, starting at 1.0.
    pub degrees: Vec<f32>,
}

impl Default for Tuning {
    fn default() -> Self {
        Self::equal(12)
    }
}

impl Tuning {
    /// Builds an equal-tempered tuning: `steps` equal divisions of the octave.
    pub fn equal(steps: usize) -> Self {
        let steps = steps.max(1);
        let degrees = (0..steps)
            .map(|step| 2.0_f32.powf(step as f32 / steps as f32))
            .collect();
        Self {
            period: 2.0,
            degrees,
        }
    }

    /// Builds a tuning from explicit degree ratios, normalized so the first
    /// degree is 1.0 (writing `220 264 330` is the same as `1 1.2 1.5`).
    pub fn from_ratios(period: f32, ratios: &[f32]) -> Self {
        let base = ratios.first().copied().filter(|r| *r != 0.0).unwrap_or(1.0);
        let degrees = if ratios.is_empty() {
            vec![1.0]
        } else {
            ratios.iter().map(|ratio| ratio / base).collect()
        };
        Self { period, degrees }
    }

    /// Frequency ratio of `pitch` (a scale-degree index) relative to the root.
    pub fn ratio(&self, pitch: f32) -> f32 {
        let len = self.degrees.len().max(1) as f32;
        let period_index = (pitch / len).floor();
        let within = pitch - period_index * len;
        let lower = within.floor();
        let frac = within - lower;
        let lower_ratio = self.degree_ratio(lower as usize);
        let base = self.period.powf(period_index) * lower_ratio;
        if frac == 0.0 {
            return base;
        }
        let upper_ratio = self.step_up_ratio(lower as usize);
        base * (upper_ratio / lower_ratio).powf(frac)
    }

    /// Ratio of degree `index` within one period (index 0..len).
    fn degree_ratio(&self, index: usize) -> f32 {
        self.degrees.get(index).copied().unwrap_or(1.0)
    }

    /// Ratio of the degree above `index`, wrapping into the next period.
    fn step_up_ratio(&self, index: usize) -> f32 {
        if index + 1 < self.degrees.len() {
            self.degrees[index + 1]
        } else {
            self.period * self.degrees.first().copied().unwrap_or(1.0)
        }
    }
}

/// Converts a scale-degree `pitch` to a frequency in Hz under `tuning`,
/// with `root_hz` as the frequency of pitch 0.
pub fn pitch_to_frequency_in(tuning: &Tuning, pitch: f32, root_hz: f32) -> f32 {
    root_hz * tuning.ratio(pitch)
}

/// Returns true if the file name ends in a known audio file extension
/// (`.wav`, `.mp3`, `.ogg`, `.flac`).
pub fn is_audio_filename(name: &str) -> bool {
    let lower = name.to_ascii_lowercase();
    lower.ends_with(".wav")
        || lower.ends_with(".mp3")
        || lower.ends_with(".ogg")
        || lower.ends_with(".flac")
}

#[cfg(test)]
mod tests {
    #![allow(clippy::unwrap_used)]
    use super::{
        Curve, Note, NoteSegment, STANDARD_WAVEFORMS, Tuning, WavePoint, default_instrument,
        pitch_to_frequency, pitch_to_frequency_in, ramp_curve, sample_envelope, sample_wave,
        standard_waveform,
    };

    fn approx(a: f32, b: f32) -> bool {
        (a - b).abs() < 1e-3
    }

    fn vertex(x: f32, y: f32, curve: Curve) -> WavePoint {
        WavePoint { x, y, curve }
    }

    #[test]
    fn note_trajectory_round_trips_through_the_normalized_curve() {
        // Steady note: empty curve, duration from the end span.
        let mut note = Note {
            end: NoteSegment::hold(2.0),
            ..Note::default()
        };
        assert_eq!(note.duration(), 2.0);
        assert!(note.pitch_curve().is_empty());

        // A glide converts to a single end span and back to the same ramp.
        note.set_pitch_curve(&ramp_curve(7.0, Curve::LINEAR));
        assert!(note.segments.is_empty());
        assert_eq!(note.end.offset.pitch, 7.0);
        assert_eq!(note.end.offset.time, 2.0);
        assert_eq!(note.pitch_curve(), ramp_curve(7.0, Curve::LINEAR));

        // Multi-point bend with a leading jump and a flat tail; the trajectory
        // keeps the duration and reproduces the same envelope.
        let bend = [
            vertex(0.0, 4.0, Curve::SMOOTH),
            vertex(0.5, -2.0, Curve::LINEAR),
            vertex(0.75, -2.0, Curve::LINEAR),
        ];
        note.set_pitch_curve(&bend);
        assert_eq!(note.duration(), 2.0);
        // Jump to +4, eased down to -2, then flat to the end.
        assert_eq!(note.segments[0].offset.time, 0.0);
        assert_eq!(note.segments[0].offset.pitch, 4.0);
        let curve = note.pitch_curve();
        for x in [0.0, 0.25, 0.5, 0.75, 1.0] {
            assert!(approx(
                sample_envelope(&curve, x),
                sample_envelope(&bend, x)
            ));
        }
        // A second conversion cycle is exact.
        let again = {
            let mut copy = note.clone();
            copy.set_pitch_curve(&curve);
            copy
        };
        assert_eq!(again, note);
    }

    #[test]
    fn leading_coincident_vertex_canonicalizes_then_stays_stable() {
        let mut note = Note {
            end: NoteSegment::hold(2.0),
            ..Note::default()
        };
        note.set_pitch_curve(&[
            vertex(0.0, 0.0, Curve::LINEAR),
            vertex(0.0, 5.0, Curve::LINEAR),
            vertex(0.6, 3.0, Curve::LINEAR),
            vertex(1.0, 0.0, Curve::LINEAR),
        ]);
        let canonical = note.pitch_curve();
        assert_eq!(
            canonical.iter().map(|p| (p.x, p.y)).collect::<Vec<_>>(),
            [(0.0, 5.0), (0.6, 3.0), (1.0, 0.0)]
        );
        assert!(approx(sample_envelope(&canonical, 0.0), 5.0));
        let again = {
            let mut copy = note.clone();
            copy.set_pitch_curve(&canonical);
            copy
        };
        assert_eq!(again, note);
    }

    #[test]
    fn sine_like_vertices_sample_smoothly() {
        let points = [
            vertex(0.0, 0.0, Curve::SMOOTH),
            vertex(0.25, 1.0, Curve::SMOOTH),
            vertex(0.75, -1.0, Curve::SMOOTH),
        ];
        let samples = sample_wave(&points, 0x100);
        assert_eq!(samples.len(), 0x100);
        assert!(approx(samples[0], 0.0));
        assert!(approx(samples[0x40], 1.0));
        assert!(approx(samples[0xC0], -1.0));
        let max_step = samples
            .windows(2)
            .map(|pair| (pair[1] - pair[0]).abs())
            .fold(0.0f32, f32::max);
        assert!(max_step < 0.05, "sine-like table should be smooth");
    }

    #[test]
    fn coincident_vertices_jump_like_a_square() {
        let points = [
            vertex(0.0, 1.0, Curve::LINEAR),
            vertex(0.5, 1.0, Curve::LINEAR),
            vertex(0.5, -1.0, Curve::LINEAR),
            vertex(1.0, -1.0, Curve::LINEAR),
        ];
        let samples = sample_wave(&points, 0x40);
        assert!(approx(samples[0x10], 1.0));
        assert!(approx(samples[0x30], -1.0));
    }

    #[test]
    fn last_vertex_wraps_to_first() {
        let points = [
            vertex(0.0, 0.0, Curve::LINEAR),
            vertex(0.5, 1.0, Curve::LINEAR),
        ];
        let samples = sample_wave(&points, 0x40);
        assert!(approx(samples[0x20], 1.0));
        assert!(approx(samples[0x30], 0.5));
    }

    #[test]
    fn single_vertex_is_constant() {
        let samples = sample_wave(&[vertex(0.0, 0.3, Curve::LINEAR)], 0x10);
        assert!(samples.iter().all(|&value| approx(value, 0.3)));
    }

    #[test]
    fn envelope_clamps_ends_and_interpolates() {
        let points = [
            vertex(0.0, 0.0, Curve::LINEAR),
            vertex(0.5, 8.0, Curve::LINEAR),
            vertex(1.0, 0.0, Curve::LINEAR),
        ];
        // Before/after the ends hold the first/last value (no wrap).
        assert!(approx(sample_envelope(&points, 0.0), 0.0));
        assert!(approx(sample_envelope(&points, 1.0), 0.0));
        // Linear interpolation between breakpoints.
        assert!(approx(sample_envelope(&points, 0.25), 4.0));
        assert!(approx(sample_envelope(&points, 0.5), 8.0));
        // An empty envelope is 0; a single point is constant.
        assert!(approx(sample_envelope(&[], 0.3), 0.0));
        assert!(approx(
            sample_envelope(&[vertex(0.0, 5.0, Curve::LINEAR)], 0.9),
            5.0
        ));
    }

    #[test]
    fn tempo_ramp_doubles_speed_each_half_time_and_inverts() {
        use super::Tempo;
        // 120 → 480 bpm (×4) over 100 beats.
        let tempo = Tempo::new(120.0, 4.0, 100.0);
        let total = tempo.seconds_at(100.0);
        assert!(approx(tempo.seconds_at(0.0), 0.0));
        // Round trip beat ↔ seconds.
        for beat in [10.0, 37.0, 80.0, 100.0] {
            assert!(approx(tempo.beat_at(tempo.seconds_at(beat)), beat));
        }
        // Instantaneous speed at half the total time is double the start.
        let rate = |seconds: f32| (tempo.beat_at(seconds + 0.001) - tempo.beat_at(seconds)) / 0.001;
        assert!((rate(total / 2.0) / rate(0.0) - 2.0).abs() < 0.01);
        // A steady tempo is the old constant mapping.
        let steady = Tempo::new(120.0, 1.0, 100.0);
        assert!(approx(steady.seconds_at(4.0), 2.0));
    }

    #[test]
    fn sound_jitter_is_deterministic_and_within_range() {
        use super::sound_jitter;
        // Same seed → same result; ranges respected; different seeds differ.
        let (detune, volume) = sound_jitter(0.5, 0.2, 42);
        assert_eq!((detune, volume), sound_jitter(0.5, 0.2, 42));
        for seed in 0..200 {
            let (detune, volume) = sound_jitter(0.5, 0.2, seed);
            assert!(detune.abs() <= 0.5 + 1e-6);
            assert!((volume - 1.0).abs() <= 0.2 + 1e-6);
        }
        assert_ne!(sound_jitter(0.5, 0.2, 1), sound_jitter(0.5, 0.2, 2));
        // Off (zero ranges) is a no-op.
        assert_eq!(sound_jitter(0.0, 0.0, 7), (0.0, 1.0));
    }

    #[test]
    fn default_instrument_covers_standard_waveforms() {
        for name in STANDARD_WAVEFORMS {
            let sound = default_instrument(name).unwrap();
            assert_eq!(sound.harmonics.len(), 1);
            assert_eq!(sound.harmonics[0].wave, name);
        }
        assert!(default_instrument("guitar").is_none());
    }

    #[test]
    fn standard_sine_is_exact_and_unknown_is_none() {
        let sine = standard_waveform("sine", 0x100).unwrap();
        assert_eq!(sine.len(), 0x100);
        assert!(approx(sine[0], 0.0));
        assert!(approx(sine[0x40], 1.0));
        assert!(approx(sine[0x80], 0.0));
        assert!(approx(sine[0xC0], -1.0));
        for name in ["triangle", "square", "saw", "soft_square", "hill"] {
            assert_eq!(standard_waveform(name, 0x40).unwrap().len(), 0x40);
        }
        assert!(standard_waveform("nope", 0x10).is_none());
    }

    #[test]
    fn default_tuning_matches_12tet() {
        let tuning = Tuning::default();
        for pitch in [-12.0, -5.0, 0.0, 1.0, 7.0, 12.0, 19.0] {
            let expected = pitch_to_frequency(pitch);
            let got = pitch_to_frequency_in(&tuning, pitch, 440.0);
            assert!(approx(got, expected), "pitch {pitch}: {got} vs {expected}");
        }
    }

    #[test]
    fn from_ratios_normalizes_to_first_degree() {
        let a = Tuning::from_ratios(2.0, &[220.0, 264.0, 330.0]);
        let b = Tuning::from_ratios(2.0, &[1.0, 1.2, 1.5]);
        assert_eq!(a.degrees, b.degrees);
        assert!(approx(a.degrees[1], 1.2));
        assert!(approx(a.degrees[2], 1.5));
    }

    #[test]
    fn integer_pitch_lands_on_degree() {
        let tuning = Tuning::from_ratios(2.0, &[1.0, 1.25, 1.5]);
        assert!(approx(tuning.ratio(0.0), 1.0));
        assert!(approx(tuning.ratio(1.0), 1.25));
        assert!(approx(tuning.ratio(2.0), 1.5));
        // one full period up = root ratio times period.
        assert!(approx(tuning.ratio(3.0), 2.0));
        assert!(approx(tuning.ratio(4.0), 2.5));
    }

    #[test]
    fn fractional_pitch_interpolates_geometrically() {
        let tuning = Tuning::from_ratios(2.0, &[1.0, 2.0]);
        // halfway between ratio 1 and 2 geometrically = sqrt(2).
        assert!(approx(tuning.ratio(0.5), 2.0_f32.sqrt()));
    }

    #[test]
    fn non_octave_period_works() {
        // Bohlen-Pierce style: period 3.0.
        let tuning = Tuning::from_ratios(3.0, &[1.0, 5.0 / 3.0]);
        assert!(approx(tuning.ratio(0.0), 1.0));
        assert!(approx(tuning.ratio(2.0), 3.0));
    }
}
