Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
27 changes: 22 additions & 5 deletions lib/soundmodule/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -584,18 +588,21 @@ 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:
inst.program_change(part.bank_lsb)
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))
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
67 changes: 66 additions & 1 deletion tests/engine_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -589,6 +653,7 @@ def main(argv):
check_macro_ccs()
check_volume_pan()
check_soundfont_volume()
check_expression()
check_identity()
check_tally()
check_rpn()
Expand Down
Loading