// _____ _ // |_ _| |_ _ _ ___ ___ _ __ __ _ // | | | ' \| '_/ -_) -_) ' \/ _` |_ // |_| |_||_|_| \___\___|_|_|_\__,_(_) // // Threema iOS Client // Copyright (c) 2015-2020 Threema GmbH // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License, version 3, // as published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . #import "AudioRecorder.h" #import "AppDelegate.h" #ifdef DEBUG static const DDLogLevel ddLogLevel = DDLogLevelVerbose; #else static const DDLogLevel ddLogLevel = DDLogLevelWarning; #endif #define kMaxRecordDuration 1800.0 #define kMaxSaveWaitTimeS 10.0 #define kRecordFileName @"recordAudio" #define kRecordTmpFileName @"interrupted" @interface AudioRecorder () @property NSURL *recordAudioUrl; @property NSURL *tmpRecorderFile; @property NSTimeInterval tmpFileDuration; @property dispatch_semaphore_t sema; @property BOOL interrupted; @end @implementation AudioRecorder - (void)dealloc { [self stop]; [self unregisterFromNotifications]; [self cleanupFiles]; // make sure delegate methods are not called anymore _recorder.delegate = nil; } - (void)start { [self cleanupFiles]; [self registerForNotifications]; [self startRecording]; } - (void)stop { [self stopRecorder]; [self unregisterFromNotifications]; [self joinWithTmpFile]; } - (void)startRecording { [self setupRecorder]; if ([_recorder recordForDuration:kMaxRecordDuration]) { DDLogInfo(@"Recording"); [UIApplication sharedApplication].idleTimerDisabled = YES; } } - (void)stopRecorder { if (_recorder.recording) { [_recorder stop]; } } - (NSURL *)audioURL { return [self recorderUrl]; } - (void)cleanupFiles { /* ensure audio files are deleted */ [[NSFileManager defaultManager] removeItemAtURL:_recordAudioUrl error:nil]; [[NSFileManager defaultManager] removeItemAtURL:_tmpRecorderFile error:nil]; _recordAudioUrl = nil; _tmpRecorderFile = nil; _tmpFileDuration = 0; } - (NSURL *)tmpAudioUrlWithFileNamed:(NSString *)filename { NSURL *tmpDirUrl = [NSURL fileURLWithPath:NSTemporaryDirectory() isDirectory:YES]; NSURL *url = [[tmpDirUrl URLByAppendingPathComponent:filename] URLByAppendingPathExtension: MEDIA_EXTENSION_AUDIO]; DDLogInfo(@"fileURL: %@", [url path]); return url; } - (void)setupRecorder { NSError *error = nil; [self stopRecorder]; _recordAudioUrl = [self tmpAudioUrlWithFileNamed:kRecordFileName]; NSMutableDictionary *recordSettings = [[NSMutableDictionary alloc] initWithCapacity:10]; [recordSettings setObject:[NSNumber numberWithInt:kAudioFormatMPEG4AAC] forKey:AVFormatIDKey]; [recordSettings setObject:[NSNumber numberWithFloat:22050.0] forKey: AVSampleRateKey]; [recordSettings setObject:[NSNumber numberWithInt:1] forKey:AVNumberOfChannelsKey]; [recordSettings setObject:[NSNumber numberWithInt:32000] forKey:AVEncoderBitRateKey]; [recordSettings setObject:[NSNumber numberWithInt:16] forKey:AVLinearPCMBitDepthKey]; [recordSettings setObject:[NSNumber numberWithInt:AVAudioQualityHigh] forKey:AVEncoderAudioQualityKey]; _recorder = [[AVAudioRecorder alloc] initWithURL:_recordAudioUrl settings:recordSettings error:&error]; if (_recorder == nil) { DDLogError(@"Cannot create audio recorder: %@", error); [UIAlertTemplate showAlertWithOwner:[[AppDelegate sharedAppDelegate] currentTopViewController] title:error.localizedDescription message:error.localizedFailureReason actionOk:nil]; return; } _recorder.delegate = self; } - (NSURL *)recorderUrl { return _recordAudioUrl; } - (BOOL)recording { return _recorder.recording; } - (NSTimeInterval)currentTime { return _recorder.currentTime + _tmpFileDuration; } #pragma mark - AVAudioRecorderDelegate - (void)audioRecorderDidFinishRecording:(AVAudioRecorder *)recorder successfully:(BOOL)flag { DDLogInfo(@"Finished recording, successfully: %d", flag); if (_interrupted) { _interrupted = NO; return; } else { [self stop]; [_delegate recorderDidFinish]; } } - (void)audioRecorderEncodeErrorDidOccur:(AVAudioRecorder *)recorder error:(NSError *)error { DDLogError(@"Encode error: %@", error); } #pragma mark - Notifications - (void)registerForNotifications { NSNotificationCenter *nc = [NSNotificationCenter defaultCenter]; [nc addObserver:self selector:@selector(avSessionInterrupted:) name:AVAudioSessionInterruptionNotification object:nil]; [nc addObserver:self selector:@selector(applicationWillEnterForeground:) name:UIApplicationWillEnterForegroundNotification object:nil]; } - (void)unregisterFromNotifications { [[NSNotificationCenter defaultCenter] removeObserver:self]; } - (void)avSessionInterrupted:(NSNotification *)notification { NSNumber *interruptionType = [notification.userInfo objectForKey:@"AVAudioSessionInterruptionTypeKey"]; DDLogInfo(@"AVAudioSessionInterruptionNotification: %d", interruptionType.intValue); if (interruptionType.intValue == AVAudioSessionInterruptionTypeBegan) { _interrupted = YES; _tmpFileDuration = self.currentTime; [_recorder stop]; [self saveToTmpFile]; } else { [self startRecording]; [_delegate recorderResumedAfterInterrupt]; } } - (void)applicationWillEnterForeground:(NSNotification*)notification { if (_interruptedAndNotStarted == true) { _interruptedAndNotStarted = false; [self startRecording]; [_delegate recorderResumedAfterInterrupt]; } } - (void)saveToTmpFile { // Do we already have a temporary recording? If so, join it with the current one if (_tmpRecorderFile) { AVMutableComposition *composition = [AVMutableComposition composition]; AVAsset *asset = [AVURLAsset URLAssetWithURL:_tmpRecorderFile options:nil]; CMTime timeZero = CMTimeMake(0, asset.duration.timescale); CMTimeRange timeRange = CMTimeRangeFromTimeToTime(timeZero, asset.duration); [composition insertTimeRange:timeRange ofAsset:asset atTime:timeZero error:nil]; DDLogVerbose(@"join added: %f %@", CMTimeGetSeconds(asset.duration), _tmpRecorderFile); AVAsset *secondAsset = [AVURLAsset URLAssetWithURL:_recordAudioUrl options:nil]; CMTimeRange secondTimeRange = CMTimeRangeFromTimeToTime(timeZero, secondAsset.duration); [composition insertTimeRange:secondTimeRange ofAsset:secondAsset atTime:asset.duration error:nil]; DDLogVerbose(@"join added 2: %f %@", CMTimeGetSeconds(secondAsset.duration), _recordAudioUrl); AVComposition *immutableSnapshotComposition = [composition copy]; [[NSFileManager defaultManager] removeItemAtURL:_tmpRecorderFile error:nil]; _tmpRecorderFile = [self tmpAudioUrlWithFileNamed:kRecordTmpFileName]; [self saveAudioAsset:immutableSnapshotComposition toURL:_tmpRecorderFile]; } else { _tmpRecorderFile = [self tmpAudioUrlWithFileNamed:kRecordTmpFileName]; AVAsset *asset = [AVURLAsset URLAssetWithURL:_recordAudioUrl options:nil]; [self saveAudioAsset:asset toURL:_tmpRecorderFile]; } } - (void)saveAudioAsset:(AVAsset *)asset toURL:(NSURL *)url { NSError *error; if ([[NSFileManager defaultManager] removeItemAtURL:url error:&error] == NO) { DDLogError(@"audio export could not delete file: %@ error: %@", error, url); }; AVAssetExportSession *exportSession = [[AVAssetExportSession alloc] initWithAsset:asset presetName:AVAssetExportPresetMediumQuality]; exportSession.outputURL = url; exportSession.shouldOptimizeForNetworkUse = YES; exportSession.outputFileType = AVFileTypeMPEG4; [exportSession exportAsynchronouslyWithCompletionHandler:^(void) { DDLogVerbose(@"audio export completed: %ld %@", (long)exportSession.status, url); if (exportSession.error) { DDLogError(@"audio export error: %@", exportSession.error); } dispatch_semaphore_signal(_sema); }]; _sema = dispatch_semaphore_create(0); dispatch_semaphore_wait(_sema, dispatch_time(DISPATCH_TIME_NOW, kMaxSaveWaitTimeS * NSEC_PER_SEC)); } - (void)joinWithTmpFile { if (_tmpRecorderFile) { AVMutableComposition *composition = [AVMutableComposition composition]; AVAsset *asset = [AVURLAsset URLAssetWithURL:_tmpRecorderFile options:nil]; CMTime timeZero = CMTimeMake(0, asset.duration.timescale); CMTimeRange timeRange = CMTimeRangeFromTimeToTime(timeZero, asset.duration); [composition insertTimeRange:timeRange ofAsset:asset atTime:timeZero error:nil]; DDLogVerbose(@"join added: %f %@", CMTimeGetSeconds(asset.duration), _tmpRecorderFile); AVAsset *secondAsset = [AVURLAsset URLAssetWithURL:_recordAudioUrl options:nil]; CMTimeRange secondTimeRange = CMTimeRangeFromTimeToTime(timeZero, secondAsset.duration); [composition insertTimeRange:secondTimeRange ofAsset:secondAsset atTime:asset.duration error:nil]; DDLogVerbose(@"join added 2: %f %@", CMTimeGetSeconds(secondAsset.duration), _recordAudioUrl); AVComposition *immutableSnapshotComposition = [composition copy]; [[NSFileManager defaultManager] removeItemAtURL:_tmpRecorderFile error:nil]; _tmpRecorderFile = nil; _tmpFileDuration = 0; [self saveAudioAsset:immutableSnapshotComposition toURL:_recordAudioUrl]; } } @end