diff --git a/HapticKey.xcodeproj/project.pbxproj b/HapticKey.xcodeproj/project.pbxproj index 70bbd29..8274b23 100644 --- a/HapticKey.xcodeproj/project.pbxproj +++ b/HapticKey.xcodeproj/project.pbxproj @@ -24,6 +24,8 @@ 54FBCF291FD4CDE0000EB4D3 /* HTKMultitouchActuator.m in Sources */ = {isa = PBXBuildFile; fileRef = 54FBCF281FD4CDE0000EB4D3 /* HTKMultitouchActuator.m */; }; 54FBCF321FD52483000EB4D3 /* AppIcon.icns in Resources */ = {isa = PBXBuildFile; fileRef = 54FBCF301FD52483000EB4D3 /* AppIcon.icns */; }; 54FBCF331FD52483000EB4D3 /* StatusItem.pdf in Resources */ = {isa = PBXBuildFile; fileRef = 54FBCF311FD52483000EB4D3 /* StatusItem.pdf */; }; + D978A86E20218E8100F9810F /* HTKSounds.m in Sources */ = {isa = PBXBuildFile; fileRef = D978A86D20218E8100F9810F /* HTKSounds.m */; }; + D9F9D553202538C900E70D09 /* HTKSoundMenu.m in Sources */ = {isa = PBXBuildFile; fileRef = D9F9D552202538C900E70D09 /* HTKSoundMenu.m */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -62,6 +64,10 @@ 54FBCF281FD4CDE0000EB4D3 /* HTKMultitouchActuator.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = HTKMultitouchActuator.m; sourceTree = ""; }; 54FBCF301FD52483000EB4D3 /* AppIcon.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = AppIcon.icns; sourceTree = ""; }; 54FBCF311FD52483000EB4D3 /* StatusItem.pdf */ = {isa = PBXFileReference; lastKnownFileType = image.pdf; path = StatusItem.pdf; sourceTree = ""; }; + D978A86C20218E8100F9810F /* HTKSounds.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HTKSounds.h; sourceTree = ""; }; + D978A86D20218E8100F9810F /* HTKSounds.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = HTKSounds.m; sourceTree = ""; }; + D9F9D551202538C900E70D09 /* HTKSoundMenu.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = HTKSoundMenu.h; sourceTree = ""; }; + D9F9D552202538C900E70D09 /* HTKSoundMenu.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = HTKSoundMenu.m; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -117,6 +123,10 @@ 54CDDBF11FE44F33005F35A1 /* Classes */ = { isa = PBXGroup; children = ( + D9F9D551202538C900E70D09 /* HTKSoundMenu.h */, + D9F9D552202538C900E70D09 /* HTKSoundMenu.m */, + D978A86C20218E8100F9810F /* HTKSounds.h */, + D978A86D20218E8100F9810F /* HTKSounds.m */, 548E15391FD00412001C0D4C /* HTKAppDelegate.h */, 548E153A1FD00412001C0D4C /* HTKAppDelegate.m */, 5443CEFD1FE38F6C002D4086 /* HTKEvent.h */, @@ -227,12 +237,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + D9F9D553202538C900E70D09 /* HTKSoundMenu.m in Sources */, 5443CF0D1FE394BB002D4086 /* HTKHapticFeedback.m in Sources */, 549F9236200AF543003A8D7B /* HTKTimer.m in Sources */, 5443CEFF1FE38F6C002D4086 /* HTKEvent.m in Sources */, 548E15431FD00412001C0D4C /* main.m in Sources */, 544C76362017311500FF155C /* HTKLoginItem.m in Sources */, 5443CF081FE39293002D4086 /* HTKTapGestureEventListener.m in Sources */, + D978A86E20218E8100F9810F /* HTKSounds.m in Sources */, 548E153B1FD00412001C0D4C /* HTKAppDelegate.m in Sources */, 5443CEFC1FE38EA8002D4086 /* HTKEventListener.m in Sources */, 54ACC711201F15F70026CAFD /* HTKSystemSound.m in Sources */, diff --git a/HapticKey/Base.lproj/Localizable.strings b/HapticKey/Base.lproj/Localizable.strings index 64eae85..212251c 100644 Binary files a/HapticKey/Base.lproj/Localizable.strings and b/HapticKey/Base.lproj/Localizable.strings differ diff --git a/HapticKey/Classes/HTKAppDelegate.m b/HapticKey/Classes/HTKAppDelegate.m index a1931d7..f55badc 100644 --- a/HapticKey/Classes/HTKAppDelegate.m +++ b/HapticKey/Classes/HTKAppDelegate.m @@ -11,6 +11,8 @@ #import "HTKHapticFeedback.h" #import "HTKLoginItem.h" #import "HTKTapGestureEventListener.h" +#import "HTKSounds.h" +#import "HTKSoundMenu.h" NS_ASSUME_NONNULL_BEGIN @@ -70,6 +72,9 @@ @interface HTKAppDelegate () @property (nonatomic, nullable) NSMenuItem *startOnLoginMenuItem; +@property (nonatomic, nullable) HTKSounds *sounds; +@property (nonatomic, nullable) HTKSoundMenu *soundMenu; + @end @implementation HTKAppDelegate @@ -201,7 +206,7 @@ - (void)_htk_main_updateHapticFeedback } if (eventListener) { - HTKHapticFeedback * const hapticFeedback = [[HTKHapticFeedback alloc] initWithEventListener:eventListener]; + HTKHapticFeedback * const hapticFeedback = [[HTKHapticFeedback alloc] initWithEventListener:eventListener sounds:self.sounds]; hapticFeedback.enabled = YES; self.hapticFeedback = hapticFeedback; } else { @@ -287,18 +292,25 @@ - (void)_htk_main_updateUserDefaults - (void)applicationDidFinishLaunching:(NSNotification *)aNotification { + [self _htk_main_loadSounds]; [self _htk_main_loadUserDefaults]; [self _htk_main_loadStatusItem]; [self _htk_main_loadMainBundleLoginItem]; self.finishedLaunching = YES; - + [self _htk_main_updateUserDefaults]; [self _htk_main_updateStatusItem]; [self _htk_main_updateHapticFeedback]; [self _htk_main_updateMainBundleLoginItem]; } +- (void)_htk_main_loadSounds { + HTKSounds *sounds = [[HTKSounds alloc] initWithDefaultPath]; + _sounds = sounds; + _soundMenu = [[HTKSoundMenu alloc] initWithSounds:sounds]; +} + - (void)_htk_main_loadUserDefaults { NSUserDefaults * const defaults = [NSUserDefaults standardUserDefaults]; @@ -419,8 +431,9 @@ - (void)_htk_main_loadStatusItem NSMenuItem * const useSoundEffectMenuItem = [[NSMenuItem alloc] init]; useSoundEffectMenuItem.title = NSLocalizedString(@"STATUS_MENU_ITEM_SOUND_EFFECT_MENU_ITEM", @"A status menu item to use sound effect."); - useSoundEffectMenuItem.action = @selector(_htk_action_didSelectSoundEffectTypeMenuItem:); - useSoundEffectMenuItem.target = self; + //useSoundEffectMenuItem.action = @selector(_htk_action_didSelectSoundEffectTypeMenuItem:); + //useSoundEffectMenuItem.target = self; + useSoundEffectMenuItem.submenu = self.soundMenu.soundSubmenu; [statusMenu addItem:useSoundEffectMenuItem]; self.useSoundEffectMenuItem = useSoundEffectMenuItem; diff --git a/HapticKey/Classes/HTKHapticFeedback.h b/HapticKey/Classes/HTKHapticFeedback.h index a7d5575..4fe6fb5 100644 --- a/HapticKey/Classes/HTKHapticFeedback.h +++ b/HapticKey/Classes/HTKHapticFeedback.h @@ -11,6 +11,7 @@ NS_ASSUME_NONNULL_BEGIN @class HTKEventListener; +@class HTKSounds; typedef NS_ENUM(NSUInteger, HTKHapticFeedbackType) { HTKHapticFeedbackTypeNone, @@ -30,12 +31,14 @@ typedef NS_ENUM(NSUInteger, HTKSoundFeedbackType) { @property (nonatomic, getter=isEnabled) BOOL enabled; @property (nonatomic) HTKHapticFeedbackType type; @property (nonatomic) HTKSoundFeedbackType soundType; +@property (nonatomic, readonly, nullable) HTKSounds *sounds; @property (nonatomic, getter=isScreenFlashEnabled) BOOL screenFlashEnabled; + (instancetype)new NS_UNAVAILABLE; - (instancetype)init NS_UNAVAILABLE; -- (instancetype)initWithEventListener:(HTKEventListener *)eventListener NS_DESIGNATED_INITIALIZER; +- (instancetype)initWithEventListener:(HTKEventListener *)eventListener + sounds:(nullable HTKSounds*)sounds NS_DESIGNATED_INITIALIZER; @end diff --git a/HapticKey/Classes/HTKHapticFeedback.m b/HapticKey/Classes/HTKHapticFeedback.m index 8532a7a..42bee5c 100644 --- a/HapticKey/Classes/HTKHapticFeedback.m +++ b/HapticKey/Classes/HTKHapticFeedback.m @@ -12,6 +12,7 @@ #import "HTKMultitouchActuator.h" #import "HTKSystemSound.h" #import "HTKTimer.h" +#import "HTKSounds.h" @import AudioToolbox; @@ -25,7 +26,6 @@ @interface HTKHapticFeedback () @property (nonatomic, nullable) HTKTimer *timer; -@property (nonatomic, readonly) HTKSystemSound *defaultSystemSound; @end @@ -38,13 +38,13 @@ - (instancetype)init } - (instancetype)initWithEventListener:(HTKEventListener *)eventListener + sounds:(nullable HTKSounds*)sounds { if (self = [super init]) { _eventListener = eventListener; _eventListener.delegate = self; _type = HTKHapticFeedbackTypeMedium; - - _defaultSystemSound = [[HTKSystemSound alloc] initWithSystemSoundsGroup:kDefaultSystemSoundsGroup name:kDefaultSystemSoundsName]; + _sounds = sounds; } return self; } @@ -70,15 +70,12 @@ - (void)eventListener:(HTKEventListener *)eventListener didListenEvent:(HTKEvent self.timer = [[HTKTimer alloc] initWithInterval:kMinimumActuationInterval target:self selector:@selector(_htk_timer_didFire:)]; const SInt32 actuationID = [self _htk_main_actuationID]; - HTKSystemSound * const systemSound = [self _htk_main_systemSound]; switch (event.phase) { case HTKEventPhaseBegin: if (actuationID != 0) { [[HTKMultitouchActuator sharedActuator] actuateActuationID:actuationID unknown1:0 unknown2:0.0 unknown3:2.0]; } - if (systemSound) { - [systemSound play]; - } + [self.sounds playFingerDown]; if (self.screenFlashEnabled) { AudioServicesPlaySystemSoundWithCompletion(kSystemSoundID_FlashScreen, NULL); } @@ -87,6 +84,7 @@ - (void)eventListener:(HTKEventListener *)eventListener didListenEvent:(HTKEvent if (actuationID != 0) { [[HTKMultitouchActuator sharedActuator] actuateActuationID:actuationID unknown1:0 unknown2:0.0 unknown3:0.0]; } + [self.sounds playFingerUp]; break; } } @@ -117,17 +115,6 @@ - (SInt32)_htk_main_actuationID return 0; } -- (nullable HTKSystemSound *)_htk_main_systemSound -{ - switch (self.soundType) { - case HTKSoundFeedbackTypeNone: - return nil; - case HTKSoundFeedbackTypeDefault: - return self.defaultSystemSound; - } - return nil; -} - @end NS_ASSUME_NONNULL_END diff --git a/HapticKey/Classes/HTKSoundMenu.h b/HapticKey/Classes/HTKSoundMenu.h new file mode 100644 index 0000000..9ff3ecd --- /dev/null +++ b/HapticKey/Classes/HTKSoundMenu.h @@ -0,0 +1,39 @@ +// +// HTKSoundMenu.h +// HapticKey +// +// Created by Chris Ballinger on 2/2/18. +// Copyright © 2018 Yoshimasa Niwa. All rights reserved. +// + +@import Foundation; +@import AppKit; + +@class HTKSounds; + +NS_ASSUME_NONNULL_BEGIN + +/** + * This class helps create and manage the sound submenu. + */ +@interface HTKSoundMenu : NSObject + +// MARK: Properties + +@property (nonatomic, readonly) HTKSounds *sounds; +@property (nonatomic, readonly) NSMenu *soundSubmenu; + +// MARK: Init + ++ (instancetype)new NS_UNAVAILABLE; +- (instancetype)init NS_UNAVAILABLE; + +- (instancetype)initWithSounds:(HTKSounds *)sounds NS_DESIGNATED_INITIALIZER; + +// MARK: Public Methods + +- (void) refreshMenuItems; + +@end + +NS_ASSUME_NONNULL_END diff --git a/HapticKey/Classes/HTKSoundMenu.m b/HapticKey/Classes/HTKSoundMenu.m new file mode 100644 index 0000000..b2141d9 --- /dev/null +++ b/HapticKey/Classes/HTKSoundMenu.m @@ -0,0 +1,283 @@ +// +// HTKSoundMenu.m +// HapticKey +// +// Created by Chris Ballinger on 2/2/18. +// Copyright © 2018 Yoshimasa Niwa. All rights reserved. +// + +#import "HTKSoundMenu.h" +#import "HTKSounds.h" + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, HTKFingerDirection) { + HTKFingerDirectionUp, + HTKFingerDirectionDown +}; + +@interface NSView (Constranits) +- (void)htk_pinToView:(NSView *)view edge:(NSLayoutAttribute)edge offset:(CGFloat)offset; +- (void)htk_pinToView:(NSView *)view edge:(NSLayoutAttribute)attribute; +- (void)htk_pinAllEdgesToView:(NSView *)view; +@end + +@interface HTKSoundMenu() + +/// all menu items in soundSubmenu +//@property (nonatomic, readonly) NSArray *allMenuItems; +/// menu items corresponding to sound files on disk +@property (nonatomic, readonly) NSArray *soundFileMenuItems; +@property (nonatomic, readonly) NSSlider *volumeSlider; + +@end + +@implementation HTKSoundMenu + +// MARK: - Init + +- (instancetype)init +{ + [self doesNotRecognizeSelector:_cmd]; + abort(); +} + +- (instancetype)initWithSounds:(HTKSounds *)sounds +{ + NSParameterAssert(sounds); + if (self = [super init]) { + _sounds = sounds; + _soundSubmenu = [[NSMenu alloc] init]; + _soundSubmenu.delegate = self; + _volumeSlider = [NSSlider sliderWithTarget:self action:@selector(volumeSliderValueChanged:)]; + _volumeSlider.translatesAutoresizingMaskIntoConstraints = NO; + [self refreshMenuItems]; + } + return self; +} + +// MARK: Public Methods + +- (void) refreshMenuItems { + [self.soundSubmenu removeAllItems]; + [self addSoundSubmenuItems]; + [self updateVolumeSlider]; +} + +// MARK: UI Actions + +- (void) openSoundDirectoryInFinder:(NSMenuItem*)sender { + [NSWorkspace.sharedWorkspace openURL:[NSURL fileURLWithPath:self.sounds.path]]; +} + +- (void) soundItemSelected:(NSMenuItem*)sender { + HTKFingerDirection fingerDirection = sender.tag; + NSString *soundFilePath = sender.representedObject; + + switch (fingerDirection) { + case HTKFingerDirectionUp: + HTKSounds.fingerUpFilePath = soundFilePath; + [self.sounds reloadFingerUp]; + [self.sounds playFingerUp]; + break; + case HTKFingerDirectionDown: + HTKSounds.fingerDownFilePath = soundFilePath; + [self.sounds reloadFingerDown]; + [self.sounds playFingerDown]; + break; + } + [self updateStateForSoundMenuItems]; +} + +- (void) volumeSliderValueChanged:(NSSlider*)sender { + HTKSounds.desiredVolume = sender.floatValue; + [self.sounds updateVolume]; + // preview volume level + if (HTKSounds.fingerDownFilePath) { + [self.sounds playFingerDown]; + } else if (HTKSounds.fingerUpFilePath) { + [self.sounds playFingerUp]; + } +} + +// MARK: Private Methods + +- (void) updateVolumeSlider { + float value = HTKSounds.desiredVolume; + self.volumeSlider.floatValue = value; +} + +/// adds/removes checkmarks to the sound menu items that were chosen by the user +- (void) updateStateForSoundMenuItems { + [self.soundFileMenuItems enumerateObjectsUsingBlock:^(NSMenuItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + [self setStateForSoundMenuItem:obj]; + }]; +} + +- (NSMenuItem *) menuItemForSoundFilePath:(nullable NSString*)soundFilePath fingerDirection:(HTKFingerDirection)fingerDirection { + NSMenuItem *menuItem = [[NSMenuItem alloc] init]; + menuItem.representedObject = soundFilePath; + menuItem.tag = fingerDirection; + + NSString *title = soundFilePath.lastPathComponent.stringByDeletingPathExtension; + if (!title) { + title = NSLocalizedString(@"SOUND_MENU_ITEM_NONE", "no sound menu item selected"); + } + + if ([soundFilePath isEqualToString:HTKSounds.defaultUpFilePath] || + [soundFilePath isEqualToString:HTKSounds.defaultDownFilePath]) { + NSString *defaultString = NSLocalizedString(@"SOUND_MENU_ITEM_DEFAULT", @"section label string for default selection"); + title = [NSString stringWithFormat:@"%@ %@", defaultString, title]; + } + menuItem.title = title; + + [self setStateForSoundMenuItem:menuItem]; + + menuItem.target = self; + menuItem.action = @selector(soundItemSelected:); + return menuItem; +} + +- (void) setStateForSoundMenuItem:(NSMenuItem*)menuItem { + NSString *soundFilePath = menuItem.representedObject; + HTKFingerDirection direction = menuItem.tag; + + NSString *upPath = HTKSounds.fingerUpFilePath; + NSString *downPath = HTKSounds.fingerDownFilePath; + + switch (direction) { + case HTKFingerDirectionUp: + if ((soundFilePath && upPath && [soundFilePath isEqualToString:HTKSounds.fingerUpFilePath]) || + (soundFilePath == nil && upPath == nil) ) { + menuItem.state = NSOnState; + } else { + menuItem.state = NSOffState; + } + break; + case HTKFingerDirectionDown: + if ((soundFilePath && downPath && [soundFilePath isEqualToString:downPath]) || + (soundFilePath == nil && downPath == nil)) { + menuItem.state = NSOnState; + } else { + menuItem.state = NSOffState; + + } + break; + } +} + +- (void) addSoundSubmenuItems { + NSArray *menuItems = self.allMenuItems; + [menuItems enumerateObjectsUsingBlock:^(NSMenuItem * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + [self.soundSubmenu addItem:obj]; + }]; +} + +- (NSArray*) generateSoundMenuItemsForDirection:(HTKFingerDirection)direction { + NSMutableArray* items = [NSMutableArray array]; + [self.sounds.allSoundFiles enumerateObjectsUsingBlock:^(NSURL * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { + NSMenuItem *item = [self menuItemForSoundFilePath:obj.path fingerDirection:direction]; + [items addObject:item]; + }]; + NSMenuItem *noneItem = [self menuItemForSoundFilePath:nil fingerDirection:direction]; + [items addObject:noneItem]; + return items; +} + +- (NSArray*) allMenuItems { + + + NSMutableArray *menuItems = [NSMutableArray array]; + + // Finger Down + + NSMenuItem *fingerDownLabel = [[NSMenuItem alloc] init]; + fingerDownLabel.title = NSLocalizedString(@"SOUND_MENU_ITEM_FINGER_DOWN", @"section label for finger down settings"); + fingerDownLabel.enabled = NO; + [menuItems addObject:fingerDownLabel]; + + NSArray *downItems = [self generateSoundMenuItemsForDirection:HTKFingerDirectionDown]; + [menuItems addObjectsFromArray:downItems]; + + // Finger Up + [menuItems addObject:NSMenuItem.separatorItem]; + + NSMenuItem *fingerUpLabel = [[NSMenuItem alloc] init]; + fingerUpLabel.title = NSLocalizedString(@"SOUND_MENU_ITEM_FINGER_UP", @"section label for finger up settings"); + fingerUpLabel.enabled = NO; + [menuItems addObject:fingerUpLabel]; + + NSArray *upItems = [self generateSoundMenuItemsForDirection:HTKFingerDirectionUp]; + [menuItems addObjectsFromArray:upItems]; + + // Volume Control + [menuItems addObject:NSMenuItem.separatorItem]; + + NSMenuItem *volumeLabel = [[NSMenuItem alloc] init]; + volumeLabel.title = NSLocalizedString(@"SOUND_MENU_ITEM_VOLUME", @"section label for sound effect volume setting"); + volumeLabel.enabled = NO; + [menuItems addObject:volumeLabel]; + + // Can't seem to get the volume slider's left inset to work without doing this + NSMenuItem *volumeItem = [[NSMenuItem alloc] init]; + NSView *view = [[NSView alloc] initWithFrame:NSMakeRect(0, 0, 100, 19)]; + [view addSubview:self.volumeSlider]; + [view htk_pinToView:self.volumeSlider edge:NSLayoutAttributeLeading offset:-20]; + [view htk_pinToView:self.volumeSlider edge:NSLayoutAttributeTop offset:0]; + [view htk_pinToView:self.volumeSlider edge:NSLayoutAttributeBottom offset:0]; + [view htk_pinToView:self.volumeSlider edge:NSLayoutAttributeTrailing offset:0]; + volumeItem.view = view; + [menuItems addObject:volumeItem]; + + // Add Sounds... + [menuItems addObject:NSMenuItem.separatorItem]; + + NSMenuItem *addSounds = [[NSMenuItem alloc] init]; + addSounds.title = NSLocalizedString(@"SOUND_MENU_ITEM_ADD_SOUNDS", @"menu item for adding custom sounds"); + addSounds.action = @selector(openSoundDirectoryInFinder:); + addSounds.target = self; + [menuItems addObject:addSounds]; + + // store sounds separately so we can toggle them + _soundFileMenuItems = [upItems arrayByAddingObjectsFromArray:downItems]; + + return menuItems; +} + +// MARK: - NSMenuDelegate + +- (void) menuNeedsUpdate:(NSMenu *)menu { + [self refreshMenuItems]; +} + +@end + +// MARK: - View Constraints + +@implementation NSView (Constranits) + +- (void)htk_pinToView:(NSView *)view edge:(NSLayoutAttribute)edge { + [self htk_pinToView:view edge:edge offset:0.0]; +} + +- (void)htk_pinToView:(NSView *)view edge:(NSLayoutAttribute)edge offset:(CGFloat)offset +{ + [self addConstraint:[NSLayoutConstraint constraintWithItem:self + attribute:edge + relatedBy:NSLayoutRelationEqual + toItem:view + attribute:edge + multiplier:1.0f + constant:offset]]; +} + +- (void)htk_pinAllEdgesToView:(NSView *)view +{ + [self htk_pinToView:view edge:NSLayoutAttributeBottom]; + [self htk_pinToView:view edge:NSLayoutAttributeTop]; + [self htk_pinToView:view edge:NSLayoutAttributeLeading]; + [self htk_pinToView:view edge:NSLayoutAttributeTrailing]; +} +@end + +NS_ASSUME_NONNULL_END diff --git a/HapticKey/Classes/HTKSounds.h b/HapticKey/Classes/HTKSounds.h new file mode 100644 index 0000000..2c34d93 --- /dev/null +++ b/HapticKey/Classes/HTKSounds.h @@ -0,0 +1,82 @@ +// +// HTKSounds.h +// HapticKey +// +// Created by Chris Ballinger on 1/30/18. +// Copyright © 2018 Yoshimasa Niwa. All rights reserved. +// + +@import Foundation; + +NS_ASSUME_NONNULL_BEGIN + +/** + * This class helps manage the custom sound files available for playback. + */ +@interface HTKSounds : NSObject + +// MARK: Properties + +/** Current sound file directory */ +@property (nonatomic, readonly) NSString *path; +/** Full paths to all potential sound files in this directory */ +@property (nonatomic, readonly) NSArray *allSoundFiles; + +// MARK: Init + ++ (instancetype)new NS_UNAVAILABLE; +- (instancetype)init NS_UNAVAILABLE; + +/** Path to directory containing playable sound files. Will be created if directory doesn't exist. */ +- (nullable instancetype)initWithPath:(NSString *)path error:(NSError**)error NS_DESIGNATED_INITIALIZER; +- (nullable instancetype)initWithDefaultPath; + +// MARK: Public Methods + +/** Reloads fingerUp and fingerDown players from user preferences */ +- (void) reloadPlayers; +- (void) reloadFingerUp; +- (void) reloadFingerDown; +/** Updates player volumes based on stored user preference */ +- (void) updateVolume; + +/** Play sound for finger up, if enabled */ +- (void) playFingerUp; +/** Play sound for finger down, if enabled */ +- (void) playFingerDown; + +// MARK: Class Properties + +@end + +// MARK: - File Paths + +@interface HTKSounds (FilePaths) + +/** ~/Library/Application Support/HapticKey/Sounds/ */ +@property (nonatomic, class, readonly) NSString *defaultSoundsDirectory; +/** Default sound for finger-up */ +@property (nonatomic, class, readonly) NSString *defaultUpFilePath; +/** Default sound for finger-down */ +@property (nonatomic, class, readonly) NSString *defaultDownFilePath; + +@end + +// MARK: - User Defaults + +@interface HTKSounds (UserDefaults) + +/** finger down sound file path stored in user defaults */ +@property (nonatomic, class, nullable) NSString *fingerUpFilePath; +/** finger up sound file path stored in user defaults */ +@property (nonatomic, class, nullable) NSString *fingerDownFilePath; + +/** volume from 0.0 -> 1.0. Takes effect after reloadPlayers is called. */ +@property (nonatomic, class) float desiredVolume; + +/** Force-resets to use default sounds */ +- (void) resetDefaultSounds; + +@end + +NS_ASSUME_NONNULL_END diff --git a/HapticKey/Classes/HTKSounds.m b/HapticKey/Classes/HTKSounds.m new file mode 100644 index 0000000..11109cf --- /dev/null +++ b/HapticKey/Classes/HTKSounds.m @@ -0,0 +1,362 @@ +// +// HTKSounds.m +// HapticKey +// +// Created by Chris Ballinger on 1/30/18. +// Copyright © 2018 Yoshimasa Niwa. All rights reserved. +// + +#import "HTKSounds.h" + +@import AudioToolbox; +@import os.log; +@import AVFoundation; + +NS_ASSUME_NONNULL_BEGIN + +static NSString * const kDefaultDownSoundFileName = @"↓.aif"; +static NSString * const kDefaultUpSoundFileName = @"↑.aif"; + +static NSString * const kDefaultSystemSoundFilePath = @"/System/Library/Components/CoreAudio.component/Contents/SharedSupport/SystemSounds/ink/InkSoundBecomeMouse.aif"; +static UInt64 const kDefaultSystemSoundExpectedAudioBytes = 88032; + +/** NSUserDefaults key */ +static NSString * const kFingerUpFilePathKey = @"FingerUpFilePath"; +/** NSUserDefaults key */ +static NSString * const kFingerDownFilePathKey = @"FingerDownFilePath"; +static NSString * const kDesiredVolumeKey = @"DesiredVolume"; + + +@interface HTKSounds() +@property (nonatomic, readonly, nullable) AVAudioPlayer *fingerUp; +@property (nonatomic, readonly, nullable) AVAudioPlayer *fingerDown; +@end + +@implementation HTKSounds + +// MARK: - Init + +- (instancetype)init +{ + [self doesNotRecognizeSelector:_cmd]; + abort(); +} + +- (nullable instancetype)initWithPath:(NSString *)path error:(NSError**)error +{ + NSParameterAssert(path); + if (self = [super init]) { + _path = [path copy]; + if (![self createSoundsDirectoryIfNeeded:error]) { + return nil; + } + [self createDefaultSoundsIfNeeded]; + [self checkValidityOfUserSoundPreferences]; + [self reloadPlayers]; + } + return self; +} + +- (nullable instancetype)initWithDefaultPath { + return [self initWithPath:self.class.defaultSoundsDirectory error:nil]; +} + +// MARK: - Public + +- (void) playFingerUp { + [self.fingerUp play]; +} + +- (void) playFingerDown { + [self.fingerDown play]; +} + +- (void) reloadPlayers { + [self reloadFingerDown]; + [self reloadFingerUp]; +} + +- (void) reloadFingerUp { + _fingerUp = nil; + NSString *fingerUpFilePath = self.class.fingerUpFilePath; + if (fingerUpFilePath) { + NSURL *fileURL = [NSURL fileURLWithPath:fingerUpFilePath]; + _fingerUp = [[AVAudioPlayer alloc] initWithContentsOfURL:fileURL error:nil]; + } + _fingerUp.volume = self.class.desiredVolume; +} + +- (void) reloadFingerDown { + _fingerDown = nil; + NSString *fingerDownFilePath = self.class.fingerDownFilePath; + if (fingerDownFilePath) { + NSURL *fileURL = [NSURL fileURLWithPath:fingerDownFilePath]; + _fingerDown = [[AVAudioPlayer alloc] initWithContentsOfURL:fileURL error:nil]; + } + _fingerDown.volume = self.class.desiredVolume; +} + +- (void) updateVolume { + _fingerUp.volume = self.class.desiredVolume; + _fingerDown.volume = self.class.desiredVolume; +} + +- (NSArray*) allSoundFiles { + NSArray *contents = [NSFileManager.defaultManager contentsOfDirectoryAtURL:[NSURL fileURLWithPath:self.path] includingPropertiesForKeys:nil options:NSDirectoryEnumerationSkipsHiddenFiles error:nil]; + if (!contents.count) { + // we must populate an initial sound + [self resetDefaultSounds]; + contents = [NSFileManager.defaultManager contentsOfDirectoryAtURL:[NSURL fileURLWithPath:self.path] includingPropertiesForKeys:nil options:NSDirectoryEnumerationSkipsHiddenFiles error:nil]; + } + return contents; +} + +// MARK: - Private + +/// checks if user's sound preferences point to valid paths +/// and unsets them if they are invalid +- (void) checkValidityOfUserSoundPreferences { + if (![NSFileManager.defaultManager fileExistsAtPath:self.class.fingerUpFilePath]) { + self.class.fingerUpFilePath = nil; + } + if (![NSFileManager.defaultManager fileExistsAtPath:self.class.fingerDownFilePath]) { + self.class.fingerDownFilePath = nil; + } +} + +/// creates destination sound directory if it is not present +- (BOOL) createSoundsDirectoryIfNeeded:(NSError**)error { + BOOL isDirectory = NO; + BOOL exists = [NSFileManager.defaultManager fileExistsAtPath:self.path isDirectory:&isDirectory]; + if (exists && !isDirectory) { + // path must be a directory + return NO; + } else if (!exists) { + return [NSFileManager.defaultManager createDirectoryAtPath:_path withIntermediateDirectories:YES attributes:nil error:error]; + } else { + return YES; + } +} + +/// on first launch, split the default system sound into two +/// separate sound files for finger-up and finger-down +/// returns YES if sounds are ready, or NO on failure +- (BOOL) createDefaultSoundsIfNeeded { + // bail out if we've got the files already + if ([NSFileManager.defaultManager fileExistsAtPath:self.class.defaultUpFilePath] && + [NSFileManager.defaultManager fileExistsAtPath:self.class.defaultDownFilePath]) { + return YES; + } else { + return [self createDefaultSoundFiles]; + } +} + +/// creates default up/down sound files from source system sound +- (BOOL) createDefaultSoundFiles { + NSURL *originalFile = [NSURL fileURLWithPath:kDefaultSystemSoundFilePath]; + + AudioFileID inAudioFile = NULL; + AudioFileID downFile = NULL; + AudioFileID upFile = NULL; + dispatch_block_t closeAudioFiles = ^{ + if (inAudioFile) { + AudioFileClose(inAudioFile); + } + if (downFile) { + AudioFileClose(downFile); + } + if (upFile) { + AudioFileClose(upFile); + } + }; + + OSStatus result = AudioFileOpenURL((__bridge CFURLRef)originalFile, kAudioFileReadPermission, 0, &inAudioFile); + if (result != noErr) { + os_log_error(OS_LOG_DEFAULT, "Error opening system audio file at path %{public}@ %d %@", originalFile.path, result, UTCreateStringForOSType(result)); + closeAudioFiles(); + return NO; + } else { + os_log_info(OS_LOG_DEFAULT, "Opened system audio file at path %{public}@", originalFile.path); + } + + AudioStreamBasicDescription asbd = {0}; + UInt32 specifierSize = sizeof(asbd); + result = AudioFileGetProperty(inAudioFile, kAudioFilePropertyDataFormat, &specifierSize, &asbd); + NSAssert2(noErr == result, @"Error getting absd for system audio file %d %@", result, UTCreateStringForOSType(result)); + if (result != noErr) { + os_log_error(OS_LOG_DEFAULT, "Error getting absd for system audio file %d %@", result, UTCreateStringForOSType(result)); + closeAudioFiles(); + return NO; + } + + UInt64 totalBytes = 0; + specifierSize = sizeof(totalBytes); + result = AudioFileGetProperty(inAudioFile, kAudioFilePropertyAudioDataByteCount, &specifierSize, &totalBytes); + NSAssert1(noErr == result, @"Error getting audio byte count %d", result); + + // Check validity of source file by expected number of audio bytes + NSAssert2(kDefaultSystemSoundExpectedAudioBytes == totalBytes, @"Unexpected default system sound audio byte count! %llu vs %llu", totalBytes, kDefaultSystemSoundExpectedAudioBytes); + if (kDefaultSystemSoundExpectedAudioBytes != totalBytes) { + os_log_error(OS_LOG_DEFAULT, "Unexpected default system sound audio byte count! %llu vs %llu", totalBytes, kDefaultSystemSoundExpectedAudioBytes); + closeAudioFiles(); + return NO; + } + + UInt64 packetCount = 0; + specifierSize = sizeof(packetCount); + result = AudioFileGetProperty(inAudioFile, kAudioFilePropertyAudioDataPacketCount, &specifierSize, &packetCount); + NSAssert1(noErr == result, @"Error getting audio packet count %d", result); + UInt32 packetSize = 0; + specifierSize = sizeof(packetSize); + result = AudioFileGetProperty(inAudioFile, kAudioFilePropertyMaximumPacketSize, &specifierSize, &packetSize); + NSAssert1(noErr == result, @"Error getting audio packet size %d", result); + + NSURL *downFileURL = [NSURL fileURLWithPath:self.class.defaultDownFilePath]; + NSURL *upFileURL = [NSURL fileURLWithPath:self.class.defaultUpFilePath]; + + OSStatus (^createOutFile)(NSURL*,AudioFileID*) = ^OSStatus(NSURL *fileURL, AudioFileID *audioFile) { + OSStatus result = AudioFileCreateWithURL((__bridge CFURLRef)fileURL, kAudioFileAIFFType, &asbd, kAudioFileFlags_EraseFile, audioFile); + + NSAssert1(noErr == result, @"Error setting absd for audio file %d", result); + if (result != noErr) { + os_log_error(OS_LOG_DEFAULT, "Error creating audio file at path %{public}@ %d", downFileURL.path, result); + + } else { + os_log_info(OS_LOG_DEFAULT, "Opened output audio file at path %{public}@", downFileURL.path); + } + return result; + }; + + OSStatus upResult = createOutFile(upFileURL, &upFile); + OSStatus downResult = createOutFile(downFileURL, &downFile); + + if (upResult != noErr || downResult != noErr) { + closeAudioFiles(); + return NO; + } + + NSMutableData *audioDataBuffer = [NSMutableData dataWithLength:totalBytes]; + UInt32 bytesToRead = (UInt32)totalBytes; + UInt32 packetsToRead = (UInt32)packetCount; + result = AudioFileReadPacketData(inAudioFile, false, &bytesToRead, NULL, 0, &packetsToRead, audioDataBuffer.mutableBytes); + NSAssert1(noErr == result, @"Error reading system audio file data %d", result); + audioDataBuffer.length = bytesToRead; + + UInt32 halfPackets = packetsToRead / 2; + UInt32 halfBytes = halfPackets * packetSize; + NSData *downData = [[audioDataBuffer subdataWithRange:NSMakeRange(0, halfBytes)] copy]; + NSData *upData = [[audioDataBuffer subdataWithRange:NSMakeRange(halfBytes, halfBytes)] copy]; + + + OSStatus (^writeOutFile)(NSData*,AudioFileID) = ^OSStatus(NSData *audioData, AudioFileID audioFile) { + UInt32 bytesToWrite = (UInt32)audioData.length; + UInt32 packetsToWrite = halfPackets; + OSStatus result = AudioFileWritePackets(audioFile, false, bytesToWrite, NULL, 0, &packetsToWrite, audioData.bytes); + NSAssert1(noErr == result, @"Error writing audio data %d", result); + result = AudioFileOptimize(audioFile); + return result; + }; + + result = writeOutFile(downData, downFile); + result = writeOutFile(upData, upFile); + + closeAudioFiles(); + + return YES; +} + +@end + +// MARK: - File Paths + +@implementation HTKSounds (FilePaths) + ++ (NSString*) defaultUpFilePath { + NSString *filePath = [self.defaultSoundsDirectory stringByAppendingPathComponent:kDefaultUpSoundFileName]; + return filePath; +} + ++ (NSString*) defaultDownFilePath { + NSString *filePath = [self.defaultSoundsDirectory stringByAppendingPathComponent:kDefaultDownSoundFileName]; + return filePath; +} + ++ (NSString*) defaultSoundsDirectory { + NSString *applicationSupportDirectory = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES).lastObject; + NSString *applicationName = NSBundle.mainBundle.infoDictionary[(NSString*)kCFBundleNameKey]; + NSString *appDirectory = [applicationSupportDirectory stringByAppendingPathComponent:applicationName]; + NSString *soundsDirectory = [appDirectory stringByAppendingPathComponent:@"Sounds"]; + NSParameterAssert(soundsDirectory); + return soundsDirectory; +} + +@end + + +// MARK: - User Defaults + +@implementation HTKSounds (UserDefaults) + ++ (void) setFingerUpFilePath:(nullable NSString *)fingerUpFilePath { + if (fingerUpFilePath) { + [NSUserDefaults.standardUserDefaults setObject:fingerUpFilePath forKey:kFingerUpFilePathKey]; + } else { + [NSUserDefaults.standardUserDefaults removeObjectForKey:kFingerUpFilePathKey]; + } + [NSUserDefaults.standardUserDefaults synchronize]; +} + ++ (nullable NSString*) fingerUpFilePath { + return [NSUserDefaults.standardUserDefaults stringForKey:kFingerUpFilePathKey]; +} + ++ (void) setFingerDownFilePath:(nullable NSString *)fingerDownFilePath { + if (fingerDownFilePath) { + [NSUserDefaults.standardUserDefaults setObject:fingerDownFilePath forKey:kFingerDownFilePathKey]; + } else { + [NSUserDefaults.standardUserDefaults removeObjectForKey:kFingerDownFilePathKey]; + } + [NSUserDefaults.standardUserDefaults synchronize]; +} + ++ (nullable NSString*) fingerDownFilePath { + return [NSUserDefaults.standardUserDefaults stringForKey:kFingerDownFilePathKey]; +} + +/// returns valid volume range within 0.0->1.0 ++ (float) validVolume:(float)value { + if (value > 1.0) { + return 1.0; + } else if (value < 0.0) { + return 0.0; + } else { + return value; + } +} + ++ (float) desiredVolume { + // default to 1.0 if unset + if(![NSUserDefaults.standardUserDefaults objectForKey:kDesiredVolumeKey]){ + return 1.0; + } + float value = [NSUserDefaults.standardUserDefaults floatForKey:kDesiredVolumeKey]; + return [self validVolume:value]; +} + ++ (void) setDesiredVolume:(float)desiredVolume { + float value = [self validVolume:desiredVolume]; + [NSUserDefaults.standardUserDefaults setFloat:value forKey:kDesiredVolumeKey]; + [NSUserDefaults.standardUserDefaults synchronize]; +} + +- (void) resetDefaultSounds { + [self createDefaultSoundFiles]; + self.class.fingerUpFilePath = self.class.defaultUpFilePath; + self.class.fingerDownFilePath = self.class.defaultDownFilePath; + [self checkValidityOfUserSoundPreferences]; + [self reloadPlayers]; +} + +@end + +NS_ASSUME_NONNULL_END diff --git a/HapticKey/en.lproj/Localizable.strings b/HapticKey/en.lproj/Localizable.strings index 3eba7db..9b86844 100644 Binary files a/HapticKey/en.lproj/Localizable.strings and b/HapticKey/en.lproj/Localizable.strings differ