Perfect Pitch game in Swift UI

4 likes

For our music project, we were assigned to create a music-related game. As I was already following an online course on IOS development, I thought this as a perfect opportunity for me to practice what I had learned. The game I decided to create was a game that tests one's ability to recognize different keys in notes, chords, and scales.

How I made it

I started by making a menu page which consists of three buttons, a title and an icon at the button right indicating how many coins the user has. The three buttons navigate to the three different tests: single notes, chords, and scales.

Then, I needed the sound files of the notes and found this website. At that point, I could not find an efficient way to play the sounds until I came across this StackOverflow post. Not only does it contain highly reusable functions, it also can play multiple sound files at once. I can configure the time span for the sounds as well.

Here is what I ended up implementing:

import Foundation
import AVFoundation
import Combine

var audioPlayer: AVAudioPlayer?
func playSound(sound: String){
    if let path = Bundle.main.path(forResource: sound, ofType: "wav"){
        do{
            audioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path))
            audioPlayer?.play()
        }catch{
            print("cound not")
        }
    }
}

class GSAudio: NSObject, AVAudioPlayerDelegate {

    static let sharedInstance = GSAudio()
    private override init() { }
    var players: [URL: AVAudioPlayer] = [:]
    var duplicatePlayers: [AVAudioPlayer] = []

    func playSound(soundFileName: String) {
        guard let bundle = Bundle.main.path(forResource: soundFileName, ofType: "wav") else { return }
        let soundFileNameURL = URL(fileURLWithPath: bundle)
        if let player = players[soundFileNameURL] { //player for sound has been found
            if !player.isPlaying { //player is not in use, so use that one
                player.prepareToPlay()
                player.play()
            } else { // player is in use, create a new, duplicate, player and use that instead
                do {
                    let duplicatePlayer = try AVAudioPlayer(contentsOf: soundFileNameURL)
                    duplicatePlayer.delegate = self
                    //assign delegate for duplicatePlayer so delegate can remove the duplicate once it's stopped playing
                    duplicatePlayers.append(duplicatePlayer)
                    //add duplicate to array so it doesn't get removed from memory before finishing
                    duplicatePlayer.prepareToPlay()
                    duplicatePlayer.play()
                } catch let error {
                    print(error.localizedDescription)
                }
            }
        } else { //player has not been found, create a new player with the URL if possible
            do {
                let player = try AVAudioPlayer(contentsOf: soundFileNameURL)
                players[soundFileNameURL] = player
                player.prepareToPlay()
                player.play()
            } catch let error {
                print(error.localizedDescription)
            }
        }
    }

    func playSounds(soundFileNames: [String]) {
        for soundFileName in soundFileNames {
            playSound(soundFileName: soundFileName)
        }
    }

    func playSounds(soundFileNames: String...) {
        for soundFileName in soundFileNames {
            playSound(soundFileName: soundFileName)
        }
    }

    func playSounds(soundFileNames: [String], withDelay: Double) { //withDelay is in seconds
        for (index, soundFileName) in soundFileNames.enumerated() {
            let delay = withDelay * Double(index)
            let _ = Timer.scheduledTimer(timeInterval: delay, target: self, selector: #selector(playSoundNotification(_:)), userInfo: ["fileName": soundFileName], repeats: false)
        }
    }

    @objc func playSoundNotification(_ notification: NSNotification) {
        if let soundFileName = notification.userInfo?["fileName"] as? String {
            playSound(soundFileName: soundFileName)
        }
    }

    func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
        if let index = duplicatePlayers.firstIndex(of: player) {
            duplicatePlayers.remove(at: index)
        }
    }

}

The codes for each of the pages weren't that challenging as I decided to use a picker slider for the user to select the choices. Then, for each mode, I created a page to show whether the user got the correct key. I simply checked the boolean value of a Binding variable "answer" and navigated to the "correct" or the "wrong" page. Later on, to further users' playing experience, I decided to implement a renowned psychological device - the coin. Depending on how well the user performs in the game, a different amount of coins is given.

Challenge #1 Swift UI view protocol

At first, I was honestly quite confused with the view protocol. Binding variable? State variable? As I had been used to IOS development in the traditional Swift-Storyboard way, all these extra features of Swift UI sounded superfluous to me. After doing some research, I soon found out that the binding variable and state variables are extremely powerful and minimalize a lot of redundant codes.

Challenge #2 Coin feature

To implement the coin feature, I had to access the internal storage of the user's device. Otherwise, the coin variable would not remain the same the next time user opens the app.

 @State private var coin = UserDefaults.standard.integer(forKey: "coin")

For users to have good use of the coins, I created a feature. In chords mode, the user has a choice to play the chords separately by submitting a fee of 50 coins. Similarly, in scale mode, the user has the option to pay 50 coins to play the scale slower. I designed a pop up for the payment confirmation.

Challenge #3 Frontend Design

I wrote a ton of codes for the frontend. Even though that may be inevitable, I still feel like there is a lot of redundancy. For instance, this is the code for one of the modes:

struct chord: View {
    @Binding var status: Int
    @Binding var current: Array
    @Binding var Index: Int
    @Binding var answer: Bool
    @Binding var coin: Int

    @State private let pickerValue: Array = ["a", "b", "c", "d", "e", "f", "g"]

    @State private var oneS = true
    @State private var one = ""
    @State private var twoS = true
    @State private var two = ""

    @State private var threeS = true
    @State private var three = ""
    @State private var isAlert = false

    var body: some View {
        VStack{
            Picker(selection: $Index, label: Text("")) {
                                  ForEach(0 ..< pickerValue.count) {
                                    Text(self.pickerValue[$0])
                                  }

                       }.labelsHidden().padding()
                       .overlay(
                           RoundedRectangle(cornerRadius: 16)
                               .stroke(Color.orange, lineWidth: 2)
                       )
            HStack {
                Button(action:{
                    self.oneS.toggle()
                    self.one = self.pickerValue[self.Index]
                }){
                    if oneS{
                        Text("")
                        .font(.system(size: 30))
                        .foregroundColor(Color.black)
                        .padding(8)
                    }else{
                        Text("\(one)")
                        .font(.system(size: 30))
                        .foregroundColor(Color.black)
                        .padding(8)
                    }
                }.overlay(
                    RoundedRectangle(cornerRadius: 20)
                        .stroke(Color.orange, lineWidth: 3)
                        .frame(width: 100, height: 90, alignment: Alignment.center)
                ).padding(40)

                Button(action:{
                    self.twoS.toggle()
                    self.two = self.pickerValue[self.Index]
                }){
                    if twoS{
                        Text("")
                        .font(.system(size: 30))
                        .foregroundColor(Color.black)
                        .padding(8)
                    }else{
                        Text("\(two)")
                        .font(.system(size: 30))
                        .foregroundColor(Color.black)
                        .padding(8)
                    }
                }.overlay(
                    RoundedRectangle(cornerRadius: 20)
                        .stroke(Color.orange, lineWidth: 3)
                        .frame(width: 100, height: 90, alignment: Alignment.center)
                ).padding(40)

                Button(action:{
                    self.threeS.toggle()
                    self.three = self.pickerValue[self.Index]
                }){
                    if threeS{
                        Text("")
                        .font(.system(size: 30))
                        .foregroundColor(Color.black)
                        .padding(8)
                    }else{
                        Text("\(three)")
                        .font(.system(size: 30))
                        .foregroundColor(Color.black)
                        .padding(8)
                    }
                }.overlay(
                    RoundedRectangle(cornerRadius: 20)
                        .stroke(Color.orange, lineWidth: 3)
                        .frame(width: 100, height: 90, alignment: Alignment.center)
                ).padding(40)      
            }            
            Button(action: {
                GSAudio.sharedInstance.playSounds(soundFileNames: self.current)
            }){
                HStack {
                    Image("icons8-music-record-60")

                    Text("Play Chord")
                    .font(.system(size: 20))
                }.frame(minWidth: 0, maxWidth: .infinity)
                .padding()
                .foregroundColor(.white)
                .background(LinearGradient(gradient: Gradient(colors: [Color.orange, Color.yellow]), startPoint: .leading, endPoint: .trailing))
                .cornerRadius(40)
                .padding(.horizontal, 20)
                .padding(3).scaleEffect(x: 1.1, y: 1.1, anchor: UnitPoint.center)
            }
            Button(action: {
                self.isAlert = true
            }){
                HStack {
                    Image("icons8-music-record-60")
                    Text("Saperate - 10 Coins")
                        .font(.system(size: 20))
                }.frame(minWidth: 0, maxWidth: .infinity)
                .padding()
                .foregroundColor(.white)
                .background(LinearGradient(gradient: Gradient(colors: [Color.orange, Color.yellow]), startPoint: .leading, endPoint: .trailing))
                .cornerRadius(40)
                .padding(.horizontal, 20)
                .padding(3).scaleEffect(x: 1.1, y: 1.1, anchor: UnitPoint.center)
            }.alert(isPresented: $isAlert) { () -> Alert in                
            Alert(title: Text("Purchase"), message: Text("Are you sure you want to purchase with 10 coins?"), primaryButton: .default(Text("Yes"), action: {                
                if self.coin > 10{               
                    let sortedArray = self.current.sorted(by: <)                
                    GSAudio.sharedInstance.playSounds(soundFileNames: sortedArray, withDelay: 0.5)
                    self.coin -= 10
                    UserDefaults.standard.set(self.coin, forKey: "coin")
                }    
            }), secondaryButton: .default(Text("No")))
            }
            Button(action:{
                let ans = [self.one, self.two, self.three]
                self.status = 5
                if ans.containsSameElements(as: self.current){
                    self.answer = true
                    self.coin += 25
                    UserDefaults.standard.set(self.coin, forKey: "coin")
                }
                else{
                    self.answer = false
                }
                print(self.current)
                self.one = ""
                self.two = ""
                self.three = ""
            }){
                Text("submit")
                .frame(minWidth: 0, maxWidth: .infinity)
                .padding()
                    .foregroundColor(Color.orange)
                .overlay(
                    RoundedRectangle(cornerRadius: 40)
                    .stroke(Color.orange, lineWidth: 3)
                )
                .padding(.horizontal, 20)
                .padding(6).scaleEffect(x: 1.1, y: 1.1, anchor: UnitPoint.center)
            }
            HStack (spacing: 100){
                Button(action: {
                           self.one = ""
                           self.two = ""
                           self.three = ""
                       }) {
                           Text("Retry")
                            .foregroundColor(Color.red)
                            .font(.system(size: 25))
                }
            Button(action: {
                self.status = 3
            }){
                Text("Menu")
                    .foregroundColor(Color.red)
                    .font(.system(size: 25))
                }
            }.padding()

            HStack {
                Image("icons8-coin-48")
                Text("\(self.coin)")
                    .font(.title)
            }.offset(x: 120, y: -10)

            }.edgesIgnoringSafeArea(.all).padding(30).offset(y: 30)
    }
}

struct chord_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

extension Array where Element: Comparable {
    func containsSameElements(as other: [Element]) -> Bool {
        return self.count == other.count && self.sorted() == other.sorted()
    }

There is a lot of reusable elements, and I could have structured the codes a bit better.

The code can be found on github

Conclusion

After doing this project, I felt more comfortable manipulating the basic elements in SwiftUI, including @State and @Binding variables, and how to initialize them in a view controller. Spending a couple of days working on this game, I realized how sophisticated IOS development is - protocols, the usage of struct and class, optional, and more. Using swift to develop an IOS software just seems cumbersome. Maybe in the near future, I can study react native or other javascript libraries to develop IOS related software.


Leave a Comment
/200 Characters