From 3f788a2f81879889e15e42fe952166e6866981b3 Mon Sep 17 00:00:00 2001 From: Laurent Date: Sun, 24 May 2026 11:08:29 +0200 Subject: [PATCH] feat: add ShazamService for music recognition --- Music/Services/ShazamService.swift | 83 ++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 Music/Services/ShazamService.swift diff --git a/Music/Services/ShazamService.swift b/Music/Services/ShazamService.swift new file mode 100644 index 0000000..b9c5f32 --- /dev/null +++ b/Music/Services/ShazamService.swift @@ -0,0 +1,83 @@ +import AVFAudio +import Observation +import ShazamKit + +@Observable +final class ShazamService: NSObject, SHSessionDelegate { + var isListening = false + var matchedTitle: String? + var matchedArtist: String? + var errorMessage: String? + + private var session: SHSession? + private let audioEngine = AVAudioEngine() + private var timeoutTask: Task? + + func startListening() { + guard !isListening else { + stopListening() + return + } + + matchedTitle = nil + matchedArtist = nil + errorMessage = nil + + let session = SHSession() + session.delegate = self + self.session = session + + let inputNode = audioEngine.inputNode + let format = inputNode.outputFormat(forBus: 0) + + inputNode.installTap(onBus: 0, bufferSize: 2048, format: format) { buffer, time in + session.matchStreamingBuffer(buffer, at: time) + } + + do { + try audioEngine.start() + isListening = true + + timeoutTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(15)) + guard !Task.isCancelled else { return } + self?.stopListening() + if self?.matchedTitle == nil { + self?.errorMessage = "No match found. Try again in a quieter environment." + } + } + } catch { + errorMessage = "Could not access microphone." + } + } + + func stopListening() { + timeoutTask?.cancel() + timeoutTask = nil + if audioEngine.isRunning { + audioEngine.inputNode.removeTap(onBus: 0) + audioEngine.stop() + } + session = nil + isListening = false + } + + func clearResult() { + matchedTitle = nil + matchedArtist = nil + errorMessage = nil + } + + nonisolated func session(_ session: SHSession, didFind match: SHMatch) { + Task { @MainActor [weak self] in + guard let item = match.mediaItems.first else { return } + self?.matchedTitle = item.title + self?.matchedArtist = item.artist + self?.stopListening() + } + } + + nonisolated func session(_ session: SHSession, didNotFindMatchFor signature: SHSignature) { + // Session continues listening; the 15s timeout handles the "no match" case. + } +}