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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ here. The two packages version and release together, from this repository;
Releases up to and including audiodsp's v0.1.1 shipped both packages from
there, and are recorded in its changelog.

## Unreleased

### Fixed

- **Turning a macro now changes the notes already sounding** on all 43
melodic instruments: cutoff, resonance, volume, layer mixes, envelope
amounts, brightness and modulation depths and rates reach a held chord the
way a hardware knob does. Envelope times, glide, pitch and anything built
into a waveform still take effect on the next note, as do the drum
machines' macros. With a macro set before the note, every instrument
sounds exactly as it did (#166).

## v0.4.0 (2026-10-15)

### Changed
Expand Down
58 changes: 53 additions & 5 deletions lib/audioinstruments/_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,53 @@ def env_shape_table(attack, decay, sustain, length=96):
return out


_SUM = synthio.MathOperation.SUM
_PRODUCT = synthio.MathOperation.PRODUCT


def live(value):
"""A continuous macro that notes already sounding follow.

An instrument that reads a macro as a float when a note starts can only
change the *next* note. A block can be shared instead: every note wires
the same block into its filter frequency, Q, amplitude or LFO, and the
macro handler writes ``block.a = new_value``. Each sounding note picks the
new value up on its next audio block, the way a hardware knob reaches
every voice at once.

A block holding a constant renders exactly what the plain float renders,
on every interpreter this package runs on, so a macro set before a note
sounds the same as it always did.
"""
return synthio.Math(_SUM, value, 0.0, 0.0)


def gain(a, b=1.0, c=1.0):
"""``a * b * c`` as one amplitude input, where any of the three may be a
live block. A note's level often depends on more than one macro (volume
times a layer mix); this keeps each of them live."""
return synthio.Math(_PRODUCT, a, b, c)


def block_value(x):
"""The value ``x`` holds right now when it is a number, or a SUM/PRODUCT
of numbers and such blocks; ``None`` when it moves on its own (an LFO, an
envelope) and so cannot be known from Python."""
if isinstance(x, (int, float)):
return x
if isinstance(x, synthio.Math):
op = x.operation
if op != _SUM and op != _PRODUCT:
return None
a = block_value(x.a)
b = block_value(x.b)
c = block_value(x.c)
if a is None or b is None or c is None:
return None
return a + b + c if op == _SUM else a * b * c
return None


def logmap(v, lo, hi):
"""Map a normalized 0.0-1.0 value onto ``lo``..``hi`` logarithmically."""
return lo * ((hi / lo) ** v)
Expand Down Expand Up @@ -264,16 +311,17 @@ def copy_note(source, target):


def note_is_silent(note):
"""True only when a note is *provably* inaudible - a numeric amplitude of
zero or less.
"""True only when a note is *provably* inaudible - an amplitude of zero or
less, read straight off a number or off a live macro's block (see
:func:`block_value`) as it stands now.

An amplitude driven by an LFO or a block input is assumed audible, because
An amplitude driven by an LFO or an envelope is assumed audible, because
it may be anywhere in its cycle. Being wrong in that direction costs a
thinner steal; being wrong the other way silences a key the player is
holding.
"""
amplitude = getattr(note, "amplitude", None)
if isinstance(amplitude, (int, float)):
amplitude = block_value(getattr(note, "amplitude", None))
if amplitude is not None:
return amplitude <= 0.0
return False

Expand Down
53 changes: 28 additions & 25 deletions lib/audioinstruments/andromeda.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@
import synthio

from audioinstruments._support import (
EVENT_NOTE_ON, EVENT_NOTE_OFF, EVENT_PARAMETER, env_shape_table, key_of,
logmap, make_table,
EVENT_NOTE_ON, EVENT_NOTE_OFF, EVENT_PARAMETER, env_shape_table, gain,
key_of, live, logmap, make_table,
)
from audioinstruments._support import Instrument
from audioinstruments import _support
Expand All @@ -57,15 +57,17 @@ def create(sample_rate, channel_count=2, transport=None):
SR = sample_rate
synth = _support.synthesizer(SR, channel_count)

# Macros
volume = 0.8
f1_cutoff = 2000.0
f2_cutoff = 3000.0
f_mix = 0.5
res1 = 1.0
res2 = 1.0
env_to_f1 = 2000.0
env_to_f2 = 1000.0
# Macros. The live ones are blocks every sounding note reads, so turning
# the knob reaches a held note; the rest are read when a note starts.
volume = live(0.8)
f1_cutoff = live(2000.0)
f2_cutoff = live(3000.0)
f_mix = live(0.5)
f_mix_inv = live(0.5) # 1 - Filter Mix, the Filter 1 side's share
res1 = live(1.0)
res2 = live(1.0)
env_to_f1 = live(2000.0)
env_to_f2 = live(1000.0)
unison_detune = 0.0
a_a = 0.01
a_d = 0.3
Expand All @@ -87,7 +89,6 @@ def steal_oldest():
_support.steal_oldest(voices, release_voice)

def handle_event(event_type, channel, note_id, data0, value0, value1, sample_position):
nonlocal volume, f1_cutoff, f2_cutoff, f_mix, res1, res2, env_to_f1, env_to_f2
nonlocal unison_detune, a_a, a_d, a_s, a_r, f_a, f_d, master_tune
nonlocal serial

Expand All @@ -99,7 +100,7 @@ def handle_event(event_type, channel, note_id, data0, value0, value1, sample_pos
steal_oldest()

hz = synthio.midi_to_hz(data0 + value1) * master_tune
amp = volume * value0
amp = gain(volume, value0)

env = synthio.Envelope(attack_time=a_a, decay_time=a_d, release_time=a_r, attack_level=1.0, sustain_level=a_s)

Expand All @@ -118,12 +119,12 @@ def handle_event(event_type, channel, note_id, data0, value0, value1, sample_pos
if unison_detune > 0.01:
for i, detune in enumerate([-unison_detune, 0.0, unison_detune]):
pan = [-0.5, 0.0, 0.5][i]
n1 = synthio.Note(hz * (1.0 + detune * 0.012), waveform=SAW, envelope=env, filter=lp1, amplitude=amp * (1.0 - f_mix) * 0.3, panning=pan)
n2 = synthio.Note(hz * (1.0 + detune * 0.012), waveform=SQUARE, envelope=env, filter=hp2, amplitude=amp * f_mix * 0.3, panning=pan)
n1 = synthio.Note(hz * (1.0 + detune * 0.012), waveform=SAW, envelope=env, filter=lp1, amplitude=gain(amp, f_mix_inv, 0.3), panning=pan)
n2 = synthio.Note(hz * (1.0 + detune * 0.012), waveform=SQUARE, envelope=env, filter=hp2, amplitude=gain(amp, f_mix, 0.3), panning=pan)
notes.extend([n1, n2])
else:
n1 = synthio.Note(hz, waveform=SAW, envelope=env, filter=lp1, amplitude=amp * (1.0 - f_mix))
n2 = synthio.Note(hz * 1.002, waveform=SQUARE, envelope=env, filter=hp2, amplitude=amp * f_mix)
n1 = synthio.Note(hz, waveform=SAW, envelope=env, filter=lp1, amplitude=gain(amp, f_mix_inv))
n2 = synthio.Note(hz * 1.002, waveform=SQUARE, envelope=env, filter=hp2, amplitude=gain(amp, f_mix))
notes.extend([n1, n2])

serial += 1
Expand All @@ -135,14 +136,16 @@ def handle_event(event_type, channel, note_id, data0, value0, value1, sample_pos
release_voice(k)

elif event_type == EVENT_PARAMETER:
if data0 == 0: volume = value0
elif data0 == 1: f1_cutoff = 50.0 * (100.0 ** value0)
elif data0 == 2: f2_cutoff = 50.0 * (100.0 ** value0)
elif data0 == 3: f_mix = value0
elif data0 == 4: res1 = 0.5 + value0 * 3.5
elif data0 == 5: res2 = 0.5 + value0 * 3.5
elif data0 == 6: env_to_f1 = value0 * 8000.0
elif data0 == 7: env_to_f2 = value0 * 8000.0
if data0 == 0: volume.a = value0
elif data0 == 1: f1_cutoff.a = 50.0 * (100.0 ** value0)
elif data0 == 2: f2_cutoff.a = 50.0 * (100.0 ** value0)
elif data0 == 3:
f_mix.a = value0
f_mix_inv.a = 1.0 - value0
elif data0 == 4: res1.a = 0.5 + value0 * 3.5
elif data0 == 5: res2.a = 0.5 + value0 * 3.5
elif data0 == 6: env_to_f1.a = value0 * 8000.0
elif data0 == 7: env_to_f2.a = value0 * 8000.0
elif data0 == 8: unison_detune = value0
elif data0 == 9: a_a = logmap(value0, 0.001, 2.001)
elif data0 == 10: a_d = logmap(value0, 0.05, 3.05)
Expand Down
50 changes: 27 additions & 23 deletions lib/audioinstruments/arp2600.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@
import synthio

from audioinstruments._support import (
EVENT_NOTE_ON, EVENT_NOTE_OFF, EVENT_PARAMETER, env_shape_table, key_of,
logmap, make_table,
EVENT_NOTE_ON, EVENT_NOTE_OFF, EVENT_PARAMETER, env_shape_table, gain,
key_of, live, logmap, make_table,
)
from audioinstruments._support import Instrument
from audioinstruments import _support
Expand All @@ -58,12 +58,13 @@ def create(sample_rate, channel_count=2, transport=None):
SR = sample_rate
synth = _support.synthesizer(SR, channel_count)

# Macros
volume = 0.8
cutoff_base = 2000.0
resonance = 1.0
fm_amt = 0.0
reverb_mix = 0.0
# Macros. The live ones are blocks every sounding note reads, so turning
# the knob reaches a held note; the rest are read when a note starts.
volume = live(0.8)
cutoff_base = live(2000.0)
resonance = live(1.0)
fm_amt = live(0.0)
reverb_mix = live(0.0)
osc2_detune = 1.0
osc3_detune = 0.5

Expand Down Expand Up @@ -93,7 +94,7 @@ def steal_oldest():
_support.steal_oldest(voices, release_voice)

def handle_event(event_type, channel, note_id, data0, value0, value1, sample_position):
nonlocal volume, cutoff_base, resonance, fm_amt, reverb_mix, osc2_detune, osc3_detune
nonlocal osc2_detune, osc3_detune
nonlocal e1_a, e1_r, e2_a, e2_d, e2_s, e2_r, vca_a, vca_r, master_tune
nonlocal serial

Expand All @@ -104,7 +105,7 @@ def handle_event(event_type, channel, note_id, data0, value0, value1, sample_pos
steal_oldest()

hz = synthio.midi_to_hz(data0 + value1) * master_tune
amp = volume * value0
amp = gain(volume, value0)

env = synthio.Envelope(attack_time=vca_a, decay_time=e2_d, release_time=vca_r, attack_level=1.0, sustain_level=1.0)

Expand All @@ -114,25 +115,28 @@ def handle_event(event_type, channel, note_id, data0, value0, value1, sample_pos

lp = synthio.Biquad(synthio.FilterMode.LOW_PASS, cutoff, Q=resonance)

o1 = synthio.Note(hz, waveform=SAW, envelope=env, filter=lp, amplitude=amp * 0.4)
o2 = synthio.Note(hz * osc2_detune, waveform=SQUARE, envelope=env, filter=lp, amplitude=amp * 0.3)
o1 = synthio.Note(hz, waveform=SAW, envelope=env, filter=lp, amplitude=gain(amp, 0.4))
o2 = synthio.Note(hz * osc2_detune, waveform=SQUARE, envelope=env, filter=lp, amplitude=gain(amp, 0.3))
# Env 1 (Attack/Release) patched to osc3's pitch input, scaled by FM Amount: the ARP
# 2600 is semi-modular, and "envelope -> VCO frequency" is one of its classic patches
# (laser/zap sounds). synthio can't do audio-rate FM, so approximate it as a one-shot
# pitch-bend sweep shaped by Env 1.
fm_tbl = env_shape_table(e1_a, e1_r, 0.0)
fm_lfo = synthio.LFO(waveform=fm_tbl, once=True, rate=1.0/max(0.01, e1_a + e1_r), scale=fm_amt * 2.0, interpolate=True) if fm_amt > 0.01 else None
o3 = synthio.Note(hz * osc3_detune, waveform=TRIANGLE, envelope=env, filter=lp, amplitude=amp * 0.3, bend=fm_lfo)
# FM Amount reaches a held note only if it was up when the note started.
fm_lfo = synthio.LFO(waveform=fm_tbl, once=True, rate=1.0/max(0.01, e1_a + e1_r), scale=gain(fm_amt, 2.0), interpolate=True) if fm_amt.a > 0.01 else None
o3 = synthio.Note(hz * osc3_detune, waveform=TRIANGLE, envelope=env, filter=lp, amplitude=gain(amp, 0.3), bend=fm_lfo)

filtered_notes = [o1, o2, o3]
notes = list(filtered_notes)

# Reverb Mix: the 2600 itself is dry; approximate a reverb-style tail with a quietly
# mixed, softly filtered, longer-release copy of the voice rather than true convolution.
if reverb_mix > 0.01:
wash_env = synthio.Envelope(attack_time=vca_a, decay_time=e2_d, release_time=vca_r + reverb_mix * 2.0, attack_level=1.0, sustain_level=1.0)
wash_filt = synthio.Biquad(synthio.FilterMode.LOW_PASS, cutoff_base * 0.4, Q=0.7)
wash = synthio.Note(hz * 1.003, waveform=TRIANGLE, envelope=wash_env, filter=wash_filt, amplitude=amp * reverb_mix * 0.25, panning=0.6)
# Likewise Reverb Mix: its level follows the knob while the wash
# sounds, but its tail length is set when the note starts.
if reverb_mix.a > 0.01:
wash_env = synthio.Envelope(attack_time=vca_a, decay_time=e2_d, release_time=vca_r + reverb_mix.a * 2.0, attack_level=1.0, sustain_level=1.0)
wash_filt = synthio.Biquad(synthio.FilterMode.LOW_PASS, gain(cutoff_base, 0.4), Q=0.7)
wash = synthio.Note(hz * 1.003, waveform=TRIANGLE, envelope=wash_env, filter=wash_filt, amplitude=gain(amp, reverb_mix, 0.25), panning=0.6)
notes.append(wash)

# Env 2 Release: retarget o1/o2/o3's shared filter to a real release
Expand All @@ -151,11 +155,11 @@ def handle_event(event_type, channel, note_id, data0, value0, value1, sample_pos
release_voice(k)

elif event_type == EVENT_PARAMETER:
if data0 == 0: volume = value0
elif data0 == 1: cutoff_base = 50.0 * (100.0 ** value0)
elif data0 == 2: resonance = 0.5 + value0 * 3.5
elif data0 == 3: fm_amt = value0
elif data0 == 4: reverb_mix = value0
if data0 == 0: volume.a = value0
elif data0 == 1: cutoff_base.a = 50.0 * (100.0 ** value0)
elif data0 == 2: resonance.a = 0.5 + value0 * 3.5
elif data0 == 3: fm_amt.a = value0
elif data0 == 4: reverb_mix.a = value0
elif data0 == 5: osc2_detune = 0.5 + value0
elif data0 == 6: osc3_detune = 0.25 + value0
elif data0 == 7: e1_a = logmap(value0, 0.001, 2.001)
Expand Down
Loading
Loading