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:
@@ -22,8 +22,8 @@ android {
|
||||
// (android.media.midi) and AAudio low-latency audio we rely on.
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 26
|
||||
versionName = "0.9.1"
|
||||
versionCode = 27
|
||||
versionName = "0.10.0"
|
||||
|
||||
// We provide our own instrumentation runner if/when tests are added.
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
|
||||
@@ -858,6 +858,11 @@ class AudioEngine(
|
||||
if (!cell.isPlayable) return
|
||||
// Remember the channel so a later note-off on this track releases it.
|
||||
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) {
|
||||
if (proj.mixer.channels[bus].midiChannel != cell.channel) continue
|
||||
val arp = arps[lane * Pattern.TRACK_COUNT + bus]
|
||||
@@ -888,19 +893,33 @@ class AudioEngine(
|
||||
}
|
||||
} else {
|
||||
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
|
||||
* 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,
|
||||
* stealing the oldest when full) is what makes the instrument polyphonic. Shared
|
||||
* 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
|
||||
// into this block's active list, so a note started mid-block sounds sample-
|
||||
// accurately within the same block.
|
||||
@@ -908,14 +927,16 @@ class AudioEngine(
|
||||
val vs = voiceSample(slot, note)
|
||||
when {
|
||||
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)
|
||||
// Empty bus (no instrument — e.g. the default channel 0) or a Sampler
|
||||
// with an empty pad → silent, matching the live audition path.
|
||||
slot?.type == null || slot.type == ToolboxType.SAMPLER -> {}
|
||||
// NES synth, or a SoundFont with nothing loaded → the tone generator.
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,10 @@ class SampleVoice(private val engineSampleRate: Int) {
|
||||
private var endIndex = 0
|
||||
private var amp = 0f
|
||||
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,
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
* voice, so any carried level rings out smoothly instead of snapping to 0). */
|
||||
|
||||
@@ -26,6 +26,10 @@ class SynthVoice(private val sampleRate: Int) {
|
||||
|
||||
private var patch: SynthPatch = SynthPatch.DEFAULT
|
||||
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 baseFreq = 440.0
|
||||
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() {
|
||||
ampEnv.kill(); filtEnv.kill()
|
||||
pitch = -1
|
||||
ownerTrack = -1
|
||||
icB = 0f; icL = 0f
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user