2 * Adium is the legal property of its developers, whose names are listed in the copyright file included
3 * with this source distribution.
5 * This program is free software; you can redistribute it and/or modify it under the terms of the GNU
6 * General Public License as published by the Free Software Foundation; either version 2 of the License,
7 * or (at your option) any later version.
9 * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
10 * the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
11 * Public License for more details.
13 * You should have received a copy of the GNU General Public License along with this program; if not,
14 * write to the Free Software Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
19 #import "AIPreferenceController.h"
20 #import "AISoundController.h"
21 #import <Adium/AIObject.h>
22 #import <Adium/AIAccount.h>
23 #import <Adium/SUSpeaker.h>
24 #import <Adium/QTSoundFilePlayer.h>
25 #import <AIUtilities/CBApplicationAdditions.h>
26 #import <AIUtilities/AIDictionaryAdditions.h>
27 #import <AIUtilities/AIWorkspaceAdditions.h>
30 #define PATH_SOUNDS @"/Sounds"
31 #define PATH_INTERNAL_SOUNDS @"/Contents/Resources/Sounds/"
32 #define SOUND_SET_PATH_EXTENSION @"txt"
33 #define SOUND_DEFAULT_PREFS @"SoundPrefs"
34 #define MAX_CACHED_SOUNDS 4 //Max cached sounds
36 #define TEXT_TO_SPEAK @"Text"
37 #define VOICE @"Voice"
38 #define PITCH @"Pitch"
41 #define SOUND_CACHE_CLEANUP_INTERVAL 60.0
43 #define SOUND_LOCATION @"Location"
44 #define SOUND_LOCATION_SEPARATOR @"////"
45 #define SOUND_PACK_PATHNAME @"AdiumSetPathname_Private"
46 #define SOUND_PACK_VERSION @"AdiumSetVersion"
47 #define SOUND_NAMES @"Sounds"
49 @interface AISoundController (PRIVATE)
50 - (void)_removeSystemAlertIDs;
51 - (void)_coreAudioPlaySound:(NSString *)inPath;
52 - (void)_scanSoundSetsFromPath:(NSString *)soundFolderPath intoArray:(NSMutableArray *)soundSetArray;
53 - (void)_addSet:(NSString *)inSet withSounds:(NSArray *)inSounds toArray:(NSMutableArray *)inArray;
54 - (void)addSoundsIndicatedByDictionary:(NSDictionary *)infoDict toArray:(NSMutableArray *)soundSetContents;
56 - (NSArray *)voiceArray;
57 - (SUSpeaker *)_speakerForVoice:(NSString *)voiceString index:(int *)voiceIndex;
59 - (void)initDefaultVoiceIfNecessary;
60 - (void)_stopSpeakingNow;
62 - (void)uncacheLastPlayer;
65 @implementation AISoundController
67 - (void)initController
69 soundCacheDict = [[NSMutableDictionary alloc] init];
70 soundCacheArray = [[NSMutableArray alloc] init];
71 soundCacheCleanupTimer = nil;
73 speechArray = [[NSMutableArray alloc] init];
77 //Create a custom sounds directory ~/Library/Application Support/Adium 2.0/Sounds
78 [[AIObject sharedAdiumInstance] createResourcePathForName:PATH_SOUNDS];
80 AIPreferenceController *preferenceController = [adium preferenceController];
82 //Register our default preferences
83 [preferenceController registerDefaults:[NSDictionary dictionaryNamed:SOUND_DEFAULT_PREFS forClass:[self class]]
84 forGroup:PREF_GROUP_SOUNDS];
86 //Ensure the temporary mute is off
87 if([[preferenceController preferenceForKey:KEY_SOUND_TEMPORARY_MUTE
88 group:PREF_GROUP_SOUNDS] boolValue])
90 [preferenceController setPreference:nil
91 forKey:KEY_SOUND_TEMPORARY_MUTE
92 group:PREF_GROUP_SOUNDS];
95 //observe pref changes
96 [preferenceController registerPreferenceObserver:self forGroup:PREF_GROUP_SOUNDS];
100 - (void)closeController
102 [[adium preferenceController] unregisterPreferenceObserver:self];
105 [self _stopSpeakingNow];
107 //Stop all sounds from playing
108 NSEnumerator *enumerator = [soundCacheDict objectEnumerator];
109 QTSoundFilePlayer *soundFilePlayer;
110 while (soundFilePlayer = [enumerator nextObject]){
111 [soundFilePlayer stop];
117 [speechArray release]; speechArray = nil;
118 [soundCacheDict release]; soundCacheDict = nil;
119 [soundCacheArray release]; soundCacheArray = nil;
125 - (void)preferencesChangedForGroup:(NSString *)group key:(NSString *)key
126 object:(AIListObject *)object preferenceDict:(NSDictionary *)prefDict firstTime:(BOOL)firstTime
128 NSEnumerator *enumerator;
129 QTSoundFilePlayer *soundFilePlayer;
130 SoundDeviceType oldSoundDeviceType;
132 useCustomVolume = YES;
133 customVolume = ([[prefDict objectForKey:KEY_SOUND_CUSTOM_VOLUME_LEVEL] floatValue]);
135 muteSounds = ([[prefDict objectForKey:KEY_SOUND_MUTE] intValue] ||
136 [[prefDict objectForKey:KEY_SOUND_TEMPORARY_MUTE] intValue] ||
137 [[prefDict objectForKey:KEY_SOUND_STATUS_MUTE] intValue]);
139 oldSoundDeviceType = soundDeviceType;
140 soundDeviceType = [[prefDict objectForKey:KEY_SOUND_SOUND_DEVICE_TYPE] intValue];
143 //Clear out our cached sounds and our speech aray if either
144 // -We're probably not going to be using them for a while
145 // -We've changed output device types so will want to recreate our sound output objects
147 //If neither of these things happened, we need to update our currently playing songs
148 //to the new volume setting.
150 BOOL needToStopAndRelease = (muteSounds || (soundDeviceType != oldSoundDeviceType));
152 enumerator = [soundCacheDict objectEnumerator];
153 while (soundFilePlayer = [enumerator nextObject]){
154 if (needToStopAndRelease){
155 [soundFilePlayer stop];
157 [soundFilePlayer setVolume:customVolume];
161 if (speaker_defaultVoice) [speaker_defaultVoice setVolume:customVolume];
162 if (speaker_variableVoice) [speaker_variableVoice setVolume:customVolume];
164 if (needToStopAndRelease){
165 [speechArray removeAllObjects];
166 [soundCacheDict removeAllObjects];
167 [soundCacheArray removeAllObjects];
172 //Sound Playing --------------------------------------------------------------------------------------------------------
173 #pragma mark Sound Playing
174 //Play a sound by name
175 - (void)playSoundNamed:(NSString *)inName
178 NSArray *soundsFolders = [adium resourcePathsForName:PATH_SOUNDS];
179 NSEnumerator *folderEnum = [soundsFolders objectEnumerator];
180 NSFileManager *mgr = [NSFileManager defaultManager];
183 while(path = [folderEnum nextObject]) {
184 path = [path stringByAppendingPathComponent:inName];
185 if([mgr fileExistsAtPath:path isDirectory:&isDir]) {
193 [self playSoundAtPath:path];
195 //They wanted a sound. We can't find the one they wanted. At least give 'em something.
200 //Play a sound by path
201 - (void)playSoundAtPath:(NSString *)inPath
205 [self _coreAudioPlaySound:inPath];
212 //Quicktime ------------------------------------------------------------------------------------------------------------
213 #pragma mark CoreAudio
214 // - Sound loading routine is not incredibly cheap (though not bad), so we should cache manually
215 // + CoreAudio offers volume control, including in real time as the sound plays
216 // + CoreAudio offers control over the output device (system events versus default audio, for example)
217 // + CoreAudio is present and functional on OS X 10.2.7 and above
218 // + QTSoundFilePlayer utilizes Quicktime for conversion so can play basically anything
219 //Play a sound using CoreAudio via QTSoundFilePlayer.
220 - (void)_coreAudioPlaySound:(NSString *)inPath
222 QTSoundFilePlayer *justCrushAlot;
224 //Search for this sound in our cache
225 justCrushAlot = [soundCacheDict objectForKey:inPath];
227 //If the sound is not cached, load it
229 //If the cache is full, remove the least recently used cached sound
230 if([soundCacheDict count] >= MAX_CACHED_SOUNDS){
231 [self uncacheLastPlayer];
234 //Load and cache the sound
235 justCrushAlot = [[QTSoundFilePlayer alloc] initWithContentsOfFile:inPath
236 usingSystemAlertDevice:(soundDeviceType == SOUND_SYTEM_ALERT_DEVICE)];
239 It's important that we are caching, not so much because of the overhead but because:
240 1) we don't want to leak QTSoundFilePlayer objects but
241 2) we don't want to release them immediately as then they would crash while playing and
242 3) we don't want to wait here until they finish playing as then Adium would beachball during each sound
243 So we cache them and release them at some point in the future. We could accomplish the same using a
244 non-autoreleasing QTSoundFilePlayer and the provided delegate methods, however:
245 4) we don't want to play the same sound more than once at a time - we would rather reset to the beginning.
246 this implies having one QTSoundFilePlayer per path, which requires caching into a lookup dict.
248 [soundCacheDict setObject:justCrushAlot forKey:inPath];
249 [justCrushAlot release];
251 [soundCacheArray insertObject:inPath atIndex:0];
256 //Move this sound to the front of the cache (This will naturally move lesser used sounds to the back for removal)
257 [soundCacheArray removeObject:inPath];
258 [soundCacheArray insertObject:inPath atIndex:0];
261 //Set the volume and play sound
263 //Reset the cached sound back to the beginning and set its volume; if it is currently playing,
264 //this will make it restart.
265 [justCrushAlot setVolume:customVolume];
266 [justCrushAlot setPlaybackPosition:0];
268 //QTSoundFilePlayer won't play if the sound is already playing, but that's fine since we
269 //reset the playback position and it will start playing there in the next run loop.
270 [justCrushAlot play];
273 if (!soundCacheCleanupTimer){
274 soundCacheCleanupTimer = [[NSTimer scheduledTimerWithTimeInterval:SOUND_CACHE_CLEANUP_INTERVAL
276 selector:@selector(soundCacheCleanup:)
278 repeats:YES] retain];
280 [soundCacheCleanupTimer setFireDate:[NSDate dateWithTimeIntervalSinceNow:SOUND_CACHE_CLEANUP_INTERVAL]];
284 //If sounds are cached when this fires, dealloc the one used least recently;
285 //If none are cached, stop the timer that got us here.
286 - (void)soundCacheCleanup:(NSTimer *)inTimer
288 if ([soundCacheArray count]){
289 [self uncacheLastPlayer];
291 [soundCacheCleanupTimer invalidate]; [soundCacheCleanupTimer release]; soundCacheCleanupTimer = nil;
295 - (void)uncacheLastPlayer
297 NSString *lastCachedPath = [soundCacheArray lastObject];
298 QTSoundFilePlayer *gangstaPlaya = [soundCacheDict objectForKey:lastCachedPath];
300 if (![gangstaPlaya isPlaying]){
302 [soundCacheDict removeObjectForKey:lastCachedPath];
303 [soundCacheArray removeLastObject];
307 //Sound Sets -----------------------------------------------------------------------------------------------------------
308 #pragma mark Sound Sets
309 //Returns an array of dictionaries, each representing a soundset with the following keys:
310 // (NString *)"Set" - The path of the soundset (name is the last component)
311 // (NSArray *)"Sounds" - An array of sound paths (name is the last component) (NSString *'s)
312 - (NSArray *)soundSetArray
315 NSMutableArray *soundSetArray;
316 NSEnumerator *enumerator;
319 soundSetArray = [[NSMutableArray alloc] init];
322 enumerator = [[adium resourcePathsForName:@"Sounds"] objectEnumerator];
323 while (path = [enumerator nextObject]){
324 [self _scanSoundSetsFromPath:path intoArray:soundSetArray];
327 return [soundSetArray autorelease];
330 - (void)_scanSoundSetsFromPath:(NSString *)soundFolderPath intoArray:(NSMutableArray *)soundSetArray
332 NSDirectoryEnumerator *enumerator; //Sound folder directory enumerator
333 NSString *file; //Current Path (relative to sound folder)
334 NSString *soundSetPath; //Name of the set
335 NSMutableArray *soundSetContents; //Array of sounds in the set
337 //Start things off with a valid set path and contents, incase any sounds aren't in subfolders
338 soundSetPath = soundFolderPath;
339 soundSetContents = [[NSMutableArray alloc] init];
342 enumerator = [[NSFileManager defaultManager] enumeratorAtPath:soundFolderPath];
343 while((file = [enumerator nextObject])){
346 NSString *fileName = [file lastPathComponent];
348 //Skip .*, *.txt, and .svn
349 if([fileName characterAtIndex:0] != '.' &&
350 [[file pathExtension] caseInsensitiveCompare:SOUND_SET_PATH_EXTENSION] != NSOrderedSame &&
351 ![[file pathComponents] containsObject:@".svn"]){ //Ignore certain files
353 //Determine if this is a file or a directory
354 fullPath = [soundFolderPath stringByAppendingPathComponent:file];
355 [[NSFileManager defaultManager] fileExistsAtPath:fullPath isDirectory:&isDirectory];
357 //Only add the soundset if it contains sounds
358 if([soundSetContents count] != 0){
359 //Close the current soundset, adding it to our sound set array
360 [self _addSet:soundSetPath withSounds:soundSetContents toArray:soundSetArray];
363 //Open a new soundset for this directory
364 soundSetPath = fullPath;
366 [soundSetContents release];
367 soundSetContents = [[NSMutableArray alloc] init];
370 if([fileName isEqualToString:@"Info.plist"]){
371 NSMutableDictionary *infoDict = [NSMutableDictionary dictionaryWithContentsOfFile:fullPath];
372 [infoDict setObject:soundSetPath forKey:SOUND_PACK_PATHNAME];
373 [self addSoundsIndicatedByDictionary:infoDict
374 toArray:soundSetContents];
378 [soundSetContents addObject:fullPath];
384 //Close the last soundset, adding it to our sound set array
385 [self _addSet:soundSetPath withSounds:soundSetContents toArray:soundSetArray];
386 [soundSetContents release];
389 - (void)_addSet:(NSString *)inSet withSounds:(NSArray *)inSounds toArray:(NSMutableArray *)inArray
391 if(inSet && inSounds && inArray){
392 [inArray addObject:[NSDictionary dictionaryWithObjectsAndKeys:inSet, KEY_SOUND_SET, inSounds, KEY_SOUND_SET_CONTENTS, nil]];
397 * @brief Add sounds indicated dynamically by a dictionary to an array
399 * Handle optional location key, which allows emoticons to be loaded from arbitrary directories.
400 * This is currently only used by the iChat sound pack.
402 - (void)addSoundsIndicatedByDictionary:(NSDictionary *)infoDict toArray:(NSMutableArray *)soundSetContents
404 int version = [[infoDict objectForKey:SOUND_PACK_VERSION] intValue];
409 NSDictionary *sounds;
410 NSEnumerator *enumerator;
411 NSString *soundName, *soundLocation = nil;
413 sounds = [self soundsDictionaryFromDictionary:infoDict usingLocation:&soundLocation];
415 //If we don't have a sound location, return
416 if(!sounds || !soundLocation) return;
418 enumerator = [sounds objectEnumerator];
419 while(soundName = [enumerator nextObject]){
420 [soundSetContents addObject:[soundLocation stringByAppendingPathComponent:soundName]];
427 NSRunAlertPanel(AILocalizedString(@"Cannot open sound set", nil),
428 AILocalizedString(@"The sound set at %@ is version %i, and this version of Adium does not know how to handle that; perhaps try a later version of Adium.", nil),
429 /*defaultButton*/ nil, /*alternateButton*/ nil, /*otherButton*/ nil,
430 [infoDict objectForKey:SOUND_PACK_PATHNAME], version);
435 - (NSDictionary *)soundsDictionaryFromDictionary:(NSDictionary *)infoDict usingLocation:(NSString **)outSoundLocation
437 NSString *soundLocation = nil, *fullSoundLocation = nil;
438 NSDictionary *sounds;
440 id possiblePaths = [infoDict objectForKey:SOUND_LOCATION];
443 if([possiblePaths isKindOfClass:[NSString class]]){
444 possiblePaths = [NSArray arrayWithObjects:possiblePaths, nil];
447 NSEnumerator *pathEnumerator = [possiblePaths objectEnumerator];
450 while((aPath = [pathEnumerator nextObject])){
451 NSString *possiblePath;
452 NSArray *splitPath = [aPath componentsSeparatedByString:SOUND_LOCATION_SEPARATOR];
454 /* Two possible formats:
456 * <string>/absolute/path/to/directory</string>
457 * <string>CFBundleIdentifier////relative/path/from/bundle/to/directory</string>
459 * The separator in the latter is ////, defined as SOUND_LOCATION_SEPARATOR.
461 if([splitPath count] == 1){
462 possiblePath = [splitPath objectAtIndex:0];
464 NSArray *components = [NSArray arrayWithObjects:
465 [[NSWorkspace sharedWorkspace] compatibleAbsolutePathForAppBundleWithIdentifier:[splitPath objectAtIndex:0]],
466 [splitPath objectAtIndex:1],
468 possiblePath = [NSString pathWithComponents:components];
471 /* If the directory exists, then we've found the location. If we
472 * make it all the way through the list without finding a valid
473 * directory, then the standard location will be used.
476 if([[NSFileManager defaultManager] fileExistsAtPath:possiblePath isDirectory:&isDir] && isDir){
477 soundLocation = possiblePath;
479 /* Keep the 'full sound location', which is what was indicated in the dictionary, for generation of
480 * the SOUND_NAMES key on a by-location basis later on.
482 fullSoundLocation = aPath;
488 sounds = [infoDict objectForKey:[NSString stringWithFormat:@"%@:%@",SOUND_NAMES,fullSoundLocation]];
489 if(!sounds) sounds = [infoDict objectForKey:SOUND_NAMES];
491 if(outSoundLocation) *outSoundLocation = soundLocation;
496 //Text to Speech -------------------------------------------------------------------------------------------------------
497 #pragma mark Text to Speech
499 * We use SUSpeaker to provide maximum flexibility over speech. NSSpeechSynthesizer does not gives us pitch/rate controls,
500 * and is not compatible with 10.2, as well.
501 * The only significant bug in SUSpeaker is that it does not reset to the system default voice when it is asked to. We
502 * therefore use 2 instances of SUSpeaker: one for default settings, and one for custom settings.
505 //Convenience method: speak the given text with default values
506 - (void)speakText:(NSString *)text
508 [self speakText:text withVoice:nil pitch:0.0 rate:0.0];
511 //Speak a voice-specific sample text at the passed settings
512 - (void)speakDemoTextForVoice:(NSString *)voiceString withPitch:(float)pitch andRate:(float)rate
516 SUSpeaker *theSpeaker;
518 [self _stopSpeakingNow];
519 theSpeaker = [self _speakerForVoice:voiceString index:&voiceIndex];
520 demoText = [theSpeaker demoTextForVoiceAtIndex:((voiceIndex != NSNotFound) ? voiceIndex : -1)];
522 [self speakText:demoText
523 withVoice:voiceString
528 //Return an array of voices in the same order as expected by SUSpeaker
531 return [self voiceArray];
534 //The systemwide default rate. This is cached when first used; it does not update if the systemwide default updates.
537 [self initDefaultVoiceIfNecessary];
541 //The systemwide default pitch. This is cached when first used; it does not update if the systemwide default updates.
542 - (float)defaultPitch
544 [self initDefaultVoiceIfNecessary];
548 //add text & voiceString to the speech queue and attempt to speak text now
549 //pass voice as nil to use default voice
550 //pass pitch as 0 to use default pitch
551 //pass rate as 0 to use default rate
552 - (void)speakText:(NSString *)text withVoice:(NSString *)voiceString pitch:(float)pitch rate:(float)rate
554 if(text && [text length]){
556 NSMutableDictionary *dict;
558 dict = [[NSMutableDictionary alloc] init];
561 [dict setObject:text forKey:TEXT_TO_SPEAK];
564 if(voiceString) [dict setObject:voiceString forKey:VOICE];
565 if(pitch > FLT_EPSILON) [dict setObject:[NSNumber numberWithFloat:pitch] forKey:PITCH];
566 if(rate > FLT_EPSILON) [dict setObject:[NSNumber numberWithFloat:rate] forKey:RATE];
568 [speechArray addObject:dict];
576 //attempt to speak the next item in the queue
579 //we have items left to speak and aren't already speaking
580 if([speechArray count] && !speaking){
581 //don't speak on top of other apps; instead, wait 1 second and try again
582 if(SpeechBusySystemWide() > 0){
583 [self performSelector:@selector(speakNext)
590 NSMutableDictionary *dict = [speechArray objectAtIndex:0];
591 NSString *text = [dict objectForKey:TEXT_TO_SPEAK];
592 NSNumber *pitchNumber = [dict objectForKey:PITCH];
593 NSNumber *rateNumber = [dict objectForKey:RATE];
594 SUSpeaker *theSpeaker = [self _speakerForVoice:[dict objectForKey:VOICE] index:NULL];
596 [theSpeaker setPitch:(pitchNumber ? [pitchNumber floatValue] : defaultPitch)];
597 [theSpeaker setRate:(rateNumber ? [rateNumber floatValue] : defaultRate)];
598 [theSpeaker setVolume:customVolume];
600 [theSpeaker speakText:text];
601 [speechArray removeObjectAtIndex:0];
605 - (IBAction)didFinishSpeaking:(SUSpeaker *)theSpeaker
611 //Immediately stop speaking
612 - (void)_stopSpeakingNow
614 [speaker_defaultVoice stopSpeaking];
615 [speaker_variableVoice stopSpeaking];
618 //INitialize the default voice if it has not yet been done
619 - (void)initDefaultVoiceIfNecessary
621 if(!speaker_defaultVoice){
622 speaker_defaultVoice = [[SUSpeaker alloc] init];
623 [speaker_defaultVoice setDelegate:self];
624 defaultRate = [speaker_defaultVoice rate];
625 defaultPitch = [speaker_defaultVoice pitch];
629 //Return the SUSpeaker which should be used for a given voice name, configured for that voice. Optionally, return
630 //the index of that voice in our array by reference.
631 - (SUSpeaker *)_speakerForVoice:(NSString *)voiceString index:(int *)voiceIndex;
633 int theIndex = (voiceIndex ? *voiceIndex : 0);
634 SUSpeaker *theSpeaker;
637 theIndex = [[self voiceArray] indexOfObject:voiceString];
639 theIndex = NSNotFound;
642 if(theIndex != NSNotFound){
643 if(!speaker_variableVoice){ //initVariableVoiceifNecessary
644 speaker_variableVoice = [[SUSpeaker alloc] init];
645 [speaker_variableVoice setDelegate:self];
647 theSpeaker = speaker_variableVoice;
648 [theSpeaker setVoiceUsingIndex:theIndex];
651 [self initDefaultVoiceIfNecessary];
652 theSpeaker = speaker_defaultVoice;
655 if (voiceIndex) *voiceIndex = theIndex;
660 - (NSArray *)voiceArray
662 static NSArray *_voiceArray = nil;
665 NSArray *originalVoiceArray = [SUSpeaker voiceNames];
666 NSMutableArray *ourVoiceArray = [originalVoiceArray mutableCopy];
669 //Vicki, a new voice in 10.3, returns an invalid name to SUSpeaker, Vicki3Smallurrent. If we see that name,
670 //replace it with just Vicki. If this gets fixed in a future release of OS X, this code will simply do nothing.
671 messedUpIndex = [ourVoiceArray indexOfObject:@"Vicki3Smallurrent"];
672 if(messedUpIndex != NSNotFound){
673 [ourVoiceArray replaceObjectAtIndex:messedUpIndex
674 withObject:@"Vicki"];
677 //ourVoiceArray is retained, so just assign it
678 _voiceArray = ourVoiceArray; //voiceArray will be in the same order that SUSpeaker expects