여는 말 : 늦어서 죄송합니다.

주간 달 세뇨 세 번째 글입니다. 매주 목~금에 올라온다는 말과 다르게, 이번에는 무려 일요일에서 월요일로 넘어가는 새벽에 글을 작성하게 되었습니다. 먼저 기대하셨던 분께 사과 말씀 드립니다. 이렇게 된 이유는, 앱의 구조 안에 오늘의 빌런들을 구겨넣는 데 많은 고민을 했기 때문입니다. 그리고 그 고민은 현재진행형입니다… 자세한 것은 아래에서 함께 확인하도록 하겠습니다.

오늘의 빌런 : ShazamKit + MusicKit

배경

사실 오늘 나올 친구들은 빌런이라고 하기는 미안한 녀석들입니다. 어쨌든 우리 앱은 최대한 많은 감각을 이용해 최대한 구체적으로 추억을 되살린다는 모토를 가지고 있는 앱이니까요. 그래서 음악 인식을 통한 기록과, 음악 재생 기능은 필수적입니다. 뮤직킷… 은 모르겠으나, 샤잠킷이 없었다면 확실히 이루지 못했을, 나아가 생각해내지 못했을 아이디어라고 생각합니다. 이 부분은 제가 맡겠다고, 호기롭게 이야기했었습니다. 그도 그럴 것이 제가 이전에 개인 앱 “들어봄”을 만드는 과정에서 샤잠킷을 사용해 봤거든요. 그 때 겪었던 시행착오가 있으니까, 한결 어렵지 않게 할 수 있겠지? 라고 생각했습니다. 누구나 맞기 전에는 그럴 듯한 계획이 있는 법입니다…

애플 퍼스트 파티 SDK는 정말 대단하다

왜 대단할까요? 사견입니다만, 쓰기 편해서 그렇다고 생각합니다. 이게 돼? 싶은 것까지도 퍼스트 파티 라이브러리를 잘 활용하면 되는 경우가 꽤 있습니다. 샤잠킷의 경우도 엄청나게 어렵지는 않습니다. 앞에서 사망 플래그를 꽂긴 했지만, 이전의 사용 경험을 통해 의외로 쉽게 할 수 있겠다는 저의 짐작은 그래도 이번에는 절반 정도 맞아 떨어졌습니다.

ShazamKit이 음원을 찾는 과정

샤잠킷은 음원을 정해진 주기마다 분석해 Signature라는 별도의 자료형으로 만들고, 이 시그니처가 일치하는 음악을 데이터베이스에서 찾는 방식으로 음원을 검색합니다. 또한 나만의 커스텀 시그니처 데이터베이스를 만들고, 그 곳에 있는 음원을 찾는 것도 가능하죠.

음원 데이터 관련된 용어가 섞이다 보니 굉장히 알쏭달쏭합니다. 코드로 볼까요?

//
//  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."
        }
    }
}

MusicKit이 음원을 재생하는 과정

뮤직킷은, 주어진 “검색어” 를 통해 음원 데이터를 가져 오고, 이를 다루는 방식으로 이루어져 있습니다.