Skip to main content

game_toolkit_dosaudio/opl/
bank.rs

1//! Validated instrument banks. Parsing and mutation belong to the control thread.
2use super::{OplOperator, OplPatch};
3use crate::{Error, Result};
4
5mod readers;
6mod starter;
7
8/// An address in an OPL MIDI instrument bank. Programs and drum keys are zero-based.
9#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
10pub struct OplBankKey {
11    /// MIDI bank-select MSB (CC0), 0 through 127.
12    pub msb: u8,
13    /// MIDI bank-select LSB (CC32), 0 through 127.
14    pub lsb: u8,
15    /// Melodic program or channel-10 drum key, 0 through 127.
16    pub program: u8,
17    /// Whether the address belongs to the percussion set.
18    pub percussion: bool,
19}
20
21/// A two-operator instrument, optionally layered with a second independent voice.
22#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
23pub struct OplInstrument {
24    /// First voice's operator registers.
25    pub patch: OplPatch,
26    /// Optional second two-operator voice (not true four-operator synthesis).
27    pub second: Option<OplPatch>,
28    /// Semitone transposition of each voice, limited to -127 through 127.
29    pub note_offsets: [i16; 2],
30    /// Second-voice detune in OP2-compatible units; two units are 1/32 semitone.
31    /// Odd values round toward negative infinity, matching the file formats.
32    pub detune: i8,
33    /// A fixed MIDI pitch instead of the incoming key, used by percussion.
34    pub fixed_note: Option<u8>,
35    /// Signed offset applied to note-on velocity before controller gain.
36    pub velocity_offset: i8,
37}
38
39impl From<OplPatch> for OplInstrument {
40    fn from(patch: OplPatch) -> Self {
41        Self {
42            patch,
43            ..Self::default()
44        }
45    }
46}
47
48impl OplInstrument {
49    fn validate(&self) -> Result<()> {
50        if self.fixed_note.is_some_and(|key| key > 127)
51            || self.note_offsets.iter().any(|n| !(-127..=127).contains(n))
52        {
53            return Err(Error::Format(
54                "unsupported OPL instrument pitch or transposition",
55            ));
56        }
57        for patch in [Some(self.patch), self.second].into_iter().flatten() {
58            if patch.feedback > 15 || patch.modulator.waveform > 7 || patch.carrier.waveform > 7 {
59                return Err(Error::Format("invalid OPL instrument register bits"));
60            }
61        }
62        Ok(())
63    }
64}
65
66/// One named instrument in a sorted bank.
67#[derive(Clone, Debug, Eq, PartialEq)]
68pub struct OplBankEntry {
69    /// MIDI bank, program and percussion address.
70    pub key: OplBankKey,
71    /// Source name (lossy UTF-8 for legacy bank encodings).
72    pub name: String,
73    /// Validated instrument register data.
74    pub instrument: OplInstrument,
75}
76
77/// An owned, sorted bank with allocation-free binary-search lookup.
78///
79/// File readers never import a historical bank implicitly. [`Self::starter`]
80/// supplies a small original starter set; loaded banks contain only their file's
81/// entries. The MIDI driver falls back to bank 0's matching program, then program
82/// 0 (drum 36 for percussion); absent fallbacks remain silent.
83#[derive(Clone, Debug, Default)]
84pub struct OplBank {
85    entries: Vec<OplBankEntry>,
86    pub(super) depth: u8,
87}
88
89impl OplBank {
90    /// An empty bank, with no fallback instruments.
91    pub fn new() -> Self {
92        Self::default()
93    }
94
95    /// A small original bank under this crate's triple license.
96    /// This supplies six melodic patches and five fixed-pitch percussion patches;
97    /// it is not a complete General MIDI sound set.
98    pub fn starter() -> Self {
99        starter::bank()
100    }
101
102    /// Insert or replace an instrument on the calling thread. Invalid entries
103    /// leave the bank unchanged. A bank holds at most 32,768 entries.
104    pub fn insert(&mut self, entry: OplBankEntry) -> Result<()> {
105        if entry.key.msb > 127 || entry.key.lsb > 127 || entry.key.program > 127 {
106            return Err(Error::Format(
107                "OPL bank address must contain seven-bit MIDI values",
108            ));
109        }
110        entry.instrument.validate()?;
111        match self.entries.binary_search_by_key(&entry.key, |e| e.key) {
112            Ok(index) => self.entries[index] = entry,
113            Err(index) => {
114                if self.entries.len() == 32_768 {
115                    return Err(Error::TooLarge);
116                }
117                self.entries.insert(index, entry);
118            }
119        }
120        Ok(())
121    }
122
123    /// Look up an exact bank address without applying fallback policy.
124    pub fn get(&self, key: OplBankKey) -> Option<&OplBankEntry> {
125        self.entries
126            .binary_search_by_key(&key, |e| e.key)
127            .ok()
128            .map(|i| &self.entries[i])
129    }
130
131    /// Named entries in ascending MIDI address order.
132    pub fn entries(&self) -> &[OplBankEntry] {
133        &self.entries
134    }
135
136    pub(super) fn resolve(&self, key: OplBankKey) -> Option<OplInstrument> {
137        self.get(key)
138            .or_else(|| {
139                self.get(OplBankKey {
140                    msb: 0,
141                    lsb: 0,
142                    ..key
143                })
144            })
145            .or_else(|| {
146                self.get(OplBankKey {
147                    msb: 0,
148                    lsb: 0,
149                    program: if key.percussion { 36 } else { 0 },
150                    ..key
151                })
152            })
153            .map(|e| e.instrument)
154    }
155}