🎀 ShazamKit

μ˜€λ””μ˜€ 인식 및 μŒμ•… λ§€μΉ­ ν”„λ ˆμž„μ›Œν¬

iOS 15+μ˜€λ””μ˜€ 인식

✨ ShazamKitμ΄λž€?

ShazamKit은 Shazam의 μ˜€λ””μ˜€ 인식 κΈ°μˆ μ„ 앱에 톡합할 수 μžˆλŠ” ν”„λ ˆμž„μ›Œν¬μž…λ‹ˆλ‹€. μž¬μƒ 쀑인 μŒμ•…μ„ μ‹€μ‹œκ°„μœΌλ‘œ μΈμ‹ν•˜κ³ , μ»€μŠ€ν…€ μ˜€λ””μ˜€ μΉ΄νƒˆλ‘œκ·Έλ₯Ό λ§Œλ“€μ–΄ μ•± λ‚΄ μ˜€λ””μ˜€ μ½˜ν…μΈ λ₯Ό λ§€μΉ­ν•˜λ©°, Shazam의 λ°©λŒ€ν•œ μŒμ•… λ°μ΄ν„°λ² μ΄μŠ€μ— μ ‘κ·Όν•  수 μžˆμŠ΅λ‹ˆλ‹€. μŒμ•… μ•±, κ²Œμž„, ꡐ윑 μ•± λ“±μ—μ„œ μ˜€λ””μ˜€ 기반 κ²½ν—˜μ„ μ œκ³΅ν•  λ•Œ μœ μš©ν•©λ‹ˆλ‹€.

πŸ’‘ 핡심 κΈ°λŠ₯: μŒμ•… 인식 Β· μ»€μŠ€ν…€ μΉ΄νƒˆλ‘œκ·Έ Β· μ‹€μ‹œκ°„ λ§€μΉ­ Β· Shazam 라이브러리 μ ‘κ·Ό Β· μ˜€λ””μ˜€ μ‹œκ·Έλ‹ˆμ²˜ 생성 Β· μ˜€ν”„λΌμΈ 인식

🎡 1. κΈ°λ³Έ μŒμ•… 인식

SHSession을 μ‚¬μš©ν•˜μ—¬ Shazam μΉ΄νƒˆλ‘œκ·Έμ—μ„œ μŒμ•…μ„ μΈμ‹ν•©λ‹ˆλ‹€.

ShazamManager.swift β€” κΈ°λ³Έ μŒμ•… 인식
import ShazamKit
import AVFoundation
import SwiftUI

@Observable
class ShazamManager: NSObject, SHSessionDelegate {
    private var session: SHSession?
    private var audioEngine: AVAudioEngine?

    var matchedMedia: SHMatchedMediaItem?
    var isListening = false
    var errorMessage: String?

    override init() {
        super.init()
        setupSession()
    }

    // μ„Έμ…˜ μ„€μ •
    private func setupSession() {
        // Shazam μΉ΄νƒˆλ‘œκ·Έ μ„Έμ…˜ 생성
        session = SHSession()
        session?.delegate = self
        print("βœ… Shazam μ„Έμ…˜ 생성됨")
    }

    // μŒμ•… 인식 μ‹œμž‘
    func startListening() {
        guard !isListening else { return }

        do {
            // μ˜€λ””μ˜€ μ„Έμ…˜ μ„€μ •
            let audioSession = AVAudioSession.sharedInstance()
            try audioSession.setCategory(.record)
            try audioSession.setActive(true)

            // μ˜€λ””μ˜€ μ—”μ§„ μ„€μ •
            audioEngine = AVAudioEngine()
            guard let audioEngine = audioEngine else { return }

            let inputNode = audioEngine.inputNode
            let bus = 0
            let bufferSize: AVAudioFrameCount = 2048

            // μ˜€λ””μ˜€ νƒ­ μ„€μΉ˜
            inputNode.installTap(onBus: bus, bufferSize: bufferSize, format: inputNode.outputFormat(forBus: bus)) { [weak self] buffer, time in
                self?.session?.matchStreamingBuffer(buffer, at: time)
            }

            // μ˜€λ””μ˜€ μ—”μ§„ μ‹œμž‘
            try audioEngine.start()

            isListening = true
            errorMessage = nil
            print("🎀 μŒμ•… 인식 μ‹œμž‘")
        } catch {
            errorMessage = "μ˜€λ””μ˜€ μ—”μ§„ μ‹œμž‘ μ‹€νŒ¨: \(error.localizedDescription)"
            print("❌ \(errorMessage ?? "")")
        }
    }

    // μŒμ•… 인식 쀑지
    func stopListening() {
        guard isListening else { return }

        audioEngine?.stop()
        audioEngine?.inputNode.removeTap(onBus: 0)
        audioEngine = nil

        isListening = false
        print("⏹️ μŒμ•… 인식 쀑지")
    }

    // MARK: - SHSessionDelegate

    func session(_ session: SHSession, didFind match: SHMatch) {
        guard let mediaItem = match.mediaItems.first else { return }

        Task { @MainActor in
            matchedMedia = mediaItem
            print("🎡 발견: \(mediaItem.title ?? "Unknown") - \(mediaItem.artist ?? "Unknown")")
        }
    }

    func session(_ session: SHSession, didNotFindMatchFor signature: SHSignature, error: Error?) {
        Task { @MainActor in
            errorMessage = "μŒμ•…μ„ 찾을 수 μ—†μŠ΅λ‹ˆλ‹€"
            print("❌ λ§€μΉ­ μ‹€νŒ¨")
        }
    }
}

πŸ“¦ 2. μ˜€λ””μ˜€ 파일 인식

μ €μž₯된 μ˜€λ””μ˜€ νŒŒμΌμ—μ„œ μŒμ•…μ„ μΈμ‹ν•©λ‹ˆλ‹€.

AudioFileRecognition.swift β€” 파일 인식
import ShazamKit
import AVFoundation

extension ShazamManager {
    // μ˜€λ””μ˜€ νŒŒμΌμ—μ„œ μ‹œκ·Έλ‹ˆμ²˜ 생성
    func generateSignature(from url: URL) async throws -> SHSignature {
        // μ˜€λ””μ˜€ 파일 읽기
        let file = try AVAudioFile(forReading: url)

        // μ‹œκ·Έλ‹ˆμ²˜ 생성기
        let generator = SHSignatureGenerator()

        // 버퍼 읽기 및 μ‹œκ·Έλ‹ˆμ²˜ 생성
        let bufferSize: AVAudioFrameCount = 2048
        guard let buffer = AVAudioPCMBuffer(
            pcmFormat: file.processingFormat,
            frameCapacity: bufferSize
        ) else {
            throw NSError(domain: "AudioError", code: -1)
        }

        while file.framePosition < file.length {
            try file.read(into: buffer)
            try generator.append(buffer, at: nil)
        }

        // μ‹œκ·Έλ‹ˆμ²˜ λ°˜ν™˜
        return generator.signature()
    }

    // νŒŒμΌμ—μ„œ μŒμ•… 인식
    func recognizeAudioFile(at url: URL) async {
        do {
            // μ‹œκ·Έλ‹ˆμ²˜ 생성
            let signature = try await generateSignature(from: url)

            // Shazam μΉ΄νƒˆλ‘œκ·Έμ—μ„œ λ§€μΉ­
            let match = try await session?.match(signature)

            if let mediaItem = match?.mediaItems.first {
                matchedMedia = mediaItem
                print("🎡 파일 인식 성곡: \(mediaItem.title ?? "Unknown")")
            }
        } catch {
            errorMessage = "파일 인식 μ‹€νŒ¨: \(error.localizedDescription)"
            print("❌ \(errorMessage ?? "")")
        }
    }
}

πŸ—‚οΈ 3. μ»€μŠ€ν…€ μΉ΄νƒˆλ‘œκ·Έ

자체 μ˜€λ””μ˜€ μΉ΄νƒˆλ‘œκ·Έλ₯Ό λ§Œλ“€μ–΄ μ•± λ‚΄ μ½˜ν…μΈ λ₯Ό μΈμ‹ν•©λ‹ˆλ‹€.

CustomCatalog.swift β€” μ»€μŠ€ν…€ μΉ΄νƒˆλ‘œκ·Έ
import ShazamKit
import AVFoundation

@Observable
class CustomCatalogManager: NSObject, SHSessionDelegate {
    private var session: SHSession?
    private var catalog: SHCustomCatalog?

    var matchedReference: SHMediaItem?
    var isListening = false

    // μ»€μŠ€ν…€ μΉ΄νƒˆλ‘œκ·Έ 생성
    func createCustomCatalog(from audioFiles: [URL]) async throws {
        var referenceSignatures: [SHSignature] = []

        // 각 μ˜€λ””μ˜€ νŒŒμΌμ—μ„œ μ‹œκ·Έλ‹ˆμ²˜ 생성
        for (index, fileURL) in audioFiles.enumerated() {
            let file = try AVAudioFile(forReading: fileURL)
            let generator = SHSignatureGenerator()

            let bufferSize: AVAudioFrameCount = 2048
            guard let buffer = AVAudioPCMBuffer(
                pcmFormat: file.processingFormat,
                frameCapacity: bufferSize
            ) else { continue }

            while file.framePosition < file.length {
                try file.read(into: buffer)
                try generator.append(buffer, at: nil)
            }

            let signature = generator.signature()
            referenceSignatures.append(signature)

            print("βœ… μ‹œκ·Έλ‹ˆμ²˜ 생성: \(fileURL.lastPathComponent)")
        }

        // λ―Έλ””μ–΄ μ•„μ΄ν…œ 생성
        var mediaItems: [SHMediaItem] = []
        for (index, signature) in referenceSignatures.enumerated() {
            let mediaItem = SHMediaItem(
                properties: [
                    .title: "Track \(index + 1)",
                    .artist: "Custom Artist"
                ]
            )
            mediaItems.append(mediaItem)
        }

        // μ»€μŠ€ν…€ μΉ΄νƒˆλ‘œκ·Έ 생성
        catalog = SHCustomCatalog()

        for (signature, mediaItem) in zip(referenceSignatures, mediaItems) {
            try await catalog?.addReferenceSignature(signature, representing: [mediaItem])
        }

        // μ»€μŠ€ν…€ μΉ΄νƒˆλ‘œκ·Έ μ„Έμ…˜ 생성
        if let catalog = catalog {
            session = SHSession(catalog: catalog)
            session?.delegate = self
            print("βœ… μ»€μŠ€ν…€ μΉ΄νƒˆλ‘œκ·Έ 생성 μ™„λ£Œ")
        }
    }

    // μ»€μŠ€ν…€ μΉ΄νƒˆλ‘œκ·Έμ—μ„œ λ§€μΉ­
    func matchAgainstCustomCatalog(signature: SHSignature) async {
        guard let session = session else { return }

        do {
            let match = try await session.match(signature)
            if let mediaItem = match.mediaItems.first {
                matchedReference = mediaItem
                print("🎡 μ»€μŠ€ν…€ μΉ΄νƒˆλ‘œκ·Έ λ§€μΉ­: \(mediaItem.title ?? "Unknown")")
            }
        } catch {
            print("❌ μ»€μŠ€ν…€ λ§€μΉ­ μ‹€νŒ¨: \(error)")
        }
    }

    // MARK: - SHSessionDelegate

    func session(_ session: SHSession, didFind match: SHMatch) {
        guard let mediaItem = match.mediaItems.first else { return }

        Task { @MainActor in
            matchedReference = mediaItem
            print("🎡 μ»€μŠ€ν…€ λ§€μΉ­ 성곡")
        }
    }

    func session(_ session: SHSession, didNotFindMatchFor signature: SHSignature, error: Error?) {
        print("❌ μ»€μŠ€ν…€ λ§€μΉ­ μ‹€νŒ¨")
    }
}

πŸ’Ύ 4. μ‹œκ·Έλ‹ˆμ²˜ μ €μž₯ 및 λ‘œλ“œ

μƒμ„±ν•œ μ‹œκ·Έλ‹ˆμ²˜λ₯Ό 파일둜 μ €μž₯ν•˜κ³  λ‚˜μ€‘μ— λ‹€μ‹œ μ‚¬μš©ν•©λ‹ˆλ‹€.

SignatureStorage.swift β€” μ‹œκ·Έλ‹ˆμ²˜ 관리
import ShazamKit
import Foundation

class SignatureStorage {
    // μ‹œκ·Έλ‹ˆμ²˜λ₯Ό 파일둜 μ €μž₯
    func saveSignature(_ signature: SHSignature, to fileName: String) throws {
        // μ‹œκ·Έλ‹ˆμ²˜ 데이터
        let data = signature.dataRepresentation

        // μ €μž₯ 경둜
        let url = FileManager.default
            .urls(for: .documentDirectory, in: .userDomainMask)[0]
            .appendingPathComponent("\(fileName).shazamsignature")

        // 파일 μ €μž₯
        try data.write(to: url)
        print("βœ… μ‹œκ·Έλ‹ˆμ²˜ μ €μž₯: \(url)")
    }

    // νŒŒμΌμ—μ„œ μ‹œκ·Έλ‹ˆμ²˜ λ‘œλ“œ
    func loadSignature(from fileName: String) throws -> SHSignature {
        // 파일 경둜
        let url = FileManager.default
            .urls(for: .documentDirectory, in: .userDomainMask)[0]
            .appendingPathComponent("\(fileName).shazamsignature")

        // 데이터 읽기
        let data = try Data(contentsOf: url)

        // μ‹œκ·Έλ‹ˆμ²˜ 생성
        let signature = try SHSignature(dataRepresentation: data)
        print("βœ… μ‹œκ·Έλ‹ˆμ²˜ λ‘œλ“œ: \(url)")

        return signature
    }

    // μ €μž₯된 λͺ¨λ“  μ‹œκ·Έλ‹ˆμ²˜ λͺ©λ‘
    func listSavedSignatures() -> [String] {
        let documentsURL = FileManager.default
            .urls(for: .documentDirectory, in: .userDomainMask)[0]

        do {
            let files = try FileManager.default
                .contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil)
                .filter { $0.pathExtension == "shazamsignature" }
                .map { $0.deletingPathExtension().lastPathComponent }

            return files
        } catch {
            print("❌ 파일 λͺ©λ‘ κ°€μ Έμ˜€κΈ° μ‹€νŒ¨: \(error)")
            return []
        }
    }

    // μ‹œκ·Έλ‹ˆμ²˜ μ‚­μ œ
    func deleteSignature(fileName: String) throws {
        let url = FileManager.default
            .urls(for: .documentDirectory, in: .userDomainMask)[0]
            .appendingPathComponent("\(fileName).shazamsignature")

        try FileManager.default.removeItem(at: url)
        print("βœ… μ‹œκ·Έλ‹ˆμ²˜ μ‚­μ œ: \(fileName)")
    }
}

πŸ“± μ’…ν•© 예제

ShazamAppView.swift β€” Shazam μ•±
import SwiftUI
import ShazamKit

struct ShazamAppView: View {
    @State private var shazamManager = ShazamManager()
    @State private var showingResult = false

    var body: some View {
        NavigationStack {
            VStack(spacing: 30) {
                // λ²„νŠΌ
                Button {
                    if shazamManager.isListening {
                        shazamManager.stopListening()
                    } else {
                        shazamManager.startListening()
                    }
                } label: {
                    ZStack {
                        Circle()
                            .fill(shazamManager.isListening ? Color.red : Color.blue)
                            .frame(width: 150, height: 150)

                        if shazamManager.isListening {
                            Image(systemName: "waveform.circle.fill")
                                .font(.system(size: 60))
                                .foregroundStyle(.white)
                        } else {
                            Image(systemName: "magnifyingglass")
                                .font(.system(size: 60))
                                .foregroundStyle(.white)
                        }
                    }
                }
                .buttonStyle(.plain)

                Text(shazamManager.isListening ? "인식 쀑..." : "νƒ­ν•˜μ—¬ μŒμ•… 인식")
                    .font(.headline)

                // κ²°κ³Ό ν‘œμ‹œ
                if let media = shazamManager.matchedMedia {
                    VStack(spacing: 12) {
                        AsyncImage(url: media.artworkURL) { image in
                            image
                                .resizable()
                                .scaledToFit()
                        } placeholder: {
                            Rectangle()
                                .fill(Color.gray.opacity(0.3))
                        }
                        .frame(width: 200, height: 200)
                        .cornerRadius(12)

                        VStack(spacing: 4) {
                            Text(media.title ?? "Unknown")
                                .font(.title2)
                                .fontWeight(.bold)

                            Text(media.artist ?? "Unknown Artist")
                                .font(.subheadline)
                                .foregroundStyle(.secondary)
                        }

                        if let appleMusicURL = media.appleMusicURL {
                            Link("Apple Musicμ—μ„œ μ—΄κΈ°", destination: appleMusicURL)
                                .buttonStyle(.borderedProminent)
                        }

                        if let webURL = media.webURL {
                            Link("μ›Ήμ—μ„œ 보기", destination: webURL)
                                .buttonStyle(.bordered)
                        }
                    }
                    .padding()
                }

                // μ—λŸ¬ λ©”μ‹œμ§€
                if let error = shazamManager.errorMessage {
                    Text(error)
                        .foregroundStyle(.red)
                        .font(.caption)
                }

                Spacer()
            }
            .padding()
            .navigationTitle("μŒμ•… 인식")
        }
    }
}

πŸ’‘ HIG κ°€μ΄λ“œλΌμΈ

🎯 μ‹€μ „ ν™œμš©

πŸ“š 더 μ•Œμ•„λ³΄κΈ°

⚑️ μ„±λŠ₯ 팁: μ»€μŠ€ν…€ μΉ΄νƒˆλ‘œκ·ΈλŠ” μ•± λ²ˆλ“€μ— ν¬ν•¨ν•˜κ±°λ‚˜ μ„œλ²„μ—μ„œ λ‹€μš΄λ‘œλ“œν•˜μ—¬ μ‚¬μš©ν•  수 μžˆμŠ΅λ‹ˆλ‹€. μ‹œκ·Έλ‹ˆμ²˜ 생성은 CPU μ§‘μ•½μ μ΄λ―€λ‘œ λ°±κ·ΈλΌμš΄λ“œ μŠ€λ ˆλ“œμ—μ„œ μ‹€ν–‰ν•˜μ„Έμš”. Shazam μΉ΄νƒˆλ‘œκ·Έ μ‚¬μš© μ‹œ λ„€νŠΈμ›Œν¬ 연결이 ν•„μš”ν•˜μ§€λ§Œ, μ»€μŠ€ν…€ μΉ΄νƒˆλ‘œκ·ΈλŠ” μ˜€ν”„λΌμΈμ—μ„œλ„ μž‘λ™ν•©λ‹ˆλ‹€.