Skip to content

Commit

Permalink
Issue #106: Added mechanism to deal with 'stuck' note on commands upo…
Browse files Browse the repository at this point in the history
…n disconnection to MIKMIDIConnectionManager.
  • Loading branch information
Andrew Madsen committed Nov 11, 2015
1 parent bbaec26 commit f6627cb
Show file tree
Hide file tree
Showing 2 changed files with 99 additions and 14 deletions.
27 changes: 22 additions & 5 deletions Source/MIKMIDIConnectionManager.h
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@
#import "MIKMIDISourceEndpoint.h"

@class MIKMIDIDevice;
@class MIKMIDINoteOnCommand;

@protocol MIKMIDIConnectionManagerDelegate;

NS_ASSUME_NONNULL_BEGIN

/**
* MIKMIDIConnectionManager can be used to manage a set of connected devices. It can be configured to automatically
* MIKMIDIConnectionManager can be used to manage a set of connected devices. It can be configured to automatically
* connect to devices as they are added, and disconnect from them as they are removed. It also supports saving
* the list of connected to NSUserDefaults and restoring them upon relaunch.
*
Expand Down Expand Up @@ -62,7 +63,7 @@ NS_ASSUME_NONNULL_BEGIN
- (BOOL)connectToDevice:(MIKMIDIDevice *)device error:(NSError **)error;

/**
* Disconnect from a connected device. No further messages from the specified device will be processed.
* Disconnect from a connected device. No further messages from the specified device will be processed.
*
* Note that you don't need to call this method when a previously-connected device was removed from the system.
* Disconnection in that situation is handled automatically.
Expand Down Expand Up @@ -96,7 +97,7 @@ NS_ASSUME_NONNULL_BEGIN
* Load and reconnect to the devices previously saved to disk by a call to -saveConfiguration. For
* this to work, the receiver's name must be the same as it was upon the previous call to -saveConfiguration.
*
* @note: This method will only connect to new devices. It will not disconnect from devices not found in the
* @note: This method will only connect to new devices. It will not disconnect from devices not found in the
* saved configuration.
*/
- (void)loadConfiguration;
Expand All @@ -108,7 +109,7 @@ NS_ASSUME_NONNULL_BEGIN

/**
* An MIKMIDIEventHandlerBlock to be called with incoming MIDI messages from any connected device.
*
*
* If you need to determine which device sent the passed in messages, call source.entity.device on the
* passed in MIKMIDISourceEndpoint argument.
*/
Expand Down Expand Up @@ -157,7 +158,7 @@ NS_ASSUME_NONNULL_BEGIN


/**
* Specifies behavior for connecting to a newly connected device. See
* Specifies behavior for connecting to a newly connected device. See
* -connectionManager:shouldConnectToNewlyAddedDevice:
*/
typedef NS_ENUM(NSInteger, MIKMIDIAutoConnectBehavior) {
Expand Down Expand Up @@ -195,6 +196,22 @@ typedef NS_ENUM(NSInteger, MIKMIDIAutoConnectBehavior) {
*/
- (MIKMIDIAutoConnectBehavior)connectionManager:(MIKMIDIConnectionManager *)manager shouldConnectToNewlyAddedDevice:(MIKMIDIDevice *)device;

/**
* A connection manager's delegate can implement this method to be notified when a connected device is disconnected,
* either because -disconnectFromDevice: was called, or because the device was unplugged.
*
* If a MIDI device is disconnected between sending a note on message and sending the corresponding note off command,
* this can cause a "stuck note" because the note off command will now never be delivered. e.g. a MIDI piano keyboard
* that is disconnected with a key held down. This method includes an array of these unterminated note on commands (if any)
* so that the receiver can appropriately deal with this situation. For example, corresponding note off commands could
* be generated and sent through whatever processing chain is processing incoming MIDI commands to terminate stuck notes.
*
* @param manager An instance of MIKMIDIConnectionManager.
* @param device The MIKMIDIDevice that was disconnected.
* @param commands An array of note on messages for which corresponding note off messages have not yet been received.
*/
- (void)connectionManager:(MIKMIDIConnectionManager *)manager deviceWasDisconnected:(MIKMIDIDevice *)device withUnterminatedNoteOnCommands:(MIKArrayOf(MIKMIDINoteOnCommand *) *)commands;

@end

NS_ASSUME_NONNULL_END
Expand Down
86 changes: 77 additions & 9 deletions Source/MIKMIDIConnectionManager.m
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,26 @@
#import "MIKMIDIConnectionManager.h"
#import "MIKMIDIDeviceManager.h"
#import "MIKMIDIDevice.h"
#import "MIKMIDIEntity.h"
#import "MIKMIDINoteOnCommand.h"
#import "MIKMIDINoteOffCommand.h"

void *MIKMIDIConnectionManagerKVOContext = &MIKMIDIConnectionManagerKVOContext;

NSString * const MIKMIDIConnectionManagerConnectedDevicesKey = @"MIKMIDIConnectionManagerConnectedDevicesKey";
NSString * const MIKMIDIConnectionManagerUnconnectedDevicesKey = @"MIKMIDIConnectionManagerUnconnectedDevicesKey";

BOOL MIKMIDINoteOffCommandCorrespondsWithNoteOnCommand(MIKMIDINoteOffCommand *noteOff, MIKMIDINoteOnCommand *noteOn);

@interface MIKMIDIConnectionManager ()

@property (nonatomic, strong, readwrite) MIKArrayOf(MIKMIDIDevice *) *availableDevices;

@property (nonatomic, strong, readonly) MIKMutableSetOf(MIKMIDIDevice *) *internalConnectedDevices;
@property (nonatomic, strong, readonly) MIKMIDIEventHandlerBlock internalEventHandler;
@property (nonatomic, strong, readonly) MIKMapTableOf(MIKMIDIDevice *, id) *connectionTokensByDevice;

@property (nonatomic, strong) MIKMapTableOf(MIKMIDIDevice *, NSMutableArray *) *pendingNoteOnsByDevice;

@property (nonatomic, readonly) MIKMIDIDeviceManager *deviceManager;

@end
Expand All @@ -48,12 +54,8 @@ - (instancetype)initWithName:(NSString *)name delegate:(id<MIKMIDIConnectionMana

_internalConnectedDevices = [[NSMutableSet alloc] init];

__weak typeof(self) weakSelf = self;
_internalEventHandler = ^(MIKMIDISourceEndpoint *endpoint, NSArray *commands) {
weakSelf.eventHandler(endpoint, commands);
};

_connectionTokensByDevice = [NSMapTable mapTableWithKeyOptions:NSPointerFunctionsStrongMemory valueOptions:NSPointerFunctionsStrongMemory];
_pendingNoteOnsByDevice = [NSMapTable mapTableWithKeyOptions:NSPointerFunctionsStrongMemory valueOptions:NSPointerFunctionsStrongMemory];

NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
[self.deviceManager addObserver:self forKeyPath:@"availableDevices" options:options context:MIKMIDIConnectionManagerKVOContext];
Expand Down Expand Up @@ -223,14 +225,19 @@ - (MIKMIDIDevice *)firstAvailableDeviceWithName:(NSString *)deviceName
return [[self.availableDevices filteredArrayUsingPredicate:predicate] firstObject];
}

#pragma mark - Connection / Disconnection
#pragma mark Connection / Disconnection

- (BOOL)internalConnectToDevice:(MIKMIDIDevice *)device error:(NSError **)error
{
if ([self isConnectedToDevice:device]) return YES;
error = error ?: &(NSError *__autoreleasing){ nil };

id token = [self.deviceManager connectDevice:device error:error eventHandler:self.internalEventHandler];
__weak typeof(self) weakSelf = self;
id token = [self.deviceManager connectDevice:device error:error eventHandler:^(MIKMIDISourceEndpoint *endpoint, NSArray *commands) {
[weakSelf recordPendingNoteOnCommands:commands fromDevice:device];
[weakSelf removePendingNoteOnCommandsTerminatedByNoteOffCommands:commands fromDevice:device];
weakSelf.eventHandler(endpoint, commands);
}];
if (!token) return NO;

[self.connectionTokensByDevice setObject:token forKey:device];
Expand Down Expand Up @@ -263,6 +270,11 @@ - (void)internalDisconnectFromDevice:(MIKMIDIDevice *)device
withSetMutation:NSKeyValueMinusSetMutation
usingObjects:[NSSet setWithObject:device]];

if ([self.delegate respondsToSelector:@selector(connectionManager:deviceWasDisconnected:withUnterminatedNoteOnCommands:)]) {
NSArray *pendingNoteOns = [self pendingNoteOnCommandsForDevice:device];
[self.delegate connectionManager:self deviceWasDisconnected:device withUnterminatedNoteOnCommands:pendingNoteOns];
}

if (self.automaticallySavesConfiguration) [self saveConfiguration];
}

Expand All @@ -278,7 +290,7 @@ - (void)connectToNewlyAddedDeviceIfAppropriate:(MIKMIDIDevice *)device
if (!device) return;

MIKMIDIAutoConnectBehavior behavior = MIKMIDIAutoConnectBehaviorConnectIfPreviouslyConnectedOrNew;

if ([self.delegate respondsToSelector:@selector(connectionManager:shouldConnectToNewlyAddedDevice:)]) {
behavior = [self.delegate connectionManager:self shouldConnectToNewlyAddedDevice:device];
}
Expand Down Expand Up @@ -368,6 +380,53 @@ - (MIKMIDIDevice *)deviceContainingEndpoint:(MIKMIDIEndpoint *)endpoint
return nil;
}

#pragma mark Pending Note Ons

- (NSMutableArray *)pendingNoteOnCommandsForDevice:(MIKMIDIDevice *)device
{
NSMutableArray *result = [self.pendingNoteOnsByDevice objectForKey:device];
if (!result) {
result = [NSMutableArray array];
[self.pendingNoteOnsByDevice setObject:result forKey:device];
}
return result;
}

- (void)recordPendingNoteOnCommands:(MIKArrayOf(MIKMIDICommand *) *)commands fromDevice:(MIKMIDIDevice *)device
{
commands = [commands filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(id obj, NSDictionary *b) {
return [obj isKindOfClass:[MIKMIDINoteOnCommand class]];
}]];
if (![commands count]) return;

NSMutableArray *pendingNoteOns = [self pendingNoteOnCommandsForDevice:device];
[pendingNoteOns addObjectsFromArray:commands];
}

- (void)removePendingNoteOnCommandsTerminatedByNoteOffCommands:(MIKArrayOf(MIKMIDICommand *) *)commands fromDevice:(MIKMIDIDevice *)device
{
commands = [commands filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(id obj, NSDictionary *b) {
if ([obj isKindOfClass:[MIKMIDINoteOnCommand class]] &&
[(MIKMIDINoteOnCommand *)obj velocity] == 0) {
return YES;
}
return [obj isKindOfClass:[MIKMIDINoteOffCommand class]];
}]];
if (![commands count]) return;

NSMutableArray *pendingNoteOns = [self pendingNoteOnCommandsForDevice:device];
if (![pendingNoteOns count]) return;

for (MIKMIDINoteOffCommand *noteOff in commands) {
for (MIKMIDINoteOnCommand *noteOn in pendingNoteOns) {
if (MIKMIDINoteOffCommandCorrespondsWithNoteOnCommand(noteOff, noteOn)) {
[pendingNoteOns removeObject:noteOn];
continue;
}
}
}
}

#pragma mark - Notifications

- (void)deviceWasPluggedIn:(NSNotification *)notification
Expand Down Expand Up @@ -458,3 +517,12 @@ + (BOOL)automaticallyNotifiesObserversOfConnectedDevices { return NO; }
}

@end

BOOL MIKMIDINoteOffCommandCorrespondsWithNoteOnCommand(MIKMIDINoteOffCommand *noteOff, MIKMIDINoteOnCommand *noteOn)
{
if (noteOff.channel != noteOn.channel) return NO;
if (noteOff.note != noteOn.note) return NO;
if ([noteOff.timestamp compare:noteOn.timestamp] != NSOrderedAscending) return NO;

return YES;
}

0 comments on commit f6627cb

Please sign in to comment.