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}