From 31d5a4165e593a146c20ff2d67c74203010bb2fc Mon Sep 17 00:00:00 2001 From: Brad Barnett <127794626+bdbarnett@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:18:58 -0600 Subject: [PATCH] Sound module: Expression on every part, and kept when a part's instrument is made again CC11 only reached a SoundFont part, and a part forgot it when its instrument was made again (a bank change to another instrument and back, or All Sound Off). A part now keeps its Expression: on an instrument part it multiplies with volume in the mixer level, squared as on a SoundFont part, and a rebuilt SoundFont part has it before its first note. --- CHANGELOG.md | 2 ++ lib/soundmodule/engine.py | 27 +++++++++++++--- tests/engine_contract.py | 67 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d96ec75..1df5e55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Sound module: Expression (CC11) works on instrument parts as well as SoundFont parts, and a part keeps it when its instrument is made again, by a bank change or All Sound Off. + ## 0.1.2.dev0 - Wake word: the app listens through medialink.wakeword and tflite. diff --git a/lib/soundmodule/engine.py b/lib/soundmodule/engine.py index cfb84b9..44b0c87 100644 --- a/lib/soundmodule/engine.py +++ b/lib/soundmodule/engine.py @@ -31,7 +31,9 @@ own 0-127 scale. Volume (CC7) reaches a SoundFont part as CC7, which plays it as the square law GM modules use (40 log10 of CC7/127 in dB, as Expression), and pan (CC10) its Pan macro; on an instrument part both set -its level and pan in the mixer. +its level and pan in the mixer. Expression (CC11) is the same square law on +every part, multiplied with volume, and a part keeps it when its instrument is +made again (a bank or program change to another instrument, or All Sound Off). The sustain pedal (CC64) holds notes for every part, and Reset All Controllers (121) also puts Expression (CC11) back to 127 and the mod wheel and channel pressure to 0, as GM's RP-015 asks. @@ -154,6 +156,7 @@ DEFAULT_VOLUME = 100 DEFAULT_PAN = 64 +DEFAULT_EXPRESSION = 127 # The soundfont instruments' macros that CC7 and CC10 drive. VOLUME_MACRO, PAN_MACRO = 0, 4 DEFAULT_REVERB = 40 # GM Level 1's power-on reverb send; chorus is 0 @@ -306,6 +309,7 @@ def reset(self): self.bank_msb = 0 self.bank_lsb = 0 self.volume = DEFAULT_VOLUME + self.expression = DEFAULT_EXPRESSION self.pan = DEFAULT_PAN self.sustain = False self.held = set() @@ -584,6 +588,7 @@ def _select(self, part): if index in inst.patches: inst.program_change(index) inst.control_change(CC_VOLUME, part.volume) + inst.control_change(CC_EXPRESSION, part.expression) inst.set_macro(PAN_MACRO, part.pan) else: if part.bank_lsb in inst.patches: @@ -591,11 +596,13 @@ def _select(self, part): self._level(part) def _level(self, part): - """An instrument part's volume and pan, on its mixer voice.""" + """An instrument part's volume, expression and pan, on its mixer + voice: volume and expression multiply, and the product is squared, + as a SoundFont part plays them.""" if not self._own_voice(part): return voice = self.mixer.voice[part.channel] - v = part.volume / 127.0 + v = part.volume / 127.0 * part.expression / 127.0 voice.level = PART_LEVEL * v * v try: voice.panning = max(-1.0, min(1.0, (part.pan - 64) / 63.0)) @@ -771,6 +778,13 @@ def control(self, part, cc, value): inst.control_change(CC_VOLUME, value) else: self._level(part) + elif cc == CC_EXPRESSION: + part.expression = value + if inst is not None: + if part.inst_name in (MELODIC, DRUMS): + inst.control_change(CC_EXPRESSION, value) + else: + self._level(part) elif cc == CC_PAN: part.pan = value if inst is not None: @@ -874,11 +888,14 @@ def reset_controllers(self, part): self._release_sustained(part) part.bend = BEND_CENTER part.rpn = RPN_NULL + part.expression = DEFAULT_EXPRESSION inst = part.inst if inst is not None: inst.pitch_bend(BEND_CENTER) - if part.inst_name in (MELODIC, DRUMS): - inst.control_change(CC_EXPRESSION, 127) + if part.inst_name not in (MELODIC, DRUMS): + self._level(part) + else: + inst.control_change(CC_EXPRESSION, DEFAULT_EXPRESSION) inst.control_change(CC_MOD_WHEEL, 0) if hasattr(inst, "channel_pressure"): inst.channel_pressure(0) diff --git a/tests/engine_contract.py b/tests/engine_contract.py index 510b37d..8cc5d36 100644 --- a/tests/engine_contract.py +++ b/tests/engine_contract.py @@ -34,7 +34,8 @@ PLANTS = ("bank-ignored", "lsb-ignored", "eager-bank", "macro-shift", "level-linear", "identity-wrong", "identity-any-device", "tally-final", "pipe-drops-peek", "frames-miscounted", "rpn-ignored", "rpn-null-ignored", "rpn-lsb-kept", "reset-keeps-bend", "reset-keeps-expression", - "fixed-range-unscaled", "reset-keeps-rpn", "volume-as-macro", "sends-last-wins", "fill-skipped") + "fixed-range-unscaled", "reset-keeps-rpn", "volume-as-macro", "sends-last-wins", "fill-skipped", + "expression-passthrough", "expression-forgotten", "expression-level-ignored") RESULTS = [0, 0] @@ -239,6 +240,40 @@ def check_soundfont_volume(): "ccs %r, macros %r" % (inst.ccs, inst.macros)) +def check_expression(): + m, made = module() + select(m, 2, engine.BANK_INSTRUMENTS, 0, 0) + m.feed(bytes((0xB2, 7, 64, 0xB2, 11, 64))) + voice = m.mixer.voice[2] + want = engine.PART_LEVEL * (64 / 127.0 * 64 / 127.0) ** 2 + check("CC11 and CC7 multiply, squared, on an instrument part's level", abs(voice.level - want) < 1e-6, + "%r, not %r" % (voice.level, want)) + m.feed(bytes((0xB2, 121, 0))) + want = engine.PART_LEVEL * (64 / 127.0) ** 2 + check("Reset All Controllers puts an instrument part's Expression back", abs(voice.level - want) < 1e-6, + "%r, not %r" % (voice.level, want)) + m.feed(bytes((0xB2, 11, 32, 0xB2, 120, 0))) + want = engine.PART_LEVEL * (64 / 127.0 * 32 / 127.0) ** 2 + check("an instrument part keeps its Expression through All Sound Off", abs(voice.level - want) < 1e-6, + "%r, not %r" % (voice.level, want)) + + m, made = module() + m.feed(bytes((0x90, 60, 100, 0xB0, 11, 32))) + inst = m.parts[0].inst + check("CC11 reaches a SoundFont part as CC11", (11, 32) in inst.ccs, repr(inst.ccs)) + inst.ccs = [] + m.feed(bytes((0xB0, 120, 0))) + check("a SoundFont part keeps its Expression through All Sound Off", (11, 32) in inst.ccs, repr(inst.ccs)) + select(m, 0, engine.BANK_INSTRUMENTS, 0, 0) + select(m, 0, 0, 0, 4) + again = made[-1] + check("a SoundFont part made again has its Expression", again is not inst and (11, 32) in again.ccs, + repr(again.ccs)) + m.feed(bytes((0xF0, 0x7E, 0x7F, 0x09, 0x01, 0xF7))) + check("GM System On puts Expression back to 127", m.parts[0].expression == 127 and again.ccs[-1] == (11, 127), + "%r, %r" % (m.parts[0].expression, again.ccs[-2:])) + + def check_identity(): replies = [] m, made = module(send=replies.append) @@ -552,6 +587,35 @@ def reset_controllers(self, part): part.inst.pitch_bend(engine.BEND_CENTER) M.reset_controllers = reset_controllers + elif name == "expression-passthrough": + old = M.control + + def control(self, part, cc, value): + if cc == engine.CC_EXPRESSION: + if part.inst is not None: + part.inst.control_change(cc, value) + return + old(self, part, cc, value) + + M.control = control + elif name == "expression-forgotten": + old = M._select + + def _select(self, part): + kept = part.expression + part.expression = engine.DEFAULT_EXPRESSION + old(self, part) + part.expression = kept + + M._select = _select + elif name == "expression-level-ignored": + def _level(self, part): + if self._own_voice(part): + v = part.volume / 127.0 + self.mixer.voice[part.channel].level = engine.PART_LEVEL * v * v + self.mixer.voice[part.channel].panning = max(-1.0, min(1.0, (part.pan - 64) / 63.0)) + + M._level = _level elif name == "fixed-range-unscaled": M._wheel = lambda self, part: part.bend elif name == "reset-keeps-rpn": @@ -589,6 +653,7 @@ def main(argv): check_macro_ccs() check_volume_pan() check_soundfont_volume() + check_expression() check_identity() check_tally() check_rpn()