iOS MidiKeyboard Touch Velocity

I’ve integrated iOS MotionManager into the MidiKeyboardComponent to pick up touch velocity on the iPad. It’s not bad, but there’s a fair amount of unwanted latency, especially when compared to Garageband’s keyboard velocity, which is one of the better feeling velocity keyboards I’ve come across. I thought the info and code below might be useful for anyone else looking to do this, and I’m hoping for suggestions on how one might improve this.

I had originally hoped to add touch sensitive velocity to the MidiKeyboardComponent on iPad using 3D Touch. Turns out, Apple hasn’t added 3D touch to the iPad. After snooping around, it’s widely believed this is achieved in apps like Garageband, via the Accelerometer.

Upon initial implementation, I realized that the dip in the z plane happens after the initial touch. So, in mouseDown, I collected a series of subsequent Accelerometer-z values, took the lowest one, and then triggered the note. This worked, however, in doing so, I created a quite a bit of latency, making the keyboard hard to play. This is due to the time it takes to collect the values, and possibly the time to copy the MouseEvent (a hacky way to hold onto the event).

So I switched to the Gyroscope’s y axis, which works only because the keybaord is on the bottom of the screen, and I found I was able to pick up the necessary values quicker, shortening the latency. Though it’s still more latent than I’d like, and certainly more latent than Garageband. And, with the Gyroscope, the iPad has to be on a surface with a little give (like a pillow) in order to allow for rotation. So…

Accelerometer = flat surface, too much latency
Gyroscope = soft surface, latency I can live with but would rather not

MotionManager.h

//
//  MotionManager.h
//  Syntorial
//
//  Created by Joe Hanley on 1/28/15.
//
//

#define MaxYCount 3

class MyKeyboardComponent;

class MotionManager
{
public:

    MotionManager(MyKeyboardComponent* keyboard_);
    ~MotionManager();

    void gyroChanged (float y);
    void accelerometerChanged (float z);
    void startCollecting();
    void start();
    void stop();

private:

    void* motionManagerWrapper;
    MyKeyboardComponent* keyboard;

    bool isCollecting, isRunning;
    float highestY;
    int yCount;
};

MotionManager.mm

#include "MotionManager.h"
#import <UIKit/UIKit.h>
#import <CoreMotion/CoreMotion.h>
#import <CoreLocation/CoreLocation.h>

@interface MotionManagerWrapper : NSObject
{
    MotionManager* owner;
}

@property (strong, nonatomic) CMMotionManager *motionManager;

@end

@implementation MotionManagerWrapper

- (void) startMotionManager
{
    self.motionManager = [[CMMotionManager alloc] init];
    self.motionManager.accelerometerUpdateInterval = .001;
    self.motionManager.gyroUpdateInterval = .001;
    
    
//    [self.motionManager startAccelerometerUpdatesToQueue:[NSOperationQueue currentQueue]
//                                             withHandler:^(CMAccelerometerData  *accelerometerData, NSError *error) {
//                                                 [self outputAccelerationData:accelerometerData.acceleration];
//                                                 if(error){
//
//                                                     NSLog(@"%@", error);
//                                                 }
//                                             }];
    
    
    [self.motionManager startGyroUpdatesToQueue:[NSOperationQueue currentQueue]
                                    withHandler:^(CMGyroData *gyroData, NSError *error) {
                                        [self outputRotationData:gyroData.rotationRate];
                                    }];
}

- (void) stopMotionManager
{
    [self.motionManager stopGyroUpdates];
//    [self.motionManager stopAccelerometerUpdates];
    self.motionManager = nil;
}

-(void)outputAccelerationData:(CMAcceleration)acceleration
{
    owner->accelerometerChanged(acceleration.z);
}
-(void)outputRotationData:(CMRotationRate)rotation
{
    owner->gyroChanged(fabs(rotation.y));
}

- (id) initWithOwner: (MotionManager*) owner_
{
    if ((self = [super init]) != nil)
    {
        owner = owner_;
    };
    
    return self;
}

- (void) dealloc
{
    [self stopMotionManager];
    [super dealloc];
}

@end

//==============================================================================
MotionManager::MotionManager(MyKeyboardComponent* keyboard_)
: keyboard(keyboard_),
isCollecting(false),
isRunning(false),
highestY (0.0),
yCount (0)
{
    MotionManagerWrapper* newManager = [[MotionManagerWrapper alloc] initWithOwner: this];
    [newManager retain];
    
    motionManagerWrapper = newManager;
}

MotionManager::~MotionManager()
{
    [((MotionManagerWrapper*) motionManagerWrapper) release];
}

void MotionManager::accelerometerChanged (float z)
{
    
//    if (isCollecting)
//    {
//        if (z < lowestZ)
//            lowestZ = z;
//        zCount++;
//
//        if (zCount >= MaxYCount)
//        {
//            isCollecting = false;
//            float velocity = 1;
//            float high = -0.9, low = -1.3;
//            lowestZ = jlimit(low, high, lowestZ);
//            float range = high - low;
//            velocity = (lowestZ - high) / range * -1;
//            keyboard->triggerNoteWithVelocity(velocity);
//        }
//    }
}

void MotionManager::gyroChanged (float y)
{
    if (isCollecting)
    {
        if (y > highestY)
            highestY = y;

        yCount++;

        if (yCount >= MaxYCount)
        {
            isCollecting = false;

            if (highestY < 0.1)
                highestY = 0.1;

            keyboard->triggerNoteWithVelocity(highestY);
        }
    }
}

void MotionManager::startCollecting()
{
    yCount = 0;
    highestY = 0.0;
    
//    zCount = 0;
//    lowestZ = 0;
    
    isCollecting = true;
}

void MotionManager::start()
{
    if (!isRunning)
    {
        [(MotionManagerWrapper*) motionManagerWrapper startMotionManager];
        isRunning = true;
    }
}

void MotionManager::stop()
{
    [(MotionManagerWrapper*) motionManagerWrapper stopMotionManager];
    isRunning = false;
}

MyKeyboardComponent.h (abbreviated for relevancy)

class MyKeyboardComponent : public MidiKeyboardComponent
{
public:
	MyKeyboardComponent(MidiKeyboardState& state_, const Orientation orientation_);
    ~MyKeyboardComponent();
    
    void mouseDown(const MouseEvent &event);
    void fillMouseStats(const MouseEvent& event, bool wasDragged);
    void triggerNote(const MouseEvent& event, float velocity = -1.0);
    void triggerNoteWithVelocity(float velocity);
    
private:
    
	Orientation orientation;
    ScopedPointer<MotionManager> motionManager;
    
    class MouseStats
    {
    public:
        
        MouseStats()
        : source (nullptr)
        {}
        
        ScopedPointer<MouseInputSource> source;
        Point<float> position;
        ModifierKeys modifiers;
        float pressure;
        float orientation;
        float rotation;
        float tiltX;
        float tiltY;
        Component* eventComponent;
        Component* originator;
        Time eventTime;
        Point<float> mouseDownPos;
        Time mouseDownTime;
        int numberOfClicks;
        bool mouseWasDragged;
    };
    
    MouseStats lastMouseDownStats;
};

MyKeyboardComponent.cpp (abbreviated for relevancy)

MyKeyboardComponent::MyKeyboardComponent(MidiKeyboardState& state_, const Orientation orientation_)
: MidiKeyboardComponent(state_, orientation_),
orientation (orientation_),
doVelocity (false)
{

    lastMouseDownStats.source = new MouseInputSource(Desktop::getInstance().getMainMouseSource());
    lastMouseDownStats.position.setX(-999);
    motionManager = new MotionManager(this);
}

MyKeyboardComponent::~MyKeyboardComponent()
{
    motionManager->stop();
}

void MyKeyboardComponent::mouseDown(const MouseEvent &event)
{
    fillMouseStats(event, false);
    motionManager->startCollecting();
}

void MyKeyboardComponent::fillMouseStats(const MouseEvent& event, bool wasDragged)
{
    *(lastMouseDownStats.source) = event.source;
    lastMouseDownStats.position.setX(event.position.getX());
    lastMouseDownStats.position.setY(event.position.getY());
    lastMouseDownStats.modifiers = event.mods;
    lastMouseDownStats.pressure = event.pressure;
    lastMouseDownStats.orientation = event.orientation;
    lastMouseDownStats.rotation = event.rotation;
    lastMouseDownStats.tiltX = event.tiltX;
    lastMouseDownStats.tiltY = event.tiltY;
    lastMouseDownStats.eventComponent = event.eventComponent;
    lastMouseDownStats.originator = event.originalComponent;
    lastMouseDownStats.eventTime = event.eventTime;
    lastMouseDownStats.mouseDownPos.setX(event.getMouseDownX());
    lastMouseDownStats.mouseDownPos.setY(event.getMouseDownY());
    lastMouseDownStats.mouseDownTime = event.mouseDownTime;
    lastMouseDownStats.numberOfClicks = event.getNumberOfClicks();
    lastMouseDownStats.mouseWasDragged = wasDragged;
}

void MyKeyboardComponent::triggerNoteWithVelocity(float velocity)
{
    MouseEvent eventCopy (*(lastMouseDownStats.source), lastMouseDownStats.position, lastMouseDownStats.modifiers, lastMouseDownStats.pressure, lastMouseDownStats.orientation, lastMouseDownStats.rotation, lastMouseDownStats.tiltX, lastMouseDownStats.tiltY, lastMouseDownStats.eventComponent, lastMouseDownStats.originator, lastMouseDownStats.eventTime, lastMouseDownStats.mouseDownPos, lastMouseDownStats.mouseDownTime, lastMouseDownStats.numberOfClicks, lastMouseDownStats.mouseWasDragged);
    
    triggerNote(eventCopy, velocity);
    
}
3 Likes

Hi Hanley,
thank you very much for posting. Some time ago, I tried to find a good approach, too. But I stopped investigating. I’m sure to get back to this soon. Like you, I also had the idea to work with the touch size. Did you futher experiment with that, too?
The Garageband keyboard is definitively quite responsive. It would be great to lift the secret about it.
If I work further on this and have something to share, I will do it here.

1 Like

I did experiment with touchSize. But it jumped between two values, essentially only providing a “hard” or “soft” velocity.

I’ve also read about approaches that use the iPad’s built-in mic to listen for taps and calculate velocity according to the loudness of the actual tap. But I imagine that might not work so well in noisy environments.

Thanks in advance for sharing.

This is very helpful! I am going to attempt to incorporate this into an application I’m working on, but before I get too far into the weeds, I’m wondering if you’ve found any other more convenient way to achieve velocity sensitivity on iPad (i.e. assuming no ForceTouch sensor). I definitely don’t want my velocity to be dependent on the app being used on a soft surface, so will probably experiment with collecting fewer z-values etc. and see how far that takes me.

I haven’t tweaked it since. The soft surface requirement is definitely something I’d prefer to do without, so I’d love to hear about what you come up with.

I tabled this issue for a bit while I worked on other more urgent features of my plugin. Now I have succeeded in implementing your MotionManager in my project and am successfully collecting z-acceleration values. To try and reduce the latency, I am only collecting 1 z-acceleration value, rather than 3, and when playing on a hard surface, am getting values ranging between approximately -0.09 and 0.3 (which seem more less correlated with the actual velocity, but it’s definitely imperfect). Thanks so much for sharing your approach; I would not have gotten this far without it!

Before I continue working on this, I noticed that you currently have the MotionManager tell your keyboard to trigger MIDI notes once it is done collecting, rather than having the MotionManager report the velocity back to the keyboard and then have the keyboard trigger the note. Is this to avoid a race condition/to avoid the clunkiness of trying to get the keyboard to wait for the MotionManager to collect a velocity value? I ask because ideally my application would work on both iOS and Android and so I’m deciding on how to structure this such that I don’t tie critical functionality into the MotionManager (for now at least).