Mono-per-track: a new note releases the track's previous one (v0.10.0)

A sustaining note held with no explicit note-off used to pile up until
voice-stealing killed it. Now, when a note appears under the playhead on a
track, that track's still-held note is released automatically, so a line moves
note-to-note like a real monophonic voice.

Each sequencer voice carries an ownerTrack tag (the lane+track that started it),
and triggerCell calls releaseTrackVoices(trackKey) before playing a new note. It
releases only voices this track owns and only gated (still-held) ones, so chords
built across several tracks on the same bus keep sounding and release tails ring
out. Uses note-off (not kill) for a click-free legato hand-off. Arp voices are
unowned and keep self-managing as mono-per-bus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Reactorcoremeltdown
2026-07-18 22:55:51 +02:00
parent 7a0f2e4426
commit 3508777ba9
4 changed files with 37 additions and 7 deletions

View File

@@ -22,8 +22,8 @@ android {
// (android.media.midi) and AAudio low-latency audio we rely on. // (android.media.midi) and AAudio low-latency audio we rely on.
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 26 versionCode = 27
versionName = "0.9.1" versionName = "0.10.0"
// We provide our own instrumentation runner if/when tests are added. // We provide our own instrumentation runner if/when tests are added.
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"

View File

@@ -858,6 +858,11 @@ class AudioEngine(
if (!cell.isPlayable) return if (!cell.isPlayable) return
// Remember the channel so a later note-off on this track releases it. // Remember the channel so a later note-off on this track releases it.
trackLastChannel[trackKey] = cell.channel trackLastChannel[trackKey] = cell.channel
// Mono-per-track: a new note releases whatever this track was still holding,
// so a held note is stopped when the next one appears under the playhead — no
// explicit note-off cell needed. Only this track's own voices are released, so
// notes other tracks hold on the same bus (chords across tracks) keep sounding.
releaseTrackVoices(trackKey)
for (bus in 0 until Pattern.TRACK_COUNT) { for (bus in 0 until Pattern.TRACK_COUNT) {
if (proj.mixer.channels[bus].midiChannel != cell.channel) continue if (proj.mixer.channels[bus].midiChannel != cell.channel) continue
val arp = arps[lane * Pattern.TRACK_COUNT + bus] val arp = arps[lane * Pattern.TRACK_COUNT + bus]
@@ -888,19 +893,33 @@ class AudioEngine(
} }
} else { } else {
arp.stop() arp.stop()
playNote(proj, bus, note, velocity) playNote(proj, bus, note, velocity, ownerTrack = trackKey)
} }
} }
} }
/** Release (fade out) every still-*held* sequencer voice started by tracker
* [trackKey] — used to make a track monophonic: its previous note is released when
* it plays a new one. Only gated voices are touched (release tails ring out), and
* matching by owner means sibling tracks' notes on a shared bus are left alone.
* Array loops allocate no iterator — safe on the audio thread. */
private fun releaseTrackVoices(trackKey: Int) {
for (v in seqVoices) if (v.ownerTrack == trackKey && v.isGated) v.noteOff()
for (v in sampleVoices) if (v.ownerTrack == trackKey && v.isGated) v.noteOff()
}
/** /**
* Sound a note on a mixer [bus]: allocate a [SampleVoice] from the bus pool when * Sound a note on a mixer [bus]: allocate a [SampleVoice] from the bus pool when
* the channel's instrument is a Sampler/SoundFont with a loaded sample, otherwise * the channel's instrument is a Sampler/SoundFont with a loaded sample, otherwise
* a [SynthVoice] for the NES synth. Pooled allocation (up to [POLYPHONY] per bus, * a [SynthVoice] for the NES synth. Pooled allocation (up to [POLYPHONY] per bus,
* stealing the oldest when full) is what makes the instrument polyphonic. Shared * stealing the oldest when full) is what makes the instrument polyphonic. Shared
* by the sequencer and the arpeggiator. * by the sequencer and the arpeggiator.
*
* [ownerTrack] tags the allocated voice with the tracker (lane,track) that started
* it (or -1 for arp/unowned notes), so [releaseTrackVoices] can later release just
* this track's note — see [triggerCell].
*/ */
private fun playNote(proj: Project, bus: Int, note: Int, velocity: Int) { private fun playNote(proj: Project, bus: Int, note: Int, velocity: Int, ownerTrack: Int = -1) {
// The allocators ([allocSynthVoice]/[allocSampleVoice]) queue the chosen voice // The allocators ([allocSynthVoice]/[allocSampleVoice]) queue the chosen voice
// into this block's active list, so a note started mid-block sounds sample- // into this block's active list, so a note started mid-block sounds sample-
// accurately within the same block. // accurately within the same block.
@@ -908,14 +927,16 @@ class AudioEngine(
val vs = voiceSample(slot, note) val vs = voiceSample(slot, note)
when { when {
vs != null -> vs != null ->
allocSampleVoice(bus).noteOn(vs.sample, note, velocity, vs.root, vs.sliceStart, vs.sliceEnd, vs.volume, allocSampleVoice(bus).also { it.ownerTrack = ownerTrack }.noteOn(
vs.sample, note, velocity, vs.root, vs.sliceStart, vs.sliceEnd, vs.volume,
vs.attack, vs.decay, vs.sustain, vs.release, vs.loop, vs.crossfadeMs) vs.attack, vs.decay, vs.sustain, vs.release, vs.loop, vs.crossfadeMs)
// Empty bus (no instrument — e.g. the default channel 0) or a Sampler // Empty bus (no instrument — e.g. the default channel 0) or a Sampler
// with an empty pad → silent, matching the live audition path. // with an empty pad → silent, matching the live audition path.
slot?.type == null || slot.type == ToolboxType.SAMPLER -> {} slot?.type == null || slot.type == ToolboxType.SAMPLER -> {}
// NES synth, or a SoundFont with nothing loaded → the tone generator. // NES synth, or a SoundFont with nothing loaded → the tone generator.
else -> { else -> {
allocSynthVoice(bus).noteOn(note, velocity, synthPatch[bus] ?: patchForSlot(slot, proj.tempoBpm)) allocSynthVoice(bus).also { it.ownerTrack = ownerTrack }
.noteOn(note, velocity, synthPatch[bus] ?: patchForSlot(slot, proj.tempoBpm))
} }
} }
} }

View File

@@ -28,6 +28,10 @@ class SampleVoice(private val engineSampleRate: Int) {
private var endIndex = 0 private var endIndex = 0
private var amp = 0f private var amp = 0f
var pitch = -1; private set var pitch = -1; private set
/** Which tracker (lane,track) started this voice, or -1 if unowned. See
* [SynthVoice.ownerTrack]: lets the sequencer make a track mono without cutting
* other tracks' notes on the same bus. */
var ownerTrack = -1
// ---- sustain loop (SoundFont): wrap [loopStartIdx, loopEndIdx) while sounding, // ---- sustain loop (SoundFont): wrap [loopStartIdx, loopEndIdx) while sounding,
// until a note-off's release envelope ends the voice. Uses the file's loop points // until a note-off's release envelope ends the voice. Uses the file's loop points
@@ -143,7 +147,7 @@ class SampleVoice(private val engineSampleRate: Int) {
phase = Phase.RELEASE phase = Phase.RELEASE
} }
fun kill() { sample = null; pitch = -1; env = 0f; endGain = 1f; lastY = 0f; declick = 0f; loopActive = false } fun kill() { sample = null; pitch = -1; ownerTrack = -1; env = 0f; endGain = 1f; lastY = 0f; declick = 0f; loopActive = false }
/** One sample of the decaying declick offset (used on the paths that stop the /** One sample of the decaying declick offset (used on the paths that stop the
* voice, so any carried level rings out smoothly instead of snapping to 0). */ * voice, so any carried level rings out smoothly instead of snapping to 0). */

View File

@@ -26,6 +26,10 @@ class SynthVoice(private val sampleRate: Int) {
private var patch: SynthPatch = SynthPatch.DEFAULT private var patch: SynthPatch = SynthPatch.DEFAULT
var pitch = -1; private set var pitch = -1; private set
/** Which tracker (lane,track) started this voice, or -1 if unowned (live/arp).
* Lets the sequencer release a track's previous note when it plays a new one
* (mono-per-track) without touching notes other tracks hold on the same bus. */
var ownerTrack = -1
private var velocity = 0f private var velocity = 0f
private var baseFreq = 440.0 private var baseFreq = 440.0
private var keyNorm = 0f // note position relative to C-4 (60), -1..1 private var keyNorm = 0f // note position relative to C-4 (60), -1..1
@@ -84,6 +88,7 @@ class SynthVoice(private val sampleRate: Int) {
fun kill() { fun kill() {
ampEnv.kill(); filtEnv.kill() ampEnv.kill(); filtEnv.kill()
pitch = -1 pitch = -1
ownerTrack = -1
icB = 0f; icL = 0f icB = 0f; icL = 0f
} }