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.
|
// (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"
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user