diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6e92faf..3256370 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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" diff --git a/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt b/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt index f261e03..9969e5b 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/audio/AudioEngine.kt @@ -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)) } } } diff --git a/app/src/main/java/space/rcmd/android/sizzle/audio/SampleVoice.kt b/app/src/main/java/space/rcmd/android/sizzle/audio/SampleVoice.kt index d81ee50..ee8bfe8 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/audio/SampleVoice.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/audio/SampleVoice.kt @@ -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). */ diff --git a/app/src/main/java/space/rcmd/android/sizzle/audio/SynthVoice.kt b/app/src/main/java/space/rcmd/android/sizzle/audio/SynthVoice.kt index 660acd4..4b51473 100644 --- a/app/src/main/java/space/rcmd/android/sizzle/audio/SynthVoice.kt +++ b/app/src/main/java/space/rcmd/android/sizzle/audio/SynthVoice.kt @@ -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 }