AVFoundation을 이용한 음성 녹음

DAMIN KIM
daily-monster
Published in
19 min readApr 17, 2024

안녕하세요 수요괴물 damin 입니다 😈

오늘은 만들고 있는 앱에 녹음 기능이 들어가서 AVFoundation을 이용해 녹음 기능을 구현했는데 이에 대해 정리 해보고자 합니다.

AVFoundation

AVFoundation은 오디오나 비디오 같은 미디어 에셋을 기기에서 다룰때 사용됩니다.

Recording

우선 저는 AaudioRecordManager를 만들어 주고 거기에 Audio 관련 프로퍼티와 메서드를 만들어 정리해주었습니다.

import Foundation
import AVFoundation

class AudioRecorderManager : NSObject, ObservableObject{

var audioRecorder : AVAudioRecorder!

...
}

AVFoundation에는 AVAudioRecorder라는 친구가 있는데요 이 친구를 이용해 녹음을 하면됩니다.

Start Recording

func startRecording() {
// AVAudioSession의 싱글턴 인스턴스를 가져옵니다.
let session = AVAudioSession.sharedInstance()
do {
// 오디오 세션의 카테고리를 녹음 및 재생으로 설정합니다.
try session.setCategory(.playAndRecord, mode: .default)
// 오디오 세션을 활성화합니다.
try session.setActive(true)
} catch {
// 세션 설정에 실패했을 경우 에러 메시지를 출력합니다.
print("Failed to set up recording session")
}

// 녹음 파일 저장을 위한 고유한 파일 이름을 생성합니다.
let fileName = UUID().uuidString + ".m4a"
// 문서 디렉터리의 경로를 가져옵니다.
let documentPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
// 파일의 전체 경로를 생성합니다.
let fileURL = documentPath.appendingPathComponent(fileName)

// 녹음 설정을 정의합니다. AAC 포맷, 샘플 레이트 12000 Hz, 단일 채널, 높은 오디오 품질로 설정합니다.
let settings = [
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
AVSampleRateKey: 12000,
AVNumberOfChannelsKey: 1,
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
]

do {
// AVAudioRecorder 인스턴스를 생성하고 초기화합니다. 위에서 정의된 설정과 경로를 사용합니다.
audioRecorder = try AVAudioRecorder(url: fileURL, settings: settings)
// 미터링을 활성화하여 녹음 중 오디오 레벨을 측정할 수 있게 합니다.
audioRecorder.isMeteringEnabled = true
// 녹음 준비를 합니다.
audioRecorder.prepareToRecord()
// 녹음을 시작합니다.
audioRecorder.record()

// 녹음 시간을 계산하기 위한 타이머를 시작합니다. 1초마다 콜백이 실행됩니다.
countTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true, block: { (value) in
self.countSec += 1
// 녹음 시간을 분과 초로 변환하여 문자열로 저장합니다.
self.timerString = self.covertSecToMinAndHour(seconds: self.countSec)
})

} catch {
// 녹음 시작에 실패했을 경우 에러 메시지를 출력합니다.
print("Recording failed to start")
}
}

저의 경우에는 녹음 시간을 보여주고 과 녹음되고 있는 오디오 상태 바를 만들어서 코드가 필수적인 것보다 좀 추가가 되었는데요. 위 주석을 보면 알 수 있듯이 AVSession의 싱글톤 인스턴스를 가져와서 활성화를 시켜줍니다. 이때 세션의 카테고리를 여러개 선택할 수 있는데요 저는 녹음과 재생을 하려고 했어서 .playAndRecord로 설정해주었습니다.

그리고 녹음 파일의 제목과 저장을 위한 경로 URL을 정의하고, 녹음에 대한 설정을 만들어주었습니다.

이때 녹음 파일을 FileManager를 이용해 저장해줬는데 documentDirectory에 “document/…/파일제목” URL 경로로 저장되기 파일제목이 겹치면 저장이 제대로 안되기 때문에 UUID로 오디오마다 고유한 경로로 저장이 되도록 하였습니다.

그리고 설정값과 경로 URL을 적용한 audioRecorder를 생성하여 녹음을 진행합니다.
녹음을 할대 audioRecorder.record()를 호출해 녹음을 하는데 그전에 prepareRecord()를 호출합니다. 그 이유는 녹음할 파일을 입력한 URL에 미리 생성하고 오디오 녹음을 준비함으로써 실제 녹음시 지연을 줄일 수 있습니다. 사실 record() 메서드 실행시 시스템에서 암시적으로 prepareRecord()를 실행 함으로써 이런 준비를 해주지만 명시적으로 따로 실행함으로써 녹음 지연을 줄일 수 있고, 오류가 생겼을때 대응하기도 용이하다고 합니다.

Recording 음량 바 만들기

녹음관련 필수 기능은 아니지만 audioRecorder.isMeteringEnabled 를 true로 설정해주면 녹음 되는 음량을 알 수 있습니다.

 func updateAudioLevels() {
audioRecorder?.updateMeters()

// 실제 오디오 레벨을 얻습니다.
if let averagePower = audioRecorder?.averagePower(forChannel: 0) {
let baseLevel = CGFloat(max(0.2, pow(10.0, averagePower / 20))) // 데시벨 값을 선형 스케일로 변환

audioLevels = audioLevels.enumerated().map { index, previousLevel in
let randomVariance = CGFloat.random(in: 0.5...1.5) // 무작위 변형 범위 확장
let targetLevel = baseLevel * randomVariance
return previousLevel * 0.5 + targetLevel * 0.8 // 더 빠른 변화를 위해 비율 조정
}
}
}

위 코드에서 처럼 audioRecorder의 averagePower를 이용해 녹음되고 있는 음량을 가지고 오고 이 값을 조정해서 뷰에 전달해 오디오 바를 만들어 줄 수 있습니다.

녹음하는 소리에 따라 크기를 조정할 수 있습니다.

Stop Recording

녹음을 멈출때는 사실 별게 없습니다. audioRecorder의 stop() 을 실행시켜주면 됩니다. 녹음시 음량바나 타이머처럼 다른 요소를 추가하셨다면 녹음종료시 그런 값들을 초기화 시켜주면 되겠죠?

Save Recording

녹음을 시작했을때 UUID를 이용해 고유한 경로로 파일을 저장해줬는데요. 저 같은 경우에는 녹음 종료시에 파일의 제목을 작성하는 화면을 띄워줬는데요. 그래서 UUID로 저장한 경로 대신에 파일 제목으로 설정한 이름으로 경로를 지정해 저장을 다시 해줘야했습니다.

func saveRecording(with title: String) {
// 녹음된 파일의 임시 URL을 확인하고, 없다면 함수를 종료합니다.
guard let tempURL = tempRecordingURL else { return }

// 문서 디렉터리의 기본 경로를 가져옵니다.
let path = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]

// 제목이 제공되지 않은 경우 현재 날짜를 기반으로 파일 이름을 생성합니다.
let newFileName = title.isEmpty ? Date().toString(dateFormat: "yyyy.MM.dd '일기'") : title
// 새 파일 URL을 생성합니다.
var newFileURL = path.appendingPathComponent("\(newFileName).m4a")

// 파일 이름 중복을 처리하기 위한 로직입니다.
var counter = 1
// 해당 경로에 파일이 이미 존재하는 경우, 새로운 이름을 생성합니다.
while FileManager.default.fileExists(atPath: newFileURL.path) {
let duplicatedFileName = "\(newFileName) (\(counter))"
newFileURL = path.appendingPathComponent("\(duplicatedFileName).m4a")
counter += 1
}

do {
// 임시 URL에서 최종 URL로 파일을 이동시킵니다.
try FileManager.default.moveItem(at: tempURL, to: newFileURL)
// 파일 이동 후 필요한 후속 처리를 수행할 수 있습니다. (예: 레코딩 목록 업데이트)
} catch {
// 파일 이동에 실패한 경우 에러 메시지를 출력합니다.
print("Error saving recording: \(error)")
}
}

그래서 위 코드처럼 저장 메서드를 따로 만들었는데, 여기에 아까 recording의 디렉토리로 설정한 UUID 경로를 전역변수 tempRecordingURL에 저장을 해주고 이 메서드에서 그 URL을 가져왔습니다. 그리고 입력받은 title을 이용해 저장하고자 하는 새로운 URL을 생성하고 while 문을 통해 FileManager.default.fileExists(atPath: newFileURL.path)로 해당 경로에 파일이 있는지 확인합니다. (경로이면서 동시에 중복된 이름이 있는지를 확인합니다.)
만약 중복 이름이 있다면 count를 추가해 고유한 URL 경로를 만들어주고
FileManager.default.moveItem(at: tempURL, to: newFileURL) 을 통해 UUID 로 만든 경로에 있는 파일을 저장하고자 하는 URL로 이동시켜 줍니다.(복제가 아니라 이전 경로에는 파일이 없어집니다.)

Fetch

오디오를 녹음하고 저장했으니 불러올 수도 있어야겠죠?
저장했던 url 경로를 통해 기록했던 오디오들을 불러와보겠습니다.

struct Recording : Equatable {
let fileURL : URL
let createdAtDate: Date
let createdAtString : String
var isPlaying : Bool
}

class AudioRecorderManager : NSObject, ObservableObject{
@Published var recordingsList = [Recording]()
}

fileURL등을 가지는 Recording 모델을 만들고 AudioRecorderManager에 변수로 Recording 배열을 만들어주고


func fetchAllRecording(){
// 문서 디렉터리의 URL을 가져옵니다.
let path = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]

do {
// 지정된 경로의 디렉터리 내 모든 파일을 가져옵니다.
let directoryContents = try FileManager.default.contentsOfDirectory(at: path, includingPropertiesForKeys: nil)
// 각 파일을 순회하면서 파일 정보를 기반으로 레코딩 목록을 만듭니다.
for url in directoryContents {
// 파일 생성 날짜를 가져옵니다.
let createdAtDate = getFileDate(for: url)
// 파일 생성 날짜를 문자열로 변환합니다.
let createdAtString = createdAtDate.toString(dateFormat: "yyyy.MM.dd")
// 레코딩 목록에 새로운 레코딩 객체를 추가합니다.
recordingsList.append(Recording(fileURL: url, createdAtDate: createdAtDate, createdAtString: createdAtString, isPlaying: false))
}
} catch {
print("Error loading recordings: \(error)")
}

// 레코딩 목록을 생성 날짜 기준 내림차순으로 정렬합니다.
recordingsList.sort(by: { $0.createdAtDate.compare($1.createdAtDate) == .orderedDescending})
}

위 코드처럼 fetch 메서드를 통해 Recording 배열에 저장한 파일들을 담아 줍니다.
FileManager를 통해 오디오를 저장했던 documents 디렉토리 내에 있는 모든 파일들을 가져와서 각 파일의 경로를 담은 Recording 만들었던 배열에 추가해줍니다.

여기서 getFileDate라는 메서드로 저장된 파일의 생성일자를 가져오는데요

func getFileDate(for file: URL) -> Date {
if let attributes = try? FileManager.default.attributesOfItem(atPath: file.path) as [FileAttributeKey: Any],
let creationDate = attributes[FileAttributeKey.creationDate] as? Date {
return creationDate
} else {
return Date()
}
}

getFileDate에서는 FileManager의 attributesOfItem 메서드를 통해 인자로 받은 url.path를 이용해 해당 경로 파일의 attributes를 가져올 수 있고 여기서 FileAttributeKey.creationDate 키를 이용해 생성일자 값인 creationDate를 가져올 수 있습니다.

Play

그럼 이제 불러온 영상들을 재생해 봅시다. 재생은 record 보다 더 간단합니다.
아까 만들어준 Recording 배열을 통해 각 Recording 파일의 url을 알 수 있으니 이를 통해 recording 파일을 재생합니다.

import Foundation
import AVFoundation

class AudioRecorderManager : NSObject, ObservableObject{

var audioRecorder : AVAudioRecorder!
// AVAudioPlayer 추가
var audioPlayer : AVAudioPlayer!

...
}

우선 AudioRecorderManager 클래스에 audioPlayer를 추가하고

Start Playing

func startPlaying(url : URL) {

// 오디오 세션 인스턴스를 가져옵니다.
let playSession = AVAudioSession.sharedInstance()

// 오디오 출력을 스피커로 시도합니다.
do {
try playSession.overrideOutputAudioPort(AVAudioSession.PortOverride.speaker)
} catch {
// 스피커 설정 실패 시 에러 메시지를 출력합니다.
print("Playing failed in Device")
}

// 오디오 플레이어를 초기화하고, 재생을 시작합니다.
do {
// URL에서 오디오 플레이어 인스턴스를 생성합니다.
audioPlayer = try AVAudioPlayer(contentsOf: url)
// 오디오를 재생할 준비를 합니다.
audioPlayer.prepareToPlay()
// 오디오 재생을 시작합니다.
audioPlayer.play()

// 재생 중인 파일을 레코딩 목록에서 찾아 상태를 업데이트합니다.
for i in 0..<recordingsList.count {
if recordingsList[i].fileURL == url {
recordingsList[i].isPlaying = true
}
}

} catch {
// 오디오 재생 실패 시 에러 메시지를 출력합니다.
print("Playing Failed")
}
}

startPlaying 이라는 메서들 만들어 재생을 위한 코드를 실행시킵니다.
위 코드를 보시면 recording 배열을 통해 저장한 url을 메서드로 넘겨주고
오디오 세션을 record 할때 처럼 가져와서 playSession.overrideOutputAudioPort(AVAudioSession.PortOverride.speaker) 로 기기의 스피커로 출력을 시도합니다.
그리고 audioPlayer를 통해 record할때처럼 prepareToPlay()로 재생 지연 방지를 위해 준비를 시키고 play()통해 오디오를 재생합니다.
(이때 Recording 모델의 isPlaying 변수를 통해 뷰에서 재생, 정지 버튼의 상태를 관리하면 되겠죠?)

Stop Playing

 func stopPlaying(url : URL) {

audioPlayer.stop()

for i in 0..<recordingsList.count {
if recordingsList[i].fileURL == url {
recordingsList[i].isPlaying = false
}
}
}

재생을 멈추는 부분도 url을 받아서 audioPlayer에서 stop()을 통해 정지해주면 됩니다.

Delete Recording

마지막으로 저장한 파일을 삭제하는 것을 해보겠습니다.

 func deleteRecording(offsets : IndexSet) {

for index in offsets {
let recordingURL = recordingsList[index].fileURL
if recordingsList[index].isPlaying == true{
stopPlaying(url: recordingsList[index].fileURL)
}
do {
// 파일 시스템에서 파일 삭제
try FileManager.default.removeItem(at: recordingURL)
} catch {
print("Can't delete with \(error)")
}
}

// 녹음 목록에서 해당 인덱스 삭제
recordingsList.remove(atOffsets: offsets)
}

이것도 간단하죠? recordingsList를 통해서 관리하고 있는 Recording 데이터들을 deleteRecording메서드 인자로 index를 받아서 삭제하려는 Recording의 url을 가지고FileManager의 removeItem으로 해당 경로로 파일시스템에서 파일을 삭제해주고, recordingsList에서도 삭제해주면 삭제가 됩니다.

오늘은 앱에서 녹음 기능에 대한 CRUD를 구현하는 방법을 알아봤는데요, 오디오 데이터를 FileManager를 통해 간단히 데이터에 대한 CRUD를 구현 할 수 있었습니다.

오디오와 같은 미디어 데이터를 다루는 애플리케이션에서 FileManager의 사용은 매우 효과적입니다. 다른 데이터 저장 방법들도 있지만, 파일 시스템 기반의 접근 방식은 특정 유형의 애플리케이션에 아래와 같은 이점을 제공합니다.

1. 확장성: FileManager를 사용하는 애플리케이션은 나중에 파일 관리 전략을 변경하거나 확장할 필요가 있을 때 유연하게 대응할 수 있다. 예를 들어, 로컬 파일 시스템에서 클라우드 기반 스토리지로의 이전이 비교적 간단하게 처리될 수 있다.

2. 직접적인 파일 접근 및 관리: FileManager를 사용하면 iOS 파일 시스템에 직접적으로 접근하여 파일을 생성, 읽기, 수정, 삭제 등을 할 수 있으며 이는 개발자가 파일 시스템의 구조를 이해하고 있을 때 파일을 더욱 효과적으로 관리할 수 있다

3. 데이터 포맷 유연성: 오디오 파일과 같은 미디어 데이터는 자체 포맷과 메타데이터를 갖고 있고, FileManager를 사용하면 이러한 파일들을 그들의 원래 포맷으로 쉽게 저장하고, 필요에 따라 추가 정보(예: 파일 생성 날짜, 파일 이름 등)를 관리할 수 있다.

4. 성능: 대용량 파일을 처리할 때, FileManager는 파일을 효과적으로 처리할 수 있는 여러 메서드를 제공하며 스트리밍, 비동기 읽기/쓰기와 같은 기능을 이용해 성능을 최적화할 수 있다.

5. 보안: iOS는 각 애플리케이션에 대해 샌드박스 환경을 제공하고, FileManager를 사용하면 이 샌드박스 내에서 애플리케이션 데이터를 안전하게 관리할 수 있으며, 사용자의 데이터 보호 정책을 준수할 수 있다.

6. 사용자 데이터 백업과 복원 용이성: FileManager를 통해 관리하는 파일은 iCloud 및 iTunes를 통한 백업과 복원이 용이하며 이는 사용자가 기기를 변경하거나 데이터를 복원해야 하는 경우 유용하다.

--

--