2023 Audio & VideoApp Services
WWDC23 · 22 min · Audio & Video / App Services
Create a great ShazamKit experience
Discover how your app can offer a great audio matching experience with the latest updates to ShazamKit. We’ll take you through matching features, updates to audio recognition, and interactions with the Shazam library. Learn tips and best practices for using ShazamKit in your audio apps. For more on ShazamKit, check out "Create custom catalogs at scale with ShazamKit" from WWDC22 as well as "Explore ShazamKit" and "Create custom audio experiences with ShazamKit" from WWDC21.
Watch at developer.apple.com ↗Code shown on screen · 13 snippets
Single match with SHManagedSession
let managedSession = SHManagedSession()
let result = await managedSession.result()
switch result {
case .match(let match):
print("Match found. MediaItemsCount: \(match.mediaItems.count)")
case .noMatch(_):
print("No match found")
case .error(_, _):
print("An error occurred")
} Multiple matches with SHManagedSession
let managedSession = SHManagedSession()
// Continuously match
for await result in managedSession.results {
switch result {
case .match(let match):
print("Match found. MediaItemsCount: \(match.mediaItems.count)")
case .noMatch(_):
print("No match found")
case .error(_, _):
print("An error occurred")
}
} Stop SHManagedSession
let managedSession = SHManagedSession()
// Cancel the session
managedSession.cancel() ShazamKit Matcher with SHManagedSession
import Foundation
import ShazamKit
struct MatchResult: Identifiable, Equatable {
let id = UUID()
let match: SHMatch?
}
final class Matcher: ObservableObject {
var isMatching = false
var currentMatchResult: MatchResult?
var currentMediaItem: SHMatchedMediaItem? {
currentMatchResult?.match?.mediaItems.first
}
private let session: SHManagedSession
init() {
if let catalog = try? ResourcesProvider.catalog() {
session = SHManagedSession(catalog: catalog)
} else {
session = SHManagedSession()
}
}
func match() async {
isMatching = true
for await result in session.results {
switch result {
case .match(let match):
Task { in
self.currentMatchResult = MatchResult(match: match)
}
case .noMatch(_):
print("No match")
endSession()
case .error(let error, _):
print("Error \(error.localizedDescription)")
endSession()
}
stopRecording()
}
}
func stopRecording() {
session.cancel()
}
func endSession() {
// Reset result of any previous match.
isMatching = false
currentMatchResult = MatchResult(match: nil)
}
} Preparing SHManagedSession
let managedSession = SHManagedSession()
await managedSession.prepare()
let result = await managedSession.result() SHManagedSession Idle State in SwiftUI
struct MatchView: View {
let session: SHManagedSession
var body: some View {
VStack {
Text(session.state == .idle ? "Hear Music?"
: "Matching")
if session.state == .matching {
ProgressView()
} else {
Button {
// start match
} label: {
Text("Learn the Dance")
}
}
}
} SHManagedSession Matching State in SwiftUI
struct MatchView: View {
let session: SHManagedSession
var body: some View {
VStack {
Text(session.state == .idle ? "Hear Music?"
: "Matching")
if session.state == .matching {
ProgressView()
} else {
Button {
// start match
} label: {
Text("Learn the Dance")
}
}
}
}
} Adding with SHLibrary
func add(mediaItems: [SHMediaItem]) async throws {
try await SHLibrary.default.addItems(mediaItems)
} Reading with SHLibrary
struct LibraryView: View {
var body: some View {
List(SHLibrary.default.items) { item in
MediaItemView(item: item)
}
}
} Reading with SHLibrary in a non-UI context
// Determine a user’s most popular genre
let currentItems = await SHLibrary.default.items
let genres = currentItems.flatMap { $0.genres }
// count frequency of genres and get the highest
let mostPopularGenre = highestOccurringGenre(from: genres) SHLibrary Remove
func remove(mediaItems: [SHMediaItem]) async throws {
try await SHLibrary.default.removeItems(mediaItems)
} RecentDancesView with SHLibrary read and delete implementation
import SwiftUI
import ShazamKit
enum NavigationPath: Hashable {
case nowPlayingView(videoURL: URL)
case danceCompletionView
}
struct RecentDancesView: View {
private enum ViewConstants {
static let emptyStateImageName: String = "EmptyStateIcon"
static let emptyStateTextTitle: String = "No Dances Yet?"
static let emptyStateTextSubtitle: String = "Find some music to start learning"
static let deleteSwipeViewOpacity: Double = 0.5
static let matchingStateTextTopPadding: CGFloat = 24
static let matchingStateTextBottomPadding: CGFloat = 16
static let progressViewScaleEffect: CGFloat = 1.1
static let progressViewBottomPadding: CGFloat = 12.0
static let learnDanceButtonWidth: CGFloat = 250
static let curvedTopSideRectangleHeight: CGFloat = 200
static let listRowBottomInset: CGFloat = 30.0
static let matchingStateText: String = "Get Ready..."
static let notMatchingStateText: String = "Hear Music?"
static let noMatchText: String = "No dance video for audio"
static let navigationTitleText: String = "Recent Dances"
static let learnDanceButtonText: String = "Learn the Dance"
static let retryButtonText: String = "Try Again"
static let cancelButtonText: String = "Cancel"
}
// MARK: Properties
private var isListEmpty: Bool {
SHLibrary.default.items.isEmpty
}
private var matchingState: String = ViewConstants.notMatchingStateText
private var matchButtonText: String = ViewConstants.learnDanceButtonText
private var canRetryMatchAttempt = false
private var navigationPath: [NavigationPath] = []
// MARK: Environment
private var matcher: Matcher
(\.openURL) var openURL
var body: some View {
NavigationStack(path: $navigationPath) {
ZStack(alignment: .bottom) {
List(SHLibrary.default.items, id: \.self) { mediaItem in
RecentDanceRowView(mediaItem: mediaItem)
.onTapGesture(perform: {
guard let appleMusicURL = mediaItem.appleMusicURL else {
return
}
openURL(appleMusicURL)
})
.swipeActions {
Button {
Task { try? await SHLibrary.default.removeItems([mediaItem]) }
} label: {
Image(systemName: "trash")
.symbolRenderingMode(.hierarchical)
}
.tint(.appPrimary.opacity(0.5))
}
}
.listStyle(.plain)
.overlay {
if isListEmpty {
ContentUnavailableView {
Label(ViewConstants.emptyStateTextTitle,
image: ImageResource(name: ViewConstants.emptyStateImageName, bundle: Bundle.main))
.font(.title)
.foregroundStyle(Color.white)
} description: {
Text(ViewConstants.emptyStateTextSubtitle)
.foregroundStyle(Color.white)
}
}
}
.safeAreaInset(edge: .bottom, spacing: ViewConstants.listRowBottomInset) {
ZStack(alignment: .top) {
CurvedTopSideRectangle()
VStack {
Text(matchingState)
.font(.body)
.foregroundStyle(.white)
.padding(.top, ViewConstants.matchingStateTextTopPadding)
.padding(.bottom, ViewConstants.matchingStateTextBottomPadding)
if matcher.isMatching {
ProgressView()
.progressViewStyle(.circular)
.tint(.appPrimary)
.scaleEffect(x: ViewConstants.progressViewScaleEffect, y: ViewConstants.progressViewScaleEffect)
.padding(.bottom, ViewConstants.progressViewBottomPadding)
Button(ViewConstants.cancelButtonText) {
canRetryMatchAttempt = false
matcher.stopRecording()
matcher.endSession()
}
.foregroundStyle(Color.appPrimary)
.font(.subheadline)
.fontWeight(.semibold)
} else {
Button {
Task { await matcher.match() }
matchingState = ViewConstants.matchingStateText
canRetryMatchAttempt = true
} label: {
Text(matchButtonText)
.foregroundStyle(.black)
.font(.title3)
.fontWeight(.heavy)
.frame(maxWidth: .infinity)
}
.frame(width: ViewConstants.learnDanceButtonWidth)
.padding()
.background(Color.appPrimary)
.clipShape(Capsule())
}
}
}
.edgesIgnoringSafeArea(.bottom)
.frame(height: ViewConstants.curvedTopSideRectangleHeight)
}
}
.background(Color.appSecondary)
.navigationTitle(isListEmpty ? "" : ViewConstants.navigationTitleText)
.preferredColorScheme(.dark)
.toolbarColorScheme(.dark, for: .navigationBar)
.navigationBarTitleDisplayMode(.large)
.toolbarBackground(Color.appSecondary, for: .navigationBar)
.frame(maxHeight: .infinity)
.onChange(of: matcher.currentMatchResult, { _, result in
guard navigationPath.isEmpty else {
print("Dance video already displayed")
return
}
guard let match = result?.match,
let url = ResourcesProvider.videoURL(forFilename: match.mediaItems.first?.videoTitle ?? "") else {
matchingState = canRetryMatchAttempt ? ViewConstants.noMatchText : ViewConstants.notMatchingStateText
matchButtonText = canRetryMatchAttempt ? ViewConstants.retryButtonText : ViewConstants.learnDanceButtonText
return
}
canRetryMatchAttempt = false
// Add the video playing view to the navigation stack.
navigationPath.append(.nowPlayingView(videoURL: url))
})
.navigationDestination(for: NavigationPath.self, destination: { newNavigationPath in
switch newNavigationPath {
case .nowPlayingView(let videoURL):
NowPlayingView(navigationPath: $navigationPath, nowPlayingViewModel: NowPlayingViewModel(player: AVPlayer(url: videoURL)))
case .danceCompletionView:
DanceCompletionView(navigationPath: $navigationPath)
}
})
.onAppear {
if AVAudioSession.sharedInstance().category != .ambient {
Task.detached { try? AVAudioSession.sharedInstance().setCategory(.ambient) }
}
matchingState = ViewConstants.notMatchingStateText
matchButtonText = ViewConstants.learnDanceButtonText
}
}
}
} Filtering for specific media items
func match(from televisionShowCatalog: SHCustomCatalog) async -> [SHMatchedMediaItem] {
let managedSession = SHManagedSession(catalog: televisionShowCatalog)
let result = await managedSession.result()
if case .match(let match) = result {
// filter for only media items related to a particular episode
let filteredMediaItems = match.mediaItems.filter { $0.title == "Episode 2" }
return filteredMediaItems
}
return []
} Resources
Related sessions
-
20 min -
15 min