Fix: AVAudioPlayer.stop() Does Nothing Because the Players Array Is Empty in SwiftUI

Fix: AVAudioPlayer.stop() Does Nothing Because the Players Array Is Empty in SwiftUI

Written by

in

Quick answer: Your stop() call fails because playSoundEffect() creates and plays a second AVAudioPlayer that is never added to the players array, so stopAllPlayers() has nothing to stop. Delete the duplicate player creation, keep every player in the array, and stop them there.

Why this happens

AVAudioPlayer.stop() only stops the exact instance you call it on. It has no effect on any other player, even one playing the same file. So when stopAllPlayers() finds an empty players array, it means the sounds you hear are coming from players that were never stored.

In the code from the question there are two separate player creations on every sound call:

  1. playSoundEffect(soundName:) builds an AVAudioPlayer from a hard-coded "swoosh" resource and calls play() on it. This player is a local variable and is never added to players.
  2. The switch then calls playTheSound, which calls playSound(sound:type:). That function builds a second AVAudioPlayer for the requested sound, plays it, and appends it to players.

So each tap on “swoosh” plays the sound twice: once from the orphaned local player, once from the tracked one. stopAllPlayers() stops the tracked one, but the orphaned one keeps playing, which makes it look like stop() “doesn’t work”. Worse, the orphaned player is re-created from the same hard-coded file every call, so even sounds other than “swoosh” get an unwanted swoosh layered on top.

There is a second, subtler reason the array can look empty: this line runs right after appending:

players = players.filter { $0.isPlaying }

isPlaying is queried immediately after play(). If the audio session is still configuring, or the player has already finished a very short effect, isPlaying can be false, and the freshly added player is filtered straight back out. You then have a playing sound with no reference to it, which is exactly the state that makes stopping impossible.

Finally, the SoundManager is instantiated twice: once as the static let shared singleton and once as var soundManager: SoundManager = SoundManager() in the view. Both are used in different places, so players get appended to one instance while stopAllPlayers() is called on the other. A singleton should be the only entry point.

How to fix it

1. Remove the duplicate player creation

playSoundEffect should do nothing but dispatch to the right sound. Delete the local audioPlayer block entirely:

import AVFoundation

class SoundManager {
    static let shared = SoundManager()

    private var players: [AVAudioPlayer] = []

    func playSoundEffect(soundName: String) {
        switch soundName {
        case "swoosh":   play("Swoosh", "mp3")
        case "key tap":  play("KeyTap", "mp3")
        case "you win":  play("YouWinSound", "mp3")
        case "ticking":  play("TickingSound", "mp3")
        default:
            assertionFailure("Unknown sound name: \(soundName)")
        }
    }

    private func play(_ name: String, _ type: String) {
        configureAudioSession()

        guard let url = Bundle.main.url(forResource: name, withExtension: type) else {
            assertionFailure("Missing sound resource: \(name).\(type)")
            return
        }

        do {
            let player = try AVAudioPlayer(contentsOf: url)
            player.delegate = self
            player.prepareToPlay()
            player.play()
            players.append(player)
        } catch {
            print("Audio player error: \(error)")
        }
    }

    private func configureAudioSession() {
        do {
            try AVAudioSession.sharedInstance()
                .setCategory(.playback, options: [.mixWithOthers])
            try AVAudioSession.sharedInstance().setActive(true)
        } catch {
            print("Audio session error: \(error)")
        }
    }
}

Now every player that makes a sound is in players, and stopAllPlayers() has something to stop.

2. Stop players without the isPlaying filter race

Drop the filter { $0.isPlaying } line. It runs before playback has reliably started and silently discards players you still need. If you want to prune finished players, do it in the delegate callback instead (step 3). The stop function becomes:

extension SoundManager {
    func stopAllPlayers() {
        for player in players {
            player.stop()
            player.currentTime = 0
        }
        players.removeAll()
    }

    func stop(soundNamed name: String) {
        // Optional: stop only the ticking sound, for example.
        // Track the name alongside the player if you need this.
    }
}

For the game flow in the question — stop the ticking when the player completes an activity — call SoundManager.shared.stopAllPlayers() from the completion handler. If you want other effects to keep going and only kill the ticking, keep a separate reference for looping sounds:

private var loopingPlayer: AVAudioPlayer?

func startTicking() {
    guard let url = Bundle.main.url(forResource: "TickingSound", withExtension: "mp3") else { return }
    loopingPlayer = try? AVAudioPlayer(contentsOf: url)
    loopingPlayer?.numberOfLoops = -1
    loopingPlayer?.play()
}

func stopTicking() {
    loopingPlayer?.stop()
    loopingPlayer = nil
}

A dedicated reference for the looping sound is more robust than filtering the array by name, and it makes intent obvious at the call site.

3. Prune finished players via AVAudioPlayerDelegate

Instead of filtering on isPlaying after every play call, let the player tell you when it finishes. Conform the manager to AVAudioPlayerDelegate and remove the player there:

extension SoundManager: AVAudioPlayerDelegate {
    func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
        players.removeAll { $0 === player }
    }
}

Note the identity check $0 === player: two players can wrap the same URL, so comparing by value is not enough. This keeps players accurate without racing against playback start.

4. Use the singleton everywhere

Replace the per-view instance with the shared one:

// In the view:
Button("Play swoosh") {
    SoundManager.shared.playSoundEffect(soundName: "swoosh")
}

Button("Stop") {
    SoundManager.shared.stopAllPlayers()
}

Do not create SoundManager() directly. If you want the compiler to enforce that, make the initializer private:

class SoundManager {
    static let shared = SoundManager()
    private init() {}
    // ...
}

5. Do you need @State or ObservableObject?

No — not for this bug. AVAudioPlayer playback does not drive any UI, so there is nothing for SwiftUI to observe. Making the manager @State in a view would actually recreate it when the view redraws, killing all sounds mid-play. Keep it as a plain class singleton. Only reach for ObservableObject if you later want UI to react to audio state (for example, a mute button that reflects a published isMuted property).

If you do adopt ObservableObject under Swift concurrency, annotate the class @MainActor so player mutation stays on the main actor and you avoid data-race warnings:

@MainActor
class SoundManager: ObservableObject { ... }

How to prevent it

  • Never create an AVAudioPlayer as a local variable and call play() on it. If nothing holds a reference, you cannot stop it later.
  • Do not filter stored players by isPlaying immediately after starting playback; prune in audioPlayerDidFinishPlaying instead.
  • Use one singleton and make init private so stray instances cannot split your player list.
  • Fail loudly on missing resources with assertionFailure or a log; a silent return in guard let url costs hours of debugging.
  • Give looping sounds (ticking, background music) their own dedicated player property with numberOfLoops = -1, separate from one-shot effects.

FAQ

Why does the sound keep playing after I call stop()?

Because the instance you stopped is not the one you hear. Any AVAudioPlayer created locally and never stored keeps playing until the file ends. Audit your play path so every play() happens on a player you retain.

Does AVAudioPlayer.stop() reset the playback position?

stop() halts playback but does not rewind. Set currentTime = 0 after stopping if you want the next play() to start from the beginning.

Should SoundManager be @MainActor or ObservableObject in SwiftUI?

Only if UI needs to observe it. For fire-and-forget sound effects a plain singleton class is fine; add @MainActor if you enable stricter concurrency checking and want the compiler to verify player access stays off arbitrary threads.

Sources & credits

Parts of this guide are adapted from content licensed under CC BY-SA 4.0; this article is shared under the same license.