주간 달 세뇨 세 번째 글입니다. 매주 목~금에 올라온다는 말과 다르게, 이번에는 무려 일요일에서 월요일로 넘어가는 새벽에 글을 작성하게 되었습니다. 먼저 기대하셨던 분께 사과 말씀 드립니다. 이렇게 된 이유는, 앱의 구조 안에 오늘의 빌런들을 구겨넣는 데 많은 고민을 했기 때문입니다. 그리고 그 고민은 현재진행형입니다… 자세한 것은 아래에서 함께 확인하도록 하겠습니다.
사실 오늘 나올 친구들은 빌런이라고 하기는 미안한 녀석들입니다. 어쨌든 우리 앱은 최대한 많은 감각을 이용해 최대한 구체적으로 추억을 되살린다는 모토를 가지고 있는 앱이니까요. 그래서 음악 인식을 통한 기록과, 음악 재생 기능은 필수적입니다. 뮤직킷… 은 모르겠으나, 샤잠킷이 없었다면 확실히 이루지 못했을, 나아가 생각해내지 못했을 아이디어라고 생각합니다. 이 부분은 제가 맡겠다고, 호기롭게 이야기했었습니다. 그도 그럴 것이 제가 이전에 개인 앱 “들어봄”을 만드는 과정에서 샤잠킷을 사용해 봤거든요. 그 때 겪었던 시행착오가 있으니까, 한결 어렵지 않게 할 수 있겠지? 라고 생각했습니다. 누구나 맞기 전에는 그럴 듯한 계획이 있는 법입니다…
왜 대단할까요? 사견입니다만, 쓰기 편해서 그렇다고 생각합니다. 이게 돼? 싶은 것까지도 퍼스트 파티 라이브러리를 잘 활용하면 되는 경우가 꽤 있습니다. 샤잠킷의 경우도 엄청나게 어렵지는 않습니다. 앞에서 사망 플래그를 꽂긴 했지만, 이전의 사용 경험을 통해 의외로 쉽게 할 수 있겠다는 저의 짐작은 그래도 이번에는 절반 정도 맞아 떨어졌습니다.
샤잠킷은 음원을 정해진 주기마다 분석해 Signature라는 별도의 자료형으로 만들고, 이 시그니처가 일치하는 음악을 데이터베이스에서 찾는 방식으로 음원을 검색합니다. 또한 나만의 커스텀 시그니처 데이터베이스를 만들고, 그 곳에 있는 음원을 찾는 것도 가능하죠.
AVAudioSession
싱글턴 객체와 AVAudioEngine
, AVAudioNodeBus
를 활용합니다.
AVAudioSession
은 하드웨어 마이크와 앱 사이의 가교 역할을 해 주는 녀석입니다. 이를 통해 마이크 사용 권한을 받고, AVAudioEngine
을 통해 실제 오디오 관련 작업을 수행하는 형태입니다.outputFormat
을 통해 설정합니다.installTap
메서드를 통해 어느 버스에서, 얼만큼의 버퍼 사이즈로, 어떤 포맷을 사용할지 설정합니다. 이 과정에서 버퍼 사이즈를, 샤잠킷에 맞는 사이즈로 등록해주어야 합니다. 샤잠킷은 3~12초 사이의 음원에 대해서 검색을 할 수 있습니다. 이 때 버퍼 사이즈를 지나치게 높게 잡으면, 찾고자 하는 파일의 용량이 너무 크다던지 하는 이슈로 인해 검색이 불가능할 수 있습니다. 저는 2048로 설정해 주었습니다. 이후 이 메서드의 completion handler를 통해 녹음된 음원의 후처리를 해 줍니다.
matchStreamingBuffer
메서드를 활용합니다.delegate
메서드를 통해 검색이 성공했을 때와 실패했을 때의 처리를 해 줍니다. 레코딩은 어떤 경우가 됐든 일단 멈춰야 합니다.음원 데이터 관련된 용어가 섞이다 보니 굉장히 알쏭달쏭합니다. 코드로 볼까요?
//
// ShazamSession.swift
// ShazamMusic
//
// Created by Gordon Choi on 2022/11/29.
//
import ShazamKit
import RxSwift
typealias ShazamSearchResult = Result<ShazamSong, ShazamError>
final class ShazamSession: NSObject {
var result = PublishSubject<ShazamSearchResult>()
var isSearching = BehaviorSubject(value: false)
private let disposeBag = DisposeBag()
private lazy var audioSession: AVAudioSession = .sharedInstance()
private lazy var session: SHSession = .init()
private lazy var audioEngine: AVAudioEngine = .init()
private lazy var inputNode = audioEngine.inputNode
private lazy var bus: AVAudioNodeBus = 0
override init() {
super.init()
session.delegate = self
bindRecord()
}
func toggleSearch() {
guard let currentState = try? isSearching.value() else { return }
switch currentState {
case true:
isSearching.onNext(false)
case false:
isSearching.onNext(true)
}
}
func bindRecord() {
isSearching
.subscribe(onNext: {
switch $0 {
case true:
self.start()
case false:
self.stop()
}
})
.disposed(by: disposeBag)
}
func start() {
switch audioSession.recordPermission {
case .granted:
record()
case .denied:
isSearching.onNext(false)
result.onNext(.failure(.recordDenied))
case .undetermined:
audioSession.requestRecordPermission { granted in
if granted {
self.record()
} else {
self.isSearching.onNext(false)
self.result.onNext(.failure(.recordDenied))
}
}
@unknown default:
isSearching.onNext(false)
result.onNext(.failure(.unknown))
}
}
func stop() {
audioEngine.stop()
inputNode.removeTap(onBus: bus)
}
private func record() {
do {
let format = inputNode.outputFormat(forBus: bus)
inputNode.installTap(onBus: bus, bufferSize: 2048, format: format) { buffer, time in
self.session.matchStreamingBuffer(buffer, at: time)
}
audioEngine.prepare()
try audioEngine.start()
} catch {
result.onNext(.failure(.unknown))
}
}
}
extension ShazamSession: SHSessionDelegate {
func session(_ session: SHSession, didFind match: SHMatch) {
guard let mediaItem = match.mediaItems.first,
let shazamSong = ShazamSong(mediaItem: mediaItem) else {
isSearching.onNext(false)
result.onNext(.failure(.matchFailed))
return
}
isSearching.onNext(false)
result.onNext(.success(shazamSong))
}
func session(_ session: SHSession, didNotFindMatchFor signature: SHSignature, error: Error?) {
isSearching.onNext(false)
result.onNext(.failure(.matchFailed))
}
}
struct ShazamSong: Equatable {
let isrc: String?
let title: String?
let artist: String?
let album: String?
let imageURL: URL?
init?(mediaItem: SHMatchedMediaItem) {
guard let isrc = mediaItem.isrc,
let title = mediaItem.title,
let artist = mediaItem.artist,
let album = mediaItem.album
else { return nil }
self.isrc = isrc
self.title = title
self.artist = artist
self.album = album
self.imageURL = mediaItem.artworkURL
}
}
extension SHMediaItemProperty {
static let album = SHMediaItemProperty("sh_albumName")
}
extension SHMediaItem {
var album: String? {
return self[.album] as? String
}
}
enum ShazamError: Error, LocalizedError {
case recordDenied
case unknown
case matchFailed
var errorDescription: String {
switch self {
case .recordDenied:
return "Record permission is denied. Please enable it in Settings."
case .matchFailed:
return "No song found or internet connection is bad."
case .unknown:
return "Unknown error occured."
}
}
}
뮤직킷은, 주어진 “검색어” 를 통해 음원 데이터를 가져 오고, 이를 다루는 방식으로 이루어져 있습니다.
String
검색어를 입력하고 조건을 설정해 맞는 노래나 앨범, 아티스트 등을 불러오는 메서드도 있고, isrc를 입력해 일치하는 노래를 불러 오는 방법도 있습니다.SystemMusicPlayer
와 ApplicationMusicPlayer
의 두 가지가 있습니다.
play
, pause
, stop
등의 메서드가 있습니다.async/await
을 사용할 수 있게 되었습니다!