diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index ae2f49f930..4bb2583791 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -816,7 +816,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { /// the grid edge IS the cap. ±2 = two octaves down / up — which is also /// AVAudioUnitTimePitch's hard limit (±2400 cents) for the radio voice, /// so the radio reaches its true floor/ceiling at the grid edges. - private static let bendRange: Float = 2.0 + private static let bendRange = MenuBandController.bendRangeOctaves /// Time constant for both axes of the FX surface. A single exponential /// response keeps diagonals round and attached to the finger without /// overshoot or frame-rate-dependent elasticity. diff --git a/slab/menuband/Sources/MenuBand/MenuBandController.swift b/slab/menuband/Sources/MenuBand/MenuBandController.swift index 29c672aea3..f7ff1a5ded 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandController.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandController.swift @@ -1467,6 +1467,14 @@ final class MenuBandController { /// `midi.start()` so receivers (Live) scale matched. static let bendSemitonesPerUnit: CGFloat = 12 + /// The wheel's reach in `bendAmount` units (octaves): the trackpad + /// accumulator clamps here and a full-scale 14-bit bend means exactly + /// this far. MIDISynth and every external receiver are told the same + /// span via RPN 0, so a piano voice bends as far as the sample, radio + /// and GM voices — it used to stop at the GM default ±2 semitones. + static let bendRangeOctaves: Float = 2 + static let bendRangeSemitones = UInt8(bendRangeOctaves * 12) + /// Master ambience knob (trackpad X-axis on the bend gesture). /// Forwards straight to the synth's global reverb, so it colors /// every backend equally and is independent of which instrument @@ -1620,7 +1628,7 @@ final class MenuBandController { // amount; the MIDI value saturates naturally inside // `sendPitchBend` (14-bit signed limit, receiver-side bend // range determines audible cap). - let result = amount.finiteMagnitudeMultiply(8192) + let result = (amount / Self.bendRangeOctaves).finiteMagnitudeMultiply(8192) let value = Int16(result.safeClamped(Float(Int16.min), Float(Int16.max))) // Remember the current bend so a freshly-allocated round-robin // channel can be set to it at noteOn time (see currentBendValue). @@ -2498,16 +2506,20 @@ final class MenuBandController { } case let .pitchBend(value, channel): + // A hardware wheel spans ±1 octave on every voice: rescale it + // into the wider ±bendRangeOctaves span MIDISynth and external + // receivers are told to expect. let amount = Float(value) / 8192.0 + let scaled = Int16(Float(value) / Self.bendRangeOctaves) if !midiMode { - synth.sendPitchBend(value: value, channel: channel) + synth.sendPitchBend(value: scaled, channel: channel) // Non-MIDI-native backends need their equivalent direct // controls so the Arturia wheel works on every Menu Band voice. synth.setSamplePitchBend(amount: amount) synth.setRadioPitchBend(amount: amount) synth.setGMPitchBend(amount: amount) } - midi.sendPitchBend(value: value, channel: channel) + midi.sendPitchBend(value: scaled, channel: channel) case .reset: releaseAllHardwareMIDINotes() diff --git a/slab/menuband/Sources/MenuBand/MenuBandMIDI.swift b/slab/menuband/Sources/MenuBand/MenuBandMIDI.swift index 9304734ea1..ac3381bebf 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandMIDI.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandMIDI.swift @@ -532,16 +532,16 @@ final class MenuBandMIDI { } enabled = true debugLog("midi.start: enabled") - // Announce a 12-semitone pitch-bend range on every melodic - // channel via RPN 0 (Pitch Bend Sensitivity). The trackpad - // accumulator scales ±1 = ±12 semitones; without this the + // Announce the wheel's pitch-bend range on every melodic channel + // via RPN 0 (Pitch Bend Sensitivity), so full scale means + // ±bendRangeSemitones exactly as it does inside; without this the // receiver (Ableton et al.) interprets our full-scale 14-bit // bend as ±2 semitones, so dragging across an octave on the // trackpad sounded like a quarter-tone wobble in Live. for ch: UInt8 in 0..<16 { send([0xB0 | ch, 101, 0]) // RPN MSB = 0 send([0xB0 | ch, 100, 0]) // RPN LSB = 0 (Pitch Bend Sensitivity) - send([0xB0 | ch, 6, 12]) // Data Entry MSB = 12 semitones + send([0xB0 | ch, 6, MenuBandController.bendRangeSemitones]) send([0xB0 | ch, 38, 0]) // Data Entry LSB = 0 cents send([0xB0 | ch, 101, 127]) // RPN Null (MSB) send([0xB0 | ch, 100, 127]) // RPN Null (LSB) diff --git a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift index afe0228f31..09dd100f6c 100644 --- a/slab/menuband/Sources/MenuBand/MenuBandSynth.swift +++ b/slab/menuband/Sources/MenuBand/MenuBandSynth.swift @@ -1101,6 +1101,7 @@ final class MenuBandSynth { // channel 9) without an extra audible swap. selectMelodicProgram(au, program: currentMelodicProgram) selectDrumKit(au) + announceBendRange(au) midiSynth = avUnit midiSynthReady = true @@ -1646,6 +1647,22 @@ final class MenuBandSynth { } } + /// RPN 0 on every melodic channel: the same bend span MenuBandMIDI + /// announces to external receivers. Without it the AU keeps the GM + /// default of ±2 semitones, and the whole wheel is a small wobble. + private func announceBendRange(_ au: AudioUnit) { + for ch: UInt8 in 0..<16 where ch != 9 { + let cc: UInt8 = 0xB0 | ch + sendMIDIEvent(au, status: cc, data1: 101, data2: 0) + sendMIDIEvent(au, status: cc, data1: 100, data2: 0) + sendMIDIEvent(au, status: cc, data1: 6, + data2: MenuBandController.bendRangeSemitones) + sendMIDIEvent(au, status: cc, data1: 38, data2: 0) + sendMIDIEvent(au, status: cc, data1: 101, data2: 127) + sendMIDIEvent(au, status: cc, data1: 100, data2: 127) + } + } + @inline(__always) private func sendMIDIEvent(_ au: AudioUnit, status: UInt8, data1: UInt8, data2: UInt8 = 0) { MusicDeviceMIDIEvent(au, UInt32(status), UInt32(data1), UInt32(data2), 0) @@ -2580,6 +2597,7 @@ final class MenuBandSynth { pluginConnected = true pluginUnit = avUnit usingPluginInstrument = true + announceBendRange(avUnit.audioUnit) // Drop the sampler off the bus while the plugin is the melodic // voice — drums (ch 9) still need their sampler/MIDISynth path. disconnectMelodicSamplerIfNeeded()