├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Examples └── SwiftUIView.swift ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── VoiceRecorderKit │ ├── Extensions │ └── Color.swift │ ├── Models │ └── RecordingModel.swift │ ├── Player+Recorder │ ├── AudioPlayer.swift │ └── SpeechRecorder.swift │ ├── Resources │ └── Media.xcassets │ │ ├── Background.colorset │ │ └── Contents.json │ │ ├── Contents.json │ │ └── primaryText.colorset │ │ └── Contents.json │ └── Views │ ├── BarView.swift │ ├── DropView.swift │ ├── VoicePlayerView.swift │ └── VoiceRecorderView.swift └── Tests └── VoiceRecorderPackageTests └── VoiceRecorderPackageTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Examples/SwiftUIView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // 4 | // 5 | // Created by devdchaudhary on 29/06/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SwiftUIView: View { 11 | 12 | @StateObject var recorder = AudioRecorder(numberOfSamples: 12, audioFormatID: kAudioFormatAppleLossless, audioQuality: .max) 13 | 14 | var body: some View { 15 | VStack { 16 | 17 | Text("My Recordings") 18 | .foregroundColor(.primaryText) 19 | .font(.system(size: 15)).bold() 20 | 21 | List { 22 | 23 | ForEach(recorder.recordings, id: \.uid) { recording in 24 | VoicePlayerView(audioUrl: recording.fileURL) 25 | } 26 | .onDelete { indexSet in 27 | delete(at: indexSet) 28 | } 29 | } 30 | .listStyle(.inset) 31 | Spacer() 32 | 33 | VoiceRecorderView(audioRecorder: recorder) { 34 | 35 | } recordingComplete: { 36 | 37 | } 38 | } 39 | .onAppear { 40 | recorder.fetchRecordings() 41 | } 42 | } 43 | 44 | func delete(at offsets: IndexSet) { 45 | 46 | var recordingIndex: Int = 0 47 | 48 | for index in offsets { 49 | recordingIndex = index 50 | } 51 | 52 | let recording = recorder.recordings[recordingIndex] 53 | recorder.deleteRecording(url: recording.fileURL, onSuccess: { 54 | recorder.recordings.remove(at: recordingIndex) 55 | DropView.showSuccess(title: "Recording removed!") 56 | }) 57 | } 58 | } 59 | 60 | struct SwiftUIView_Previews: PreviewProvider { 61 | static var previews: some View { 62 | SwiftUIView() 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Devanshu Dev Chaudhary 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "drops", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/omaralbeik/Drops", 7 | "state" : { 8 | "revision" : "f4eabe3e4e5da69e5205a69a5ddc0effd4a90519", 9 | "version" : "1.6.1" 10 | } 11 | } 12 | ], 13 | "version" : 2 14 | } 15 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.8 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "VoiceRecorderKit", 8 | platforms: [ 9 | .iOS(.v15), 10 | ], products: [ 11 | // Products define the executables and libraries a package produces, and make them visible to other packages. 12 | .library( 13 | name: "VoiceRecorderKit", 14 | targets: ["VoiceRecorderKit"]), 15 | ], 16 | dependencies: [ 17 | .package(url: "https://github.com/omaralbeik/Drops", from: "1.0.0") 18 | // Dependencies declare other packages that this package depends on. 19 | // .package(url: /* package url */, from: "1.0.0"), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 24 | .target( 25 | name: "VoiceRecorderKit", 26 | dependencies: ["Drops"], 27 | resources: [.process("Resources")] 28 | ) 29 | ] 30 | ) 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # VoiceRecorderKit 2 | 3 | A Package that uses AVFoundation to allow users to use a record and playback audio recorder via the device's mic and saves the recording audio files via FileManager. 4 | 5 | It also uses a third party library known as "Drops" to give user feedback. 6 | 7 | The View uses a ```@GestureState``` wrapper to persist the pressing state of the user and to check the drag value of the user. 8 | 9 | It resets the value to zero when the user has stopped pressing on the screen. 10 | 11 | The Player and Recorder View themselves use the 12 | ```.averagePower()``` 13 | modifier to get the power input from the voice channel and use those to visualize a waveform of the audio. 14 | 15 | Light Mode 16 | ![Simulator Screen Recording - iPhone 14 Pro - 2023-07-18 at 16 02 12](https://github.com/devdchaudhary/VoiceRecorderKit/assets/52855516/584f64e3-0d0f-4338-a359-e7b68a983481) 17 | 18 | 19 | Supports Dark Mode 20 | ![Simulator Screen Recording - iPhone 14 Pro - 2023-07-18 at 16 02 57](https://github.com/devdchaudhary/VoiceRecorderKit/assets/52855516/bac6515f-9044-4f52-9d5f-2854f060d10e) 21 | 22 | 23 | Requirements 24 | 25 | iOS 15, 26 | Swift 5.0 27 | Xcode 13.0+ 28 | 29 | Installation 30 | 31 | Swift Package Manager 32 | 33 | To integrate VoiceRecorderPackage into your Xcode project, specify it in Package Dependancies > Click the "+" button > Copy and paste the URL below: 34 | 35 | ```https://github.com/devdchaudhary/VoiceRecorderKit``` 36 | 37 | set branch to "master" 38 | 39 | Check VoiceRecorderKit 40 | 41 | Click Add to Project 42 | 43 | Usage 44 | 45 | Step 1 : Import ```VoiceRecorderKit``` 46 | 47 | Step 2 : Declare the AudioRecorder Constructor as a ```@StateObject` and give input your desired settings for the voice output. 48 | 49 | Step 3: Call the VoiceRecorderView inside your view and pass the ```@StateObject` to it. 50 | 51 | Step 3 : Press and Hold the Record Button on the VoiceRecorderView 52 | 53 | Below is an example demonstrating the use of the recorder and player. 54 | 55 | ``` 56 | import SwiftUI 57 | import AudioUnit 58 | import VoiceRecorderKit 59 | 60 | struct ContentView: View { 61 | 62 | @StateObject var recorder = AudioRecorder(numberOfSamples: 12, audioFormatID: kAudioFormatAppleLossless, audioQuality: .max) 63 | 64 | var body: some View { 65 | VStack { 66 | List { 67 | ForEach(recorder.recordings, id: \.uid) { recording in 68 | VoicePlayerView(audioUrl: recording.fileURL) 69 | .onAppear { 70 | print(recording.fileURL) 71 | } 72 | } 73 | .onDelete { indexSet in 74 | delete(at: indexSet) 75 | } 76 | } 77 | .listStyle(.inset) 78 | Spacer() 79 | 80 | VoiceRecorderView(audioRecorder: recorder) { 81 | 82 | } recordingComplete: { 83 | 84 | } 85 | } 86 | .onAppear { 87 | recorder.fetchRecordings() 88 | } 89 | } 90 | 91 | func delete(at offsets: IndexSet) { 92 | 93 | var recordingIndex: Int = 0 94 | 95 | for index in offsets { 96 | recordingIndex = index 97 | } 98 | 99 | let recording = recorder.recordings[recordingIndex] 100 | recorder.deleteRecording(url: recording.fileURL, onSuccess: { 101 | recorder.recordings.remove(at: recordingIndex) 102 | DropView.showSuccess(title: "Recording removed!") 103 | }) 104 | } 105 | } 106 | ``` 107 | -------------------------------------------------------------------------------- /Sources/VoiceRecorderKit/Extensions/Color.swift: -------------------------------------------------------------------------------- 1 | // 2 | // File.swift 3 | // 4 | // 5 | // Created by devdchaudhary on 29/06/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public extension Color { 11 | 12 | static let backgroundColor = Color("Background", bundle: .module) 13 | static let primaryText = Color("primaryText", bundle: .module) 14 | 15 | } 16 | -------------------------------------------------------------------------------- /Sources/VoiceRecorderKit/Models/RecordingModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RecordingModel.swift 3 | // 4 | // Created by devdchaudhary on 18/04/23. 5 | // 6 | 7 | import Foundation 8 | 9 | public struct Recording: Hashable { 10 | 11 | public let uid: UUID 12 | public var fileURL: URL 13 | 14 | public init(fileURL: URL) { 15 | uid = UUID() 16 | self.fileURL = fileURL 17 | } 18 | 19 | } 20 | 21 | public struct SampleModel: Hashable { 22 | 23 | let id: UUID 24 | let sample: Float 25 | 26 | public init(sample: Float) { 27 | id = UUID() 28 | self.sample = sample 29 | } 30 | 31 | } 32 | 33 | public struct RecordingSampleModel: Hashable { 34 | 35 | let id: UUID 36 | public var sample: Int 37 | 38 | init(sample: Int) { 39 | id = UUID() 40 | self.sample = sample 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /Sources/VoiceRecorderKit/Player+Recorder/AudioPlayer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AudioPlayer.swift 3 | // 4 | // Created by devdchaudhary on 18/04/23. 5 | // 6 | 7 | import SwiftUI 8 | import AVFoundation 9 | 10 | public final class AudioPlayer: NSObject, ObservableObject, AVAudioPlayerDelegate { 11 | 12 | @Published public var soundSamples: [SampleModel] = [] 13 | @Published public var isPlaying = false 14 | 15 | var audioPlayer = AVAudioPlayer() 16 | 17 | private var timer: Timer? 18 | 19 | private var currentSample: Float = 0 20 | private let numberOfSamples: Int 21 | 22 | private var durationTimer: Timer? 23 | 24 | var fileDuration: TimeInterval = 0 25 | var currentTime: Int = 0 26 | 27 | static let shared = AudioPlayer(numberOfSamples: 15) 28 | 29 | public init(isPlaying: Bool = false, audioPlayer: AVAudioPlayer = AVAudioPlayer(), timer: Timer? = nil, numberOfSamples: Int) { 30 | self.isPlaying = isPlaying 31 | self.audioPlayer = audioPlayer 32 | self.timer = timer 33 | self.numberOfSamples = numberOfSamples 34 | } 35 | 36 | func playSystemSound(soundID: SystemSoundID) { 37 | AudioServicesPlaySystemSound(soundID) 38 | } 39 | 40 | func startPlayback(audio: URL) { 41 | 42 | do { 43 | 44 | try AVAudioSession.sharedInstance().setCategory(.playback, options: .duckOthers) 45 | try AVAudioSession.sharedInstance().setActive(true) 46 | 47 | audioPlayer = try AVAudioPlayer(contentsOf: audio) 48 | audioPlayer.volume = 1.0 49 | audioPlayer.delegate = self 50 | audioPlayer.play() 51 | 52 | withAnimation { 53 | isPlaying = true 54 | } 55 | 56 | fileDuration = audioPlayer.duration.rounded() 57 | 58 | startMonitoring() 59 | 60 | } catch let error { 61 | 62 | print("Playback failed.\(error.localizedDescription)") 63 | 64 | } 65 | } 66 | 67 | func startMonitoring() { 68 | 69 | audioPlayer.isMeteringEnabled = true 70 | currentTime = Int(fileDuration) 71 | 72 | timer = Timer.scheduledTimer(withTimeInterval: 0.03, repeats: true) { [weak self] _ in 73 | 74 | guard let this = self else { return } 75 | 76 | this.audioPlayer.updateMeters() 77 | this.currentSample = this.audioPlayer.peakPower(forChannel: 0) 78 | this.soundSamples.append(SampleModel(sample: this.currentSample)) 79 | } 80 | 81 | durationTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in 82 | 83 | guard let this = self else { return } 84 | 85 | this.currentTime -= 1 86 | } 87 | 88 | } 89 | 90 | private func stopMonitoring() { 91 | soundSamples = [] 92 | audioPlayer.isMeteringEnabled = false 93 | timer?.invalidate() 94 | durationTimer?.invalidate() 95 | currentTime = Int(fileDuration) 96 | } 97 | 98 | public func stopPlayback() { 99 | audioPlayer.stop() 100 | stopMonitoring() 101 | 102 | withAnimation { 103 | isPlaying = false 104 | } 105 | } 106 | 107 | public func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { 108 | if flag { 109 | stopPlayback() 110 | } 111 | } 112 | 113 | } 114 | -------------------------------------------------------------------------------- /Sources/VoiceRecorderKit/Player+Recorder/SpeechRecorder.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SpeechRecorder.swift 3 | // 4 | // Created by devdchaudhary on 18/04/23. 5 | // 6 | 7 | import SwiftUI 8 | import AVFoundation 9 | 10 | public final class AudioRecorder: NSObject, ObservableObject { 11 | 12 | @Published public var recordings: [Recording] = [] 13 | @Published public var recording = false 14 | @Published public var soundSamples: [RecordingSampleModel] 15 | 16 | private var currentSample: RecordingSampleModel = .init(sample: .zero) 17 | private let numberOfSamples: Int 18 | 19 | private var timer: Timer? 20 | 21 | public var audioRecorder = AVAudioRecorder() 22 | 23 | let audioFormatID: AudioFormatID 24 | let sampleRateKey: Float 25 | let noOfchannels: Int 26 | let audioQuality: AVAudioQuality 27 | 28 | public init(numberOfSamples: Int, audioFormatID: AudioFormatID, audioQuality: AVAudioQuality, noOfChannels: Int = 2, sampleRateKey: Float = 44100.0) { 29 | self.soundSamples = [RecordingSampleModel](repeating: .init(sample: .zero), count: numberOfSamples) 30 | self.numberOfSamples = numberOfSamples 31 | self.audioFormatID = audioFormatID 32 | self.audioQuality = audioQuality 33 | self.noOfchannels = noOfChannels 34 | self.sampleRateKey = sampleRateKey 35 | } 36 | 37 | public func startRecording() { 38 | 39 | do { 40 | 41 | try AVAudioSession.sharedInstance().setCategory(.playAndRecord) 42 | try AVAudioSession.sharedInstance().setActive(true) 43 | 44 | } catch let error { 45 | 46 | print("Failed to set up recording session \(error.localizedDescription)") 47 | } 48 | 49 | let documentPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] 50 | let audioFilename = documentPath.appendingPathComponent("\(UUID().uuidString).m4a") 51 | 52 | UserDefaults.standard.set(audioFilename.absoluteString, forKey: "tempUrl") 53 | 54 | let settings: [String:Any] = [ 55 | AVFormatIDKey: kAudioFormatAppleLossless, 56 | AVSampleRateKey:44100.0, 57 | AVNumberOfChannelsKey:2, 58 | AVEncoderAudioQualityKey: AVAudioQuality.max.rawValue 59 | ] 60 | 61 | do { 62 | audioRecorder = try AVAudioRecorder(url: audioFilename, settings: settings) 63 | audioRecorder.record() 64 | recording = true 65 | startMonitoring() 66 | } catch { 67 | print("Could not start recording") 68 | } 69 | } 70 | 71 | public func stopRecording() { 72 | audioRecorder.stop() 73 | recording = false 74 | stopMonitoring() 75 | saveRecording() 76 | } 77 | 78 | private func saveRecording() { 79 | if let tempUrl = UserDefaults.standard.string(forKey: "tempUrl") { 80 | if let url = URL(string: tempUrl) { 81 | if let data = try? Data(contentsOf: url) { 82 | do { 83 | try data.write(to: url, options: [.atomic, .completeFileProtection]) 84 | } catch { 85 | print(error.localizedDescription) 86 | } 87 | } 88 | } 89 | } 90 | } 91 | 92 | public func deleteRecording(url: URL, onSuccess: (() -> Void)?) { 93 | 94 | do { 95 | try FileManager.default.removeItem(at: url) 96 | onSuccess?() 97 | } catch { 98 | print("File could not be deleted!") 99 | } 100 | } 101 | 102 | private func startMonitoring() { 103 | 104 | audioRecorder.isMeteringEnabled = true 105 | 106 | timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true, block: { [weak self] _ in 107 | 108 | guard let this = self else { return } 109 | 110 | this.audioRecorder.updateMeters() 111 | this.soundSamples[this.currentSample.sample] = RecordingSampleModel(sample: Int(this.audioRecorder.averagePower(forChannel: 0))) 112 | this.currentSample.sample = (this.currentSample.sample + 1) % this.numberOfSamples 113 | }) 114 | } 115 | 116 | func stopMonitoring() { 117 | audioRecorder.isMeteringEnabled = false 118 | timer?.invalidate() 119 | } 120 | 121 | public func fetchRecordings() { 122 | 123 | let fileManager = FileManager.default 124 | let documentDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0] 125 | let directoryContents = try? fileManager.contentsOfDirectory(at: documentDirectory, includingPropertiesForKeys: nil) 126 | 127 | if let directoryContents { 128 | 129 | for audio in directoryContents { 130 | let recording = Recording(fileURL: audio) 131 | recordings.append(recording) 132 | } 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /Sources/VoiceRecorderKit/Resources/Media.xcassets/Background.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "1.000", 9 | "green" : "1.000", 10 | "red" : "1.000" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "platform" : "ios", 24 | "reference" : "darkTextColor" 25 | }, 26 | "idiom" : "universal" 27 | } 28 | ], 29 | "info" : { 30 | "author" : "xcode", 31 | "version" : 1 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Sources/VoiceRecorderKit/Resources/Media.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sources/VoiceRecorderKit/Resources/Media.xcassets/primaryText.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "platform" : "universal", 6 | "reference" : "labelColor" 7 | }, 8 | "idiom" : "universal" 9 | }, 10 | { 11 | "appearances" : [ 12 | { 13 | "appearance" : "luminosity", 14 | "value" : "dark" 15 | } 16 | ], 17 | "color" : { 18 | "platform" : "ios", 19 | "reference" : "labelColor" 20 | }, 21 | "idiom" : "universal" 22 | } 23 | ], 24 | "info" : { 25 | "author" : "xcode", 26 | "version" : 1 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/VoiceRecorderKit/Views/BarView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SwiftUIView.swift 3 | // 4 | // 5 | // Created by devdchaudhary on 07/07/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BarView: View { 11 | 12 | let isRecording: Bool 13 | var value: CGFloat = 0 14 | 15 | var sample: SampleModel? 16 | 17 | var body: some View { 18 | 19 | RoundedRectangle(cornerRadius: 20) 20 | .fill(Color.primaryText) 21 | .frame(width: isRecording ? 10 : 5, height: isRecording ? value : normalizeSoundLevel(level: sample?.sample ?? 0)) 22 | } 23 | 24 | private func normalizeSoundLevel(level: Float) -> CGFloat { 25 | let level = max(0.2, CGFloat(level) + 40) / 2 26 | return level 27 | } 28 | 29 | } 30 | 31 | struct BarView_Previews: PreviewProvider { 32 | static var previews: some View { 33 | BarView(isRecording: true) 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /Sources/VoiceRecorderKit/Views/DropView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToastView.swift 3 | // CustomAudioRecorder 4 | // 5 | // Created by devdchaudhary on 13/06/23. 6 | // 7 | 8 | import SwiftUI 9 | import Drops 10 | 11 | public struct DropView { 12 | 13 | static let generator = UINotificationFeedbackGenerator() 14 | 15 | static func show( 16 | _ title: String, 17 | _ subTitle: String = "", 18 | titleNumberOfLines: Int = 0, 19 | icon: UIImage? = nil, 20 | position: Drop.Position, 21 | duration: Drop.Duration, 22 | accessibility: Drop.Accessibility) { 23 | 24 | if let icon { 25 | 26 | Drops.show(Drop( 27 | title: title, 28 | titleNumberOfLines: titleNumberOfLines, 29 | subtitle: subTitle, 30 | icon: icon, 31 | position: position, 32 | duration: duration, 33 | accessibility: accessibility 34 | )) 35 | 36 | } else { 37 | 38 | Drops.show(Drop( 39 | title: title, 40 | titleNumberOfLines: titleNumberOfLines, 41 | subtitle: subTitle, 42 | position: position, 43 | duration: duration, 44 | accessibility: accessibility 45 | )) 46 | 47 | } 48 | } 49 | 50 | public static func showWarning(title: String = "An error occured!", subtitle: String = "", titleNoofLines: Int = 0) { 51 | 52 | generator.notificationOccurred(.warning) 53 | show(title, subtitle, icon: UIImage(systemName: "exclamationmark.triangle.fill"), position: .top, duration: 2.0, accessibility: "Alert: An error occured!") 54 | } 55 | 56 | public static func showError(title: String = "An error occured!", subtitle: String = "", titleNoofLines: Int = 0) { 57 | 58 | generator.notificationOccurred(.error) 59 | show(title, subtitle, icon: UIImage(systemName: "xmark.circle"), position: .top, duration: 2.0, accessibility: "Alert: An error occured!") 60 | } 61 | 62 | public static func showSuccess(title: String = "Success!", subtitle: String = "", titleNoofLines: Int = 0) { 63 | 64 | generator.notificationOccurred(.success) 65 | show(title, subtitle, icon: UIImage(systemName: "checkmark.circle.fill"), position: .top, duration: 2.0, accessibility: "Alert: Success!") 66 | } 67 | 68 | } 69 | 70 | -------------------------------------------------------------------------------- /Sources/VoiceRecorderKit/Views/VoicePlayerView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AudioPlayerView.swift 3 | // CustomAudioRecorder 4 | // 5 | // Created by devdchaudhary on 11/05/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct VoicePlayerView: View { 11 | 12 | @StateObject var audioPlayer = AudioPlayer(numberOfSamples: 15) 13 | 14 | private var audioUrl: URL 15 | @State private var isPlaying = false 16 | 17 | @State private var timer: Timer? 18 | 19 | private var fileName: String? 20 | 21 | public init(audioUrl: URL, isPlaying: Bool = false, timer: Timer? = nil, fileName: String? = nil) { 22 | self.audioUrl = audioUrl 23 | self.isPlaying = isPlaying 24 | self.timer = timer 25 | self.fileName = fileName 26 | } 27 | 28 | public var body: some View { 29 | 30 | HStack { 31 | 32 | if audioPlayer.isPlaying { 33 | 34 | Button(action: stopPlaying) { 35 | Image(systemName: "stop.fill") 36 | .resizable() 37 | .aspectRatio(contentMode: .fit) 38 | .frame(width: 20, height: 20) 39 | .foregroundColor(Color(uiColor: .systemRed)) 40 | } 41 | .buttonStyle(.plain) 42 | 43 | } else { 44 | 45 | Button(action: playAudio) { 46 | Image(systemName: "play.fill") 47 | .resizable() 48 | .aspectRatio(contentMode: .fit) 49 | .frame(width: 20, height: 20) 50 | .foregroundColor(Color(uiColor: .systemGreen)) 51 | } 52 | .buttonStyle(.plain) 53 | 54 | } 55 | 56 | ScrollViewReader { proxy in 57 | ScrollView(.horizontal, showsIndicators: false) { 58 | LazyHStack { 59 | ForEach(audioPlayer.soundSamples, id: \.id) { level in 60 | BarView(isRecording: false, sample: level) 61 | .id(level) 62 | } 63 | } 64 | } 65 | .onChange(of: audioPlayer.soundSamples) { _ in 66 | proxy.scrollTo(audioPlayer.soundSamples.last) 67 | } 68 | } 69 | 70 | Text(audioPlayer.currentTime.description) 71 | .font(.system(size: 18)) 72 | .foregroundColor(.primaryText) 73 | 74 | } 75 | .padding() 76 | .padding(.vertical) 77 | .overlay { 78 | RoundedRectangle(cornerRadius: 20) 79 | .stroke(Color.gray, lineWidth: 1) 80 | .padding(.vertical) 81 | 82 | } 83 | } 84 | 85 | private func playAudio() { 86 | audioPlayer.playSystemSound(soundID: 1306) 87 | audioPlayer.startPlayback(audio: audioUrl) 88 | } 89 | 90 | private func stopPlaying() { 91 | audioPlayer.stopPlayback() 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /Sources/VoiceRecorderKit/Views/VoiceRecorderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ContentView.swift 3 | // CustomAudioRecorder 4 | // 5 | // Created by devdchaudhary on 28/04/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | public struct VoiceRecorderView: View { 11 | 12 | @ObservedObject var audioRecorder: AudioRecorder 13 | 14 | @GestureState private var dragState: CGSize = .zero 15 | 16 | @State private var isRecording = false 17 | 18 | @State private var timer: Timer? 19 | @State private var recordingTimer: Timer? 20 | 21 | @State private var currentTime = 0 22 | @State private var holdingTime = 0 23 | @State private var isSendingAudio = false 24 | @State private var isLocked = false 25 | @State private var dragValue: CGSize? 26 | 27 | var recordingCancelled: (() -> Void)? 28 | var recordingComplete: (() -> Void)? 29 | 30 | let bgColor: Color? 31 | 32 | public init(isRecording: Bool = false, timer: Timer? = nil, recordingTimer: Timer? = nil, currentTime: Int = 0, holdingTime: Int = 0, isSendingAudio: Bool = false, isLocked: Bool = false, dragValue: CGSize? = nil, audioRecorder: AudioRecorder, recordingCancelled: (() -> Void)?, recordingComplete: (() -> Void)?, bgColor: Color? = nil) { 33 | self.isRecording = isRecording 34 | self.timer = timer 35 | self.recordingTimer = recordingTimer 36 | self.currentTime = currentTime 37 | self.holdingTime = holdingTime 38 | self.isSendingAudio = isSendingAudio 39 | self.isLocked = isLocked 40 | self.dragValue = dragValue 41 | self.audioRecorder = audioRecorder 42 | self.recordingCancelled = recordingCancelled 43 | self.recordingComplete = recordingComplete 44 | self.bgColor = bgColor 45 | } 46 | 47 | public var body: some View { 48 | 49 | if isRecording { 50 | 51 | HStack { 52 | 53 | Button { 54 | if isRecording { 55 | if isLocked { 56 | withAnimation { 57 | cancelRecording() 58 | } 59 | } 60 | } 61 | } label: { 62 | Image(systemName: "trash.circle.fill") 63 | .resizable() 64 | .aspectRatio(contentMode: .fit) 65 | .frame(width: 30, height: 30) 66 | .foregroundColor(dragState.height >= -45 ? .primaryText : .red) 67 | } 68 | .buttonStyle(.plain) 69 | .padding(.leading, 5) 70 | 71 | Spacer() 72 | 73 | Text(dragState.width >= 200 || isLocked ? "Slide right to lock" : "Press and hold to record") 74 | .foregroundColor(.primaryText) 75 | .font(.system(size: 15)) 76 | 77 | Spacer() 78 | 79 | } 80 | .padding(.horizontal) 81 | 82 | Spacer().frame(height: 20) 83 | 84 | } 85 | 86 | HStack { 87 | 88 | ZStack { 89 | 90 | if isLocked { 91 | 92 | Button { 93 | withAnimation { 94 | stopRecording() 95 | } 96 | } label: { 97 | Image(systemName: "mic.circle.fill") 98 | .resizable() 99 | .aspectRatio(contentMode: .fit) 100 | .frame(width: 30, height: 30) 101 | .foregroundColor(.primaryText) 102 | } 103 | .buttonStyle(.plain) 104 | .padding(.leading, 5) 105 | 106 | } else { 107 | 108 | Button(action: {}) { 109 | Image(systemName: "mic.circle.fill") 110 | .resizable() 111 | .aspectRatio(contentMode: .fit) 112 | .frame(width: 30, height: 30) 113 | .foregroundColor(.primaryText) 114 | } 115 | .buttonStyle(.plain) 116 | .padding(.leading, 5) 117 | .padding(.vertical, 8) 118 | .simultaneousGesture( 119 | LongPressGesture(minimumDuration: 3) 120 | .sequenced(before: DragGesture()) 121 | .updating($dragState) { value, dragState, _ in 122 | switch value { 123 | case .first: 124 | dragState = .zero 125 | case .second(true, let drag): 126 | dragState = drag?.translation ?? .zero 127 | default: 128 | break 129 | } 130 | } 131 | .onEnded { value in 132 | switch value { 133 | case .first: 134 | dragValue = .zero 135 | case .second(true, let drag): 136 | withAnimation { 137 | dragValue = drag?.translation ?? .zero 138 | } 139 | default: 140 | break 141 | } 142 | } 143 | ) 144 | .onLongPressGesture(perform: {}) { isPressing in 145 | 146 | if isPressing { 147 | timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { _ in 148 | withAnimation { 149 | holdingTime += 1 150 | if holdingTime == 3 { 151 | withAnimation { 152 | startRecording() 153 | } 154 | } 155 | } 156 | }) 157 | 158 | } else { 159 | 160 | if holdingTime < 3 { 161 | 162 | holdingTime = 0 163 | timer?.invalidate() 164 | DropView.showWarning(title: "Hold to record", subtitle: "You must hold for atleast 2 seconds") 165 | return 166 | } 167 | 168 | if let value = dragValue { 169 | 170 | if value.height.magnitude >= 45 { 171 | 172 | let impactMed = UIImpactFeedbackGenerator(style: .medium) 173 | impactMed.impactOccurred() 174 | 175 | withAnimation { 176 | cancelRecording() 177 | } 178 | 179 | dragValue = nil 180 | return 181 | } 182 | 183 | if value.width.magnitude >= 150 { 184 | 185 | let impactMed = UIImpactFeedbackGenerator(style: .medium) 186 | impactMed.impactOccurred() 187 | 188 | withAnimation { 189 | isLocked = true 190 | } 191 | 192 | dragValue = nil 193 | return 194 | } 195 | } 196 | 197 | withAnimation { 198 | stopRecording() 199 | } 200 | dragValue = nil 201 | } 202 | } 203 | } 204 | } 205 | 206 | if isRecording { 207 | 208 | ZStack(alignment: .leading) { 209 | 210 | if dragState.width >= 200 || isLocked { 211 | 212 | HStack { 213 | 214 | Spacer() 215 | 216 | Text(currentTime.description) 217 | .font(.system(size: 18)) 218 | .foregroundColor(.primaryText) 219 | .padding(.horizontal) 220 | 221 | Image(systemName: "circle.fill") 222 | .resizable() 223 | .aspectRatio(contentMode: .fit) 224 | .foregroundColor(.red) 225 | .frame(width: 5, height: 5) 226 | .opacity(currentTime % 2 == 0 ? 1 : 0) 227 | 228 | Button(action: {}) { 229 | Image(systemName: "lock.circle.fill") 230 | .resizable() 231 | .aspectRatio(contentMode: .fit) 232 | .frame(width: 30, height: 30) 233 | .foregroundColor(.primaryText) 234 | } 235 | .buttonStyle(.plain) 236 | .padding(.horizontal) 237 | 238 | } 239 | .padding(.horizontal, 5) 240 | .padding(.vertical, 8) 241 | .background(Color.green) 242 | .cornerRadius(30) 243 | 244 | } else { 245 | 246 | HStack { 247 | 248 | Spacer() 249 | 250 | Text(currentTime.description) 251 | .font(.system(size: 18)) 252 | .foregroundColor(.primaryText) 253 | .padding(.horizontal) 254 | 255 | Image(systemName: "circle.fill") 256 | .resizable() 257 | .aspectRatio(contentMode: .fit) 258 | .foregroundColor(.red) 259 | .frame(width: 5, height: 5) 260 | .opacity(currentTime % 2 == 0 ? 1 : 0) 261 | 262 | Button(action: {}) { 263 | Image(systemName: "lock.circle.fill") 264 | .resizable() 265 | .aspectRatio(contentMode: .fit) 266 | .frame(width: 30, height: 30) 267 | .foregroundColor(.primaryText) 268 | } 269 | .buttonStyle(.plain) 270 | .padding(.horizontal) 271 | 272 | } 273 | .padding(.horizontal, 5) 274 | .padding(.vertical, 8) 275 | .background(Color.accentColor) 276 | .cornerRadius(30) 277 | 278 | } 279 | 280 | HStack(spacing: 4) { 281 | ForEach(audioRecorder.soundSamples, id: \.id) { level in 282 | BarView(isRecording: true, value: normalizeSoundLevel(level: Float(level.sample)), sample: nil) 283 | } 284 | } 285 | .padding(.leading) 286 | 287 | } 288 | } 289 | 290 | Spacer() 291 | 292 | } 293 | .padding(.horizontal) 294 | .padding(.vertical) 295 | .background(bgColor != nil ? bgColor?.ignoresSafeArea(edges: .bottom) : Color.backgroundColor.ignoresSafeArea(edges: .bottom)) 296 | 297 | } 298 | 299 | private func normalizeSoundLevel(level: Float) -> CGFloat { 300 | let level = max(0.2, CGFloat(level) + 50) / 2 // between 0.1 and 25 301 | 302 | return CGFloat(level) // scaled to max at 300 (our height of our bar) 303 | } 304 | 305 | private func startRecording() { 306 | isRecording = true 307 | timer?.invalidate() 308 | audioRecorder.startRecording() 309 | recordingTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { _ in 310 | withAnimation { 311 | currentTime += 1 312 | if currentTime >= 60 { 313 | stopRecording() 314 | } 315 | } 316 | }) 317 | } 318 | 319 | func cancelRecording() { 320 | 321 | recordingTimer?.invalidate() 322 | 323 | guard let tempUrl = UserDefaults.standard.string(forKey: "tempUrl") else { return } 324 | 325 | if let url = URL(string: tempUrl) { 326 | 327 | audioRecorder.deleteRecording(url: url, onSuccess: nil) 328 | } 329 | 330 | audioRecorder.stopMonitoring() 331 | currentTime = 0 332 | isRecording = false 333 | isLocked = false 334 | holdingTime = 0 335 | recordingCancelled?() 336 | } 337 | 338 | func stopRecording() { 339 | recordingTimer?.invalidate() 340 | audioRecorder.stopRecording() 341 | currentTime = 0 342 | isRecording = false 343 | isSendingAudio = true 344 | isLocked = false 345 | holdingTime = 0 346 | 347 | guard let tempUrl = UserDefaults.standard.string(forKey: "tempUrl") else { return } 348 | 349 | if let url = URL(string: tempUrl) { 350 | 351 | let newRecording = Recording(fileURL: url) 352 | 353 | audioRecorder.recordings.append(newRecording) 354 | } 355 | recordingComplete?() 356 | } 357 | } 358 | 359 | -------------------------------------------------------------------------------- /Tests/VoiceRecorderPackageTests/VoiceRecorderPackageTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | @testable import VoiceRecorderPackage 4 | 5 | final class VoiceRecorderPackageTests: XCTestCase { 6 | func testExample() throws { 7 | // This is an example of a functional test case. 8 | // Use XCTAssert and related functions to verify your tests produce the correct 9 | // results. 10 | let text = XCTAssert(Color.primaryText != nil) 11 | } 12 | } 13 | --------------------------------------------------------------------------------