Skip to content

Commit

Permalink
- new: much better support for soundfonts. When defining a soundfont …
Browse files Browse the repository at this point in the history
…preset

  it is possible to normalize the amplitude, either ahead of time
  or at runtime
- new: notes played using a soundfont preset can independently specify
  the velocity used, independently of the actual amplitude. This lets
  the user set the amplitude to scale the playback and set the velocity
  to select a specific layer within the soundfont, if needed
- new: soundfonts can define an amplitude to velocity curve. The default
  curve follows much closely the soundfont implementation of other software,
  and is based on a curve from decibels to velocity instead of using
  raw amplitudes
- internal: use the new Self type when possible
- internal: update dependencies. Most of the new soundfont features
  depend on corresponding features in csoundengine
  • Loading branch information
gesellkammer committed May 20, 2024
1 parent 8cbd651 commit 10256b1
Show file tree
Hide file tree
Showing 10 changed files with 324 additions and 128 deletions.
1 change: 0 additions & 1 deletion docs/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ python-constraint

sphinx-autodoc-typehints
sphinx-automodapi
sphinx_rtd_theme
nbsphinx
sphinx-design
furo
22 changes: 13 additions & 9 deletions maelzel/core/builtinpresets.py
Original file line number Diff line number Diff line change
Expand Up @@ -282,15 +282,18 @@
}


_builtinSoundfonts = {
# relative paths are relative to the package root
'.piano': ('sf2/SalC5Light2.sf2', (0, 0), "Default piano sound")
_builtinSoundfontPresets = {
'.piano': {'relpath': 'sf2/SalC5Light2.sf2',
'preset': (0, 0),
'description': 'Default piano sound',
'ampDivisor': 16000,
}
}


def builtinSoundfonts() -> dict:
def builtinSoundfonts() -> dict[str, dict]:
"""
Get the paths of the builtin soundfonts
Collects the builtin soundfonts
Returns:
a dict of {name: soundfontpath}, where *soundfontpath* is the absolute path
Expand All @@ -300,8 +303,9 @@ def builtinSoundfonts() -> dict:
from maelzel.dependencies import dataPath
datadir = dataPath()
out = {}
for presetname, (relpath, preset, description) in _builtinSoundfonts.items():
path = datadir / relpath
if path.exists():
out[presetname] = (path, preset, description)
for presetname, info in _builtinSoundfontPresets.items():
abspath = datadir / info['relpath']
if abspath.exists():
info['sf2path'] = abspath.as_posix()
out[presetname] = info
return out
5 changes: 5 additions & 0 deletions maelzel/core/configdata.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
'play.useDynamics': True,
'play.waitAfterStart': 0.5,
'play.gracenoteDuration': '1/14',
'play.soundfontFindPeakAheadOfTime': False,

'rec.blocking': True,
'rec.sr': 44100,
Expand Down Expand Up @@ -277,6 +278,10 @@
'play.gracenoteDuration':
'Duration assigned to a gracenote for playback (in quarternotes)',

'play.soundfontFindPeakAheadOfTime':
'If True, find the peak of a soundfont to adjust its normalization at'
' the moment an soundfont preset is defined',

'show.labelStyle':
'Text size used for labels'
'The format is a list of <key>=<value> pairs, '
Expand Down
1 change: 1 addition & 0 deletions maelzel/core/playback.py
Original file line number Diff line number Diff line change
Expand Up @@ -419,6 +419,7 @@ def playSession(numchannels: int = None,
buffersize: if given, use this as the buffer size. None to use a sensible
default for the backend
latency: an added latency
numbuffers: the number of buffers used by the csound engine
Returns:
the active Session
Expand Down
12 changes: 12 additions & 0 deletions maelzel/core/presetdef.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,18 @@ def _parseAudiogen(code: str, check=False) -> ParsedAudiogen:
argdocs=docstring.args if docstring else None)


@dataclasses.dataclass
class GainToVelocityCurve:
"""
Maps a gain in dB to a velocity
"""
exponent: float = 2.6
mindb: float = -72
maxdb: float = 0.
minvel: int = 1
maxvel: int = 127


def _makePresetBody(audiogen: str,
numsignals: int,
withEnvelope=True,
Expand Down
75 changes: 66 additions & 9 deletions maelzel/core/presetmanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import csoundengine.csoundlib
import csoundengine

from .presetdef import PresetDef
from .presetdef import PresetDef, GainToVelocityCurve
from .workspace import Workspace
from . import presetutils
from . import builtinpresets
Expand All @@ -35,6 +35,26 @@


_csoundPrelude = r"""
; dict holding peaks for soundfont normalization
gi__soundfont_peaks dict_new "int:float"
instr _sfpeak
ipreset = p4
ipitch1 = p5
ipitch2 = p6
kmax0 init 0
a1 sfplaym 127, ipitch1, 1, 1, ipreset, 0
a2 sfplaym 127, ipitch2, 1, 1, ipreset, 0
kmax1 peak a1
kmax2 peak a2
kmax = max(kmax1, kmax2)
if kmax > kmax0 then
println "sf peak: %f", kmax
dict_set gi__soundfont_peaks, ipreset, kmax
endif
kmax0 = kmax
endin
opcode turnoffWhenSilent, 0, a
asig xin
ksilent_ trigger detectsilence:k(asig, 0.0001, 0.05), 0.5, 0
Expand All @@ -43,6 +63,24 @@
endif
endop
opcode _linexp, i, iiiiii
ix, iexp, ix0, ix1, iy0, iy1 xin
idx = (ix - ix0) / (ix1 - ix0)
iy = (idx ^ iexp) * (iy1 - iy0) + iy0
; iy = limit:i(iy, iy0, iy1)
xout iy
endop
/*
opcode _linexp, k, kkkkkk
kx, kexp, kx0, kx1, ky0, ky1 xin
kdx = (kx - kx0) / (kx1 - kx0)
ky = (kdx ^ kexp) * (ky1 - ky0) + ky0
ky = limit:i(ky, ky0, ky1)
xout ky
endop
*/
opcode makePresetEnvelope, a, iii
ifadein, ifadeout, ifadekind xin
igain = 1.0
Expand Down Expand Up @@ -136,9 +174,14 @@ def _makeBuiltinPresets(self, sf2path: str = None) -> None:
self.defPresetSoundfont(presetname, sf2path=sf2, preset=preset,
_builtin=True, description=descr)

for name, (path, preset, descr) in builtinpresets.builtinSoundfonts().items():
self.defPresetSoundfont(name, sf2path=path, preset=preset, _builtin=True,
description=descr)
for name, info in builtinpresets.builtinSoundfonts().items():
self.defPresetSoundfont(name,
sf2path=info['sf2path'],
preset=info['preset'],
description=info.get('description', ''),
ampDivisor=info.get('ampDivisor'),
_builtin=True,
)

def defPreset(self,
name: str,
Expand Down Expand Up @@ -276,6 +319,8 @@ def defPresetSoundfont(self,
ampDivisor: int = None,
turnoffWhenSilent=True,
description='',
normalize=False,
velocityCurve: list[float] | GainToVelocityCurve = None,
_builtin=False) -> PresetDef:
"""
Define a new instrument preset based on a soundfont
Expand All @@ -297,7 +342,6 @@ def defPresetSoundfont(self,
(0, 4, 'Bright Grand'),
(0, 5, 'Very Bright Grand')]
Args:
name: the name of the preset. If not given, the name of the preset
is used.
Expand All @@ -313,6 +357,8 @@ def defPresetSoundfont(self,
includes: files to include (if needed by init or postproc)
args: mutable values needed by postproc (if any). See :meth:`~PresetManager.defPreset`
mono: if True, only the left channel of the soundfont is read
velocityCurve: either a flat list of pairs of the form [db0, vel0, db1, vel1, ...],
mapping dB values to velocities, or an instance of GainToVelocityCurve
ampDivisor: most soundfonts are PCM 16bit files and need to be scaled down
to use them in the range of -1:1. This value is used to scale amp down.
The default is 16384, but it can be changed in the config
Expand All @@ -323,6 +369,8 @@ def defPresetSoundfont(self,
turnoffWhenSilent: if True, turn a note off when the sample stops (by detecting
silence for a given amount of time)
description: a short string describing this preset
normalize: if True, queries the amplitude divisor of the soundfont at runtime
and uses that to scale amplitudes to 0dbfs
_builtin: if True, marks this preset as built-in
Example
Expand Down Expand Up @@ -371,22 +419,30 @@ def defPresetSoundfont(self,
if (bank, presetnum) not in idx.presetToName:
raise ValueError(f"Preset ({bank}:{presetnum}) not found. Possible presets: "
f"{idx.presetToName.keys()}")
if normalize and ampDivisor is None and cfg['play.soundfontFindPeakAheadOfTime']:
sfpeak = csoundengine.csoundlib.soundfontPeak(sfpath=sf2path, preset=(bank, presetnum))
if sfpeak > 0:
ampDivisor = sfpeak
normalize = False

code = presetutils.makeSoundfontAudiogen(sf2path=sf2path,
preset=(bank, presetnum),
interpolation=interpolation,
ampDivisor=ampDivisor,
mono=mono)
mono=mono,
normalize=normalize)

# We don't actually need the global variable because sfloadonce
# saves the table number into a channel
init0 = f'i__SfTable__ sfloadonce "{sf2path}"'
init0 = f'''i__SfTable__ sfloadonce "{sf2path}"'''
if init:
init = "\n".join((init0, init))
else:
init = init0
if postproc:
code = emlib.textlib.joinPreservingIndentation((code, '\n;; postproc\n', postproc))
epilogue = "turnoffWhenSilent aout1" if turnoffWhenSilent else ''
ownargs = {'ktransp': 0., 'ipitchlag': 0.1}
ownargs = {'ktransp': 0., 'ipitchlag': 0.1, 'ivel': -1}
args = ownargs if not args else args | ownargs
presetdef = self.defPreset(name=name,
code=code,
Expand All @@ -397,7 +453,8 @@ def defPresetSoundfont(self,
description=description,
aliases={'transpose': 'ktransp'})
presetdef.userDefined = not _builtin
presetdef.properties = {'sfpath': sf2path}
presetdef.properties = {'sfpath': sf2path,
'ampDivisor': ampDivisor}
return presetdef

def registerPreset(self, presetdef: PresetDef) -> None:
Expand Down
Loading

0 comments on commit 10256b1

Please sign in to comment.