ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • SwiftUI로 음성메모 구현하기
    SwiftUI 2023. 8. 15. 11:27

    안녕하세요. 그린입니다 🍏

    이번 포스팅에서는 SwiftUI로 음성메모를 구현하는 학습을 해보려해요! 🙌

     

    어떻게 만들어 볼까요?

    전체적으로 설명하면 AVFoundation을 이용하여 해당 음성메모 및 재생 등에 관한 기능을 해줄 수 있는 서비스를 만들고 해당 서비스 객체를 뷰에서 사용하면 됩니다.

    MVVM으로 굳이 뷰에 뷰모델을 만들어 해당 뷰모델이 음성메모를 관장하는 서비스 객체를 가지는 구조로 할 수 도 있지만 간단히 이번 포스팅에서는 음성메모 기능에 초점을 맞추기 위해 구조적으로 생략했음을 말씀드려요 🙋🏻

     

    그럼 바로 한번 만들어보겠습니다 🕺🏻


    음성메모 서비스 객체 구현하기

    가장 먼저 음성메모를 담당하는 서비스 객체를 구성하는것이 핵심이자 사실 전부입니다.

    그렇기에 먼저 코드로 보면서 설명을 해볼께요!

    import AVFoundation
    
    class AudioRecorderManager: NSObject, ObservableObject, AVAudioPlayerDelegate {
      /// 음성메모 녹음 관련 프로퍼티
      var audioRecorder: AVAudioRecorder?
      @Published var isRecording = false
      
      /// 음성메모 재생 관련 프로퍼티
      var audioPlayer: AVAudioPlayer?
      @Published var isPlaying = false
      @Published var isPaused = false
      
      /// 음성메모된 데이터
      var recordedFiles = [URL]()
    }
    
    // MARK: - 음성메모 녹음 관련 메서드
    extension AudioRecorderManager {
      func startRecording() {
        let fileURL = getDocumentsDirectory().appendingPathComponent("recording-\(Date().timeIntervalSince1970).m4a")
        let settings = [
          AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
          AVSampleRateKey: 12000,
          AVNumberOfChannelsKey: 1,
          AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
        ]
        
        do {
          audioRecorder = try AVAudioRecorder(url: fileURL, settings: settings)
          audioRecorder?.record()
          self.isRecording = true
        } catch {
          print("녹음 중 오류 발생: \(error.localizedDescription)")
        }
      }
      
      func stopRecording() {
        audioRecorder?.stop()
        self.recordedFiles.append(self.audioRecorder!.url)
        self.isRecording = false
      }
      
      private func getDocumentsDirectory() -> URL {
        let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)
        return paths[0]
      }
    }
    
    // MARK: - 음성메모 재생 관련 메서드
    extension AudioRecorderManager {
      func startPlaying(recordingURL: URL) {
        do {
          audioPlayer = try AVAudioPlayer(contentsOf: recordingURL)
          audioPlayer?.delegate = self
          audioPlayer?.play()
          self.isPlaying = true
          self.isPaused = false
        } catch {
          print("재생 중 오류 발생: \(error.localizedDescription)")
        }
      }
      
      func stopPlaying() {
        audioPlayer?.stop()
        self.isPlaying = false
      }
      
      func pausePlaying() {
        audioPlayer?.pause()
        self.isPaused = true
      }
      
      func resumePlaying() {
        audioPlayer?.play()
        self.isPaused = false
      }
      
      func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
        self.isPlaying = false
        self.isPaused = false
      }
    }

     

    먼저 오디오 관련한것이기에 당연히 AVFoundation를 임포트해야합니다!

    AVFoundation에 대한 딥한 포스팅은 또 별도로 만들어볼께요 😄


    그럼 하나씩 설명을 해볼까요?

     

    우선 AudioRecorderManager라는 서비스 객체를 만들면서 해당 객체를 뷰에 얹어 사용하기 위해 ObservableObject를 채택합니다.

    또한 음성메모의 재생 끝지점 등 내장되어 있는 딜리게이트 메서드 사용을 위해 AVAudioPlayerDelegate 프로토콜도 채택합니다.

     

    여기서 NSObject도 상속받는데 그 이유가 있습니다.

    AVAudioPlayerDelegate는 NSObjectProtocol을 채택하고 있습니다.

    이 프로토콜은 Core Foundation 속성을 가진 타입이기에 이 객체들이 실행되는 런타임 시 런타임 매커니즘이 해당 프로토콜을 기반으로 동작하게 됩니다.

    그렇기에 AVAudioPlayerDelegate를 채택하여 객체를 구현하기 위해 NSObejctPlayerProcotol을 채택하거나 NSObject를 상속받아 해당 AVAuidoPlayerDelegate가 간접적으로 이 런타임 매커니즘을 사용하게 만들 수 있습니다.

     

    즉, 둘중 하나의 방식인데 NSObject를 상속받는것이 더 단순하기에 사용합니다.

    만약 둘다 채택 혹은 상속하지 않으면 기본 AVAudioPlayerDelegate의 필수 구현 메서드들을 모두 정의해야 하지만 간단히 NSObject를 상속받아 해결할 수 있죠.

     

    그럼 구현부를 볼까요?

     

    두 부분으로 나눌 수 있습니다.

    먼저 음성메모 녹음을 위해 AVAudioRecorder 타입의 프로퍼티와 관련 해당 프로퍼티들이 필요합니다.

    그리고 음성메모 재생 기능을 위해 AVAudioPlayer 타입의 프로퍼티와 관련 해당 프로퍼티들이 필요하죠.

     

    그래서 구분을 위하여 각 기능에 맞는 메서드들이 모일 수 있도록 extension합니다.

     


    음성메모 녹음 관련 기능 구현하기

    첫 음성메모 녹음 관련한 extension에서 녹음 시작과 종료 메서드들을 구현합니다.

    stopRecording 메서드에서 먼저 해당 녹음파일이 저장될 위치를 파일 매니저를 통해 저장되는 파일 이름을 담아 url을 생성합니다.

    그리고 녹음 파일이 어떤 형식으로 저장될지 설정해줍니다.

     

    AVFormatIDKey는 오디오 녹음 파일의 포맷을 설정합니다.

    현재 kAudioFormatMPEG4AAC 값을 사용하도록하는데 이 값은 MPEG-4 Advanced Audio Coding(AAC) 형식을 나타냅니다.

    여기서 AAC는 손실 압축 오디오 코덱으로 MP3와 같은 오디오 형식보다 좀 더 높은 품질을 갖습니다.

     

    AVSampleRateKey는 오디오 샘플링 비율을 설정하죠.

    이 비율은 오디오 신호를 디지털 형태로 변환하는데 사용되는 주파수로 해당 값이 높을수록 오디오 품질이 높아지며 파일 크기도 커집니다.

     

    AVNumberOfChannelsKey는 오디오 녹음 파일의 채널 수를 지정합니다.

    즉, 1의 값을 주었기에 한 채널인 모노 채널로만 녹음됩니다.

    만약 2의 값이라면 두 채널인 스테레오로 녹음이 되죠.

     

    마지막으로, AVEncorderAudioQuilityKey는 오디오 인코더의 품질을 지정해줍니다.

    low, medium, high 혹은 max로 값을 줄 수 있습니다.

    당연히 품질이 높을수록 파일 크기도 커집니다.

     

    이렇게 설정 변수를 만들고 실제 녹음 시작을 위해 AVAudioRecorder 인스턴스를 만들때 어디 저장될지 url과 어떤 설정을 가질지 settings 파라미터를 두어 인스턴스를 생성합니다.

     

    그리고 녹음을 시작하죠!
    녹음이 시작되면 녹음중이라는 프로퍼티 값을 true로 변경해줍니다.

     

    다음으로 stopRecording에서 녹음을 종료할 수 있습니다.

    단순히 만들어진 audioRecorder 인스턴스를 stop해주면 됩니다.

    그리고 해당 녹음 파일의 url 경로를 해당 서비스 recordedFiles ulr 어레이 프로퍼티에 추가합니다.

     

    마지막으로, getDocumentsDirectory 메서드에서 파일 매너저의 저장될 파일 url 경로를 탐색하여 반환해줍니다.

     

    이렇게 간단히 녹음 기능 관련한 메서드들을 꾸려줄 수 있어요 😃

     

    다음으로 녹음이 된 음성메모를 실제 재생하고 일시정지 또는 종료, 재개 등의 기능을 만들어 봐야합니다!


    음성메모 재생 기능 구현하기

    먼저 startPlaying 메서드 구현을 통해 음성메모 파일을 재생시킬 수 있습니다.

    이번엔 AVAudioPlayer 인스턴스 생성을 위해 어떤 파일을 재생할지 contentsOf 파라미터에 해당 받아온 음성메모 파일 url을 주입하여 생성합니다.

    그리고 재생하여 끝을 감지하는 등 추가 기능을 위해 delegate는 self로 지정합니다.

    그리고 play를 통해 재생시키며 isPlaying 값 등 뷰에서 재생 감지를 위한 프로퍼티의 상태값을 변경해줍니다.

     

    다음으로 stop, pause, resume등의 메서드들에서 audioPlayer 인스턴스를 stop, pause, play를 통해 종료, 일시정지, 재개 등의 기능을 구현해줍니다.

     

    마지막으로, AVAudioPlayerDelegate를 통해 오디오 재생의 종료 지점을 딜리게이트로 구현하여 적절한 상태값을 변경하기 위해 audioPlayerDidFinishPlaying 메서드를 오버라이드 구현하여 상태값을 변경해줍니다.

     

    이러면 서비스 기능 객체 구현이 끝났습니다!

     

    실제 이제 뷰에 올려보면서 만들어볼까요?


    음성메모 뷰 구현하기

    import SwiftUI
    
    struct VoiceMemoView: View {
      @StateObject var audioRecorderManager = AudioRecorderManager()
      
      var body: some View {
        VStack(spacing: 20) {
          /// 타이틀 뷰
          RecordingTitleView()
          
          /// 현재 메모 진행중 상태 뷰
          RecordingStatusView(audioRecorderManager: audioRecorderManager)
          
          /// 음성메모 버튼 뷰
          RecordingButtonView(audioRecorderManager: audioRecorderManager)
          
          /// 메모 리스트 뷰
          RecordingListView(audioRecorderManager: audioRecorderManager)
        }
      }
    }
    
    // MARK: - 음성메모 타이틀 뷰
    private struct RecordingTitleView: View {
      fileprivate var body: some View {
        HStack {
          Text("음성 메모")
            .font(.largeTitle)
        }
      }
    }
    
    // MARK: - 음성메모 현재 상태 뷰
    private struct RecordingStatusView: View {
      @ObservedObject private var audioRecorderManager: AudioRecorderManager
      
      fileprivate init(audioRecorderManager: AudioRecorderManager) {
        self.audioRecorderManager = audioRecorderManager
      }
      
      fileprivate var body: some View {
        if audioRecorderManager.isRecording {
          Text("음성메모 중")
            .foregroundColor(.red)
        } else {
          Text("음성메모 준비")
        }
      }
    }
    
    // MARK: - 음성메모 버튼 뷰
    private struct RecordingButtonView: View {
      @ObservedObject private var audioRecorderManager: AudioRecorderManager
      
      fileprivate init(audioRecorderManager: AudioRecorderManager) {
        self.audioRecorderManager = audioRecorderManager
      }
      
      fileprivate var body: some View {
        HStack {
          Button(
            action: {
              audioRecorderManager.isRecording
              ? audioRecorderManager.stopRecording()
              : audioRecorderManager.startRecording()
            }
          ) {
            Text(audioRecorderManager.isRecording ? "음성메모 종료" : "음성메모 시작")
              .foregroundColor(.white)
              .padding()
              .background(audioRecorderManager.isRecording ? Color.red : Color.blue)
              .cornerRadius(10)
          }
        }
      }
    }
    
    // MARK: - 음성메모 리스트 뷰
    private struct RecordingListView: View {
      @ObservedObject private var audioRecorderManager: AudioRecorderManager
      
      fileprivate init(audioRecorderManager: AudioRecorderManager) {
        self.audioRecorderManager = audioRecorderManager
      }
      
      fileprivate var body: some View {
        Text("음성메모 리스트")
          .font(.title)
          .padding()
        
        List {
          ForEach(audioRecorderManager.recordedFiles, id: \.self) { recordedFile in
            Button(
              action: {
                if audioRecorderManager.isPlaying && audioRecorderManager.audioPlayer?.url == recordedFile {
                  audioRecorderManager.isPaused
                  ? audioRecorderManager.resumePlaying()
                  : audioRecorderManager.pausePlaying()
                } else {
                  audioRecorderManager.startPlaying(recordingURL: recordedFile)
                }
              }
            ) {
              Text(recordedFile.lastPathComponent)
                .foregroundColor(
                  audioRecorderManager.isPlaying && audioRecorderManager.audioPlayer?.url == recordedFile
                  ? (audioRecorderManager.isPaused ? .green : .red)
                  : .black
                )
            }
          }
        }
      }
    }

     

    각 VStack으로 영역별 구조체로 분리하여 호출했습니다.

    해당 뷰가 만들어질때 @StateObject 타입의 프로퍼티로 audioRecorderManager 프로퍼티를 생성해줍니다.

    이제 이 프로퍼티가 모든 기능의 창구가 됩니다.

     


    음성메모 타이틀 뷰 구현하기

    간단히 현재 뷰가 음성메모 관련한 뷰라는것을 타이틀로 나타낼 수 있도록 구현합니다.

     


    음성메모 현재 상태 뷰 구현하기

    현재 녹음중인지 녹음 준비중인지 상태를 알 수 있도록 뷰를 구현합니다.

    audioRecorderManager 프로퍼티를 상단에서 받아오고 이 인스턴스의 isRecording 상태값을 감지해 녹음중인지 준비중인지에 따라 텍스트의 문구와 색상을 변경해줍니다.

     


    음성메모 버튼 뷰 구현하기

    실제 음성메모를 동작시키기 위해 버튼을 구성해줍니다.

    녹음중이라면 해당 버튼이 눌리면 녹음을 종료하고 준비중이면 녹음을 시작하도록 action으로 담아줍니다.

    이에 따라 텍스트의 문구와 텍스트 색상, 배경색 또한 적절히 변경해주죠.

     


    음성메모 리스트 뷰 구현하기

    마지막으로 녹음된 음성메모의 항목들을 나타내고 재생시키고 정지 등의 기능을 담은 리스트 뷰를 구현해줍니다.

    audioRecorderManager의 recordedFiles 어레이 프로퍼티를 ForEach 돌려 버튼 셀로 뷰를 표현해줍니다.

    저장 녹음된것이 있다면 잘 나타나겠죠?!

     

    이때 해당 버튼 셀을 누를때 기능을 구분해줍니다.

    isPlaying 중이면서 해당 음성메모 url이 나타난 recoredFile과 일치할때 현재 isPaused로 일시정지 되어 있다면 재개시키고 아니라면 일시정지 시킵니다.

    만약 선택된 버튼 셀의 녹음 파일이 현재 isPlaying된것이 아니라면 해당 녹음파일을 재생시키도록 해줍니다.

    이에 따라 마찬가지로 버튼 셀의 색상을 변경하며 뷰에서 알 수 있도록 구현해줍니다.

     

    이렇게 뷰까지 구성하면 이상없이 음성메모 녹음 및 재생 기능을 SwiftUI로 구현할 수 있습니다!

     

    한번 어떻게 동작하는지 볼까요?


    테스트하기

     

    음성이라 음성자체가 gif로 들어가지 않지만 정상적임을 확인할 수 있어요.

     

    만약 정말 음성기능이 잘 동작하는지 확인해보고 싶다면 아래 제 깃헙 레포에 위 샘플 코드들을 올려두었으니 자유롭게 사용해보셔도 좋습니다 😉

    https://github.com/GREENOVER/playground/tree/main/voiceMemo

Designed by Tistory.