Skip to content

Commit 680c482

Browse files
committed
feat(velocity): Fixed velocity vs. per note midi velocity
Fixes #2
1 parent a7b5b82 commit 680c482

2 files changed

Lines changed: 104 additions & 104 deletions

File tree

step-recorder/src/main/kotlin/com/b3rnhard/steprecorder/MidiLearnBinding.kt

Lines changed: 73 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -4,99 +4,91 @@ import com.bitwig.extension.controller.api.ControllerHost
44
import com.bitwig.extension.controller.api.SettableEnumValue
55

66
class MidiLearnBinding(
7-
private val host: ControllerHost,
8-
private val name: String,
9-
category: String,
10-
initialValue: String,
11-
private val onTrigger: (Int) -> Unit // Callback function to execute when the binding is triggered, now accepts MIDI value
7+
private val host: ControllerHost,
8+
private val name: String,
9+
private val onTrigger: (Int) -> Unit // Callback function to execute when the binding is triggered, now accepts MIDI value
1210
) {
1311

14-
private var cc: Int = -1
15-
private var channel: Int = -1
16-
private var isLearning: Boolean = false
17-
private lateinit var setting: SettableEnumValue
12+
private var cc: Int = -1
13+
private var channel: Int = -1
14+
private var isLearning: Boolean = false
15+
private var setting: SettableEnumValue = host.preferences.getEnumSetting(
16+
name,
17+
"MIDI Learn",
18+
arrayOf("Not Mapped", "Learning..."),
19+
"Not Mapped"
20+
)
1821

19-
init {
20-
setting = host.preferences.getEnumSetting(
21-
name,
22-
category,
23-
arrayOf("Not Mapped", "Learning..."),
24-
initialValue
25-
)
22+
init {
2623

27-
setting.addValueObserver { value ->
28-
when (value) {
29-
"Learning..." -> {
30-
isLearning = true
31-
host.showPopupNotification("Learning $name - Send a CC message")
32-
}
33-
"Not Mapped" -> {
34-
cc = -1
35-
channel = -1
36-
host.showPopupNotification("$name unmapped")
37-
isLearning = false // Stop learning if manually set to Not Mapped
38-
}
39-
else -> { // Handle saved CC value
40-
parseAndSetMidiBinding(value)
41-
isLearning = false // Stop learning after parsing saved value
42-
}
43-
}
24+
setting.addValueObserver { value ->
25+
when (value) {
26+
"Learning..." -> {
27+
isLearning = true
28+
host.showPopupNotification("Learning $name - Send a CC message")
4429
}
4530

46-
// Trigger observer once on initialization to load any saved value
47-
setting.get();
48-
}
31+
"Not Mapped" -> {
32+
cc = -1
33+
channel = -1
34+
host.showPopupNotification("$name unmapped")
35+
isLearning = false // Stop learning if manually set to Not Mapped
36+
}
4937

50-
private fun parseAndSetMidiBinding(value: String) {
51-
val parts = value.split(" ")
52-
if (parts.size == 4 && parts[0] == "CC" && parts[2] == "Ch") {
53-
try {
54-
cc = parts[1].toInt()
55-
channel = parts[3].toInt() - 1 // Channel is 0-indexed internally
56-
host.showPopupNotification("$name mapped to CC ${cc} Channel ${channel + 1}")
57-
} catch (e: NumberFormatException) {
58-
host.println("Error parsing saved $name setting: $value")
59-
// Optionally reset the setting if parsing fails?
60-
// setting.set("Not Mapped")
61-
}
62-
} else {
63-
// If the format is unexpected (e.g., manual edit), reset to Not Mapped
64-
// This might be too aggressive, maybe just log an error?
65-
// host.println("Unexpected saved $name setting format: $value")
66-
// setting.set("Not Mapped")
38+
else -> { // Handle saved CC value
39+
parseAndSetMidiBinding(value)
40+
isLearning = false // Stop learning after parsing saved value
6741
}
42+
}
43+
}
44+
45+
// Trigger observer once on initialization to load any saved value
46+
setting.get()
47+
}
48+
49+
private fun parseAndSetMidiBinding(value: String) {
50+
val parts = value.split(" ")
51+
if (parts.size == 4 && parts[0] == "CC" && parts[2] == "Ch") {
52+
try {
53+
cc = parts[1].toInt()
54+
channel = parts[3].toInt() - 1 // Channel is 0-indexed internally
55+
host.showPopupNotification("$name mapped to CC ${cc} Channel ${channel + 1}")
56+
} catch (_: NumberFormatException) {
57+
host.println("Error parsing saved $name setting: $value")
58+
}
6859
}
60+
}
6961

70-
/**
71-
* Call this from your MidiIn callback to handle incoming MIDI messages.
72-
* Returns true if the message was handled by this binding (either learned or triggered),
73-
* false otherwise.
74-
*/
75-
fun handleMidiMessage(status: Int, data1: Int, data2: Int): Boolean {
76-
// Check if it's a CC message
77-
if (status in 0xB0..0xBF) {
78-
val messageChannel = status and 0x0F
79-
val messageCC = data1
80-
val value = data2
62+
/**
63+
* Call this from your MidiIn callback to handle incoming MIDI messages.
64+
* Returns true if the message was handled by this binding (either learned or triggered),
65+
* false otherwise.
66+
*/
67+
fun handleMidiMessage(status: Int, data1: Int, data2: Int): Boolean {
68+
// Check if it's a CC message
69+
if (status in 0xB0..0xBF) {
70+
val messageChannel = status and 0x0F
71+
val messageCC = data1
72+
val value = data2
8173

82-
if (isLearning) {
83-
// If currently learning, capture the CC and channel
84-
cc = messageCC
85-
channel = messageChannel
86-
setting.set("CC ${cc} Ch ${channel + 1}") // Save the binding as a string
87-
isLearning = false // Stop learning after capturing
88-
host.showPopupNotification("$name mapped to CC ${cc} Channel ${channel + 1}")
89-
return true // Message was handled (learned)
90-
}
74+
if (isLearning) {
75+
// If currently learning, capture the CC and channel
76+
cc = messageCC
77+
channel = messageChannel
78+
setting.set("CC ${cc} Ch ${channel + 1}") // Save the binding as a string
79+
isLearning = false // Stop learning after capturing
80+
host.showPopupNotification("$name mapped to CC ${cc} Channel ${channel + 1}")
81+
return true // Message was handled (learned)
82+
}
9183

92-
// If not learning, check if the message matches the learned binding
93-
if (cc != -1 && channel != -1 && messageCC == cc && messageChannel == channel && value != 0) {
94-
// For CC messages, trigger the callback with the value
95-
onTrigger.invoke(value)
84+
// If not learning, check if the message matches the learned binding
85+
if (cc != -1 && channel != -1 && messageCC == cc && messageChannel == channel && value != 0) {
86+
// For CC messages, trigger the callback with the value
87+
onTrigger.invoke(value)
9688

97-
return true // Message was handled (triggered)
98-
}
99-
}
100-
return false // Message was not handled by this binding
89+
return true // Message was handled (triggered)
90+
}
10191
}
92+
return false // Message was not handled by this binding
93+
}
10294
}

step-recorder/src/main/kotlin/com/b3rnhard/steprecorder/StepRecorderExtension.kt

Lines changed: 31 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import com.bitwig.extension.controller.api.*
88
class StepRecorderExtension(definition: ControllerExtensionDefinition, host: ControllerHost) :
99
ControllerExtension(definition, host) {
1010

11+
private lateinit var fixedVelocityToggle: ISettableBooleanValue
12+
private lateinit var fixedVelocitySetting: SettableRangedValue
1113
private lateinit var documentState: DocumentState
1214
private lateinit var application: Application
1315
private lateinit var transport: Transport
@@ -34,7 +36,7 @@ class StepRecorderExtension(definition: ControllerExtensionDefinition, host: Con
3436

3537
// Chord detection
3638
private val activeNotes = mutableMapOf<Int, Long>()
37-
private val pendingNotes = mutableSetOf<Int>()
39+
private val pendingNotes = mutableListOf<Pair<Int, Int>>()
3840
private var firstNoteTime: Long = 0
3941
private var lastNoteOnTime: Long = 0
4042
private val chordThresholdMs = 100L // Notes within 100ms are considered a chord
@@ -72,6 +74,7 @@ class StepRecorderExtension(definition: ControllerExtensionDefinition, host: Con
7274
setupClearToggle()
7375
setupNoteSelectionObserver()
7476
setClearOldNotesWhenRecording()
77+
setupVelocity()
7578

7679
updateStepLength()
7780
resetCursorClipToPlayStart()
@@ -97,7 +100,7 @@ class StepRecorderExtension(definition: ControllerExtensionDefinition, host: Con
97100
if (velocity > 0) {
98101
// Note On
99102
activeNotes[note] = currentTime
100-
pendingNotes.add(note)
103+
pendingNotes.add(note to velocity)
101104

102105
// Track first note time only when starting a new chord
103106
if (pendingNotes.size == 1) {
@@ -133,6 +136,18 @@ class StepRecorderExtension(definition: ControllerExtensionDefinition, host: Con
133136
}
134137
}
135138

139+
private fun setupVelocity() {
140+
fixedVelocityToggle = documentState.getEnumBasedBooleanSetting("Fixed velocity", "Step Recorder", false)
141+
MidiLearnBinding(host, "Fixed Velocity Binding") {
142+
fixedVelocityToggle.set(!(fixedVelocityToggle.get()))
143+
}
144+
fixedVelocitySetting =
145+
documentState.getNumberSetting("Note Velocity", "Step Recorder", 0.0, 127.0, 1.0, "units (0-127)", 127.0)
146+
MidiLearnBinding(host, "Fixed Note Velocity Binding") {
147+
fixedVelocitySetting.set(it.toDouble())
148+
}
149+
}
150+
136151
private fun updateStepLength() {
137152
stepper.updateNoteLengthInIntegerRepresentation(stepLengthValueSetting.get(), tripletSetting.get())
138153
}
@@ -149,8 +164,6 @@ class StepRecorderExtension(definition: ControllerExtensionDefinition, host: Con
149164
MidiLearnBinding(
150165
host,
151166
"Toggle triplet",
152-
"MIDI Learn",
153-
"Not Mapped",
154167
{
155168
val current = tripletSetting.get()
156169
val newValue = if (current === "Regular") "Triplet" else "Regular"
@@ -174,7 +187,7 @@ class StepRecorderExtension(definition: ControllerExtensionDefinition, host: Con
174187
}
175188

176189
cursorForwardAction.addSignalObserver(action)
177-
midiLearnBindings.add(MidiLearnBinding(host, "Forward Button", "MIDI Learn", "Not Mapped", action.withArg()))
190+
midiLearnBindings.add(MidiLearnBinding(host, "Forward Button", action.withArg()))
178191
}
179192

180193
private fun setupBackward() {
@@ -193,7 +206,7 @@ class StepRecorderExtension(definition: ControllerExtensionDefinition, host: Con
193206
}
194207

195208
cursorBackwardAction.addSignalObserver(action)
196-
midiLearnBindings.add(MidiLearnBinding(host, "Backward Button", "MIDI Learn", "Not Mapped", action.withArg()))
209+
midiLearnBindings.add(MidiLearnBinding(host, "Backward Button", action.withArg()))
197210
}
198211

199212
private lateinit var clearToggleSetting: ISettableBooleanValue
@@ -210,8 +223,6 @@ class StepRecorderExtension(definition: ControllerExtensionDefinition, host: Con
210223
MidiLearnBinding(
211224
host,
212225
"Clear Toggle on forward/backward Button",
213-
"MIDI Learn",
214-
"Not Mapped",
215226
{ clearToggleSetting.set(!clearToggleSetting.get()) }
216227
)
217228
)
@@ -229,8 +240,6 @@ class StepRecorderExtension(definition: ControllerExtensionDefinition, host: Con
229240
MidiLearnBinding(
230241
host,
231242
"Clear old notes on note input Toggle",
232-
"MIDI Learn",
233-
"Not Mapped",
234243
{ clearNotesOnInputSetting.set(!clearNotesOnInputSetting.get()) }
235244
)
236245
)
@@ -247,8 +256,6 @@ class StepRecorderExtension(definition: ControllerExtensionDefinition, host: Con
247256
MidiLearnBinding(
248257
host,
249258
"Clear Button",
250-
"MIDI Learn",
251-
"Not Mapped",
252259
this::clearNotesAtCurrentStepRange.withArg()
253260
)
254261
)
@@ -271,7 +278,7 @@ class StepRecorderExtension(definition: ControllerExtensionDefinition, host: Con
271278

272279
midiLearnBindings.add(
273280
MidiLearnBinding(
274-
host, "Step Length Value Control", "MIDI Learn", "Not Mapped",
281+
host, "Step Length Value Control",
275282
{ changeStepLengthValue(it) })
276283
)
277284
}
@@ -306,7 +313,7 @@ class StepRecorderExtension(definition: ControllerExtensionDefinition, host: Con
306313
}
307314
}
308315

309-
midiLearnBindings.add(MidiLearnBinding(host, "Enable/Disable", "MIDI Learn", "Not Mapped") {
316+
midiLearnBindings.add(MidiLearnBinding(host, "Enable/Disable") {
310317
val newEnabled = !enableSetting.get()
311318
enableSetting.set(newEnabled)
312319
val text = "Step recorder is now ${if (newEnabled) "enabled" else "disabled"}"
@@ -353,18 +360,18 @@ class StepRecorderExtension(definition: ControllerExtensionDefinition, host: Con
353360

354361
private fun processPendingNotes() {
355362
if (pendingNotes.isNotEmpty()) {
356-
val noteList = pendingNotes.sorted()
363+
val noteList = pendingNotes
357364

358365
if (noteList.size == 1) {
359-
addNotesToCurrentClip(noteList, 127)
366+
addNotesToCurrentClip(noteList)
360367
} else {
361368
val noteTimeSpan = lastNoteOnTime - firstNoteTime
362369

363370
if (noteTimeSpan <= chordThresholdMs) {
364-
addNotesToCurrentClip(noteList, 127)
371+
addNotesToCurrentClip(noteList)
365372
} else {
366373
noteList.forEach { note ->
367-
addNotesToCurrentClip(listOf(note), 127)
374+
addNotesToCurrentClip(listOf(note))
368375
}
369376
}
370377
}
@@ -373,7 +380,7 @@ class StepRecorderExtension(definition: ControllerExtensionDefinition, host: Con
373380
}
374381
}
375382

376-
private fun addNotesToCurrentClip(notes: List<Int>, velocity: Int) {
383+
private fun addNotesToCurrentClip(notes: List<Pair<Int, Int>>) {
377384
if (!clipLauncherCursorClip.exists().get()) {
378385
host.showPopupNotification("No active clip found. Create or select a clip and open piano roll.")
379386
return
@@ -395,14 +402,15 @@ class StepRecorderExtension(definition: ControllerExtensionDefinition, host: Con
395402
}
396403

397404
// Add all notes at the same position (chord)
398-
notes.forEach { note ->
405+
notes.forEach { (note, velocity) ->
399406
host.println("setStep y:${note}, x:${stepper.x}")
400407
// - 0.0001 prevents note lengths too close to the next and clearing deletes both
401408
val nl = stepper.stepLengthInBeats - 0.0001
402-
clipLauncherCursorClip.setStep(0, stepper.x, note, velocity, nl)
409+
val actualVelocity = if (fixedVelocityToggle.get()) fixedVelocitySetting.raw.toInt() else velocity
410+
clipLauncherCursorClip.setStep(0, stepper.x, note, actualVelocity, nl)
403411
}
404412

405-
val noteNames = notes.joinToString(", ") { getNoteNameFromMidi(it) }
413+
val noteNames = notes.joinToString(", ") { getNoteNameFromMidi(it.first) }
406414
host.println("Added $noteNames at step ${stepper.x}")
407415

408416
updateCursorSelection(stepper.x)
@@ -432,7 +440,7 @@ class StepRecorderExtension(definition: ControllerExtensionDefinition, host: Con
432440

433441
private fun resetCursorClipToPlayStart() {
434442
val playStartValue = clipLauncherCursorClip.playStart.get()
435-
val newStep = stepper.resetXFromBeats(playStartValue, clipLauncherCursorClip)
443+
stepper.resetXFromBeats(playStartValue, clipLauncherCursorClip)
436444

437445
host.println(
438446
"Reset step recorder cursor to play start ${

0 commit comments

Comments
 (0)