Sprite Kit & playing sound leads to app termination

using ARC

Just a problem I've run into- I have an SKScene in which I play a sound fx using SKAction class method

[SKAction playSoundFileNamed:@"sound.wav" waitForCompletion:NO];

Now when I try to go to background, no matter that the sound was over, apparently iOS is terminating my app due to gpus_ReturnNotPermittedKillClient.

Now only when I comment this line and not running the action iOS runs it great in background (of course, paused, but without termination).

What am I doing wrong?

EDIT: iOS will not terminate the app if the line wasn't run- say, if it was in an if statement that wasn't run (soundOn == YES) or something like that, when the bool is false


Solution 1:

The problem is AVAudioSession can't be active while the app enters background. This isn't immediately obvious because Sprite Kit makes no mention that it uses AVAudioSession internally.

The fix is quite simple, and also applies to ObjectAL => set the AVAudioSession to inactive while the app is in background, and reactivate the audio session when the app enters foreground.

A simplified AppDelegate with this fix looks like so:

#import <AVFoundation/AVFoundation.h>
...

- (void)applicationWillResignActive:(UIApplication *)application
{
    // prevent audio crash
    [[AVAudioSession sharedInstance] setActive:NO error:nil];
}

- (void)applicationDidEnterBackground:(UIApplication *)application
{
    // prevent audio crash
    [[AVAudioSession sharedInstance] setActive:NO error:nil];
}

- (void)applicationWillEnterForeground:(UIApplication *)application
{
    // resume audio
    [[AVAudioSession sharedInstance] setActive:YES error:nil];
}

PS: this fix will be included in Kobold Kit v7.0.3.

Solution 2:

I found that it's all about deactivating AVAudioSession in AppDelegate applicationDidEnterBackground:, but often it fails with error (no deactivation in effect):

Error Domain=NSOSStatusErrorDomain Code=560030580 "The operation couldn’t be completed. (OSStatus error 560030580.)

which still leads to the crash described here: Spritekit crashes when entering background.

So, it's not enough to setActive:NO - we have to deactivate it effectively (without that error). I made a simple solution by adding dedicated instance method to the AppDelegate which deactivates AVAudioSession as long as there is no error.

In short it looks like this:

- (void)applicationDidEnterBackground:(UIApplication *)application {
    NSLog(@"%s", __FUNCTION__);
    [self stopAudio];
}

- (void)stopAudio {
    NSError *error = nil;
    [[AVAudioSession sharedInstance] setActive:NO error:&error];
    NSLog(@"%s AudioSession Error: %@", __FUNCTION__, error);
    if (error) [self stopAudio];
}

NSLog proof:

2014-01-25 11:41:48.426 MyApp[1957:60b] -[ATWAppDelegate applicationDidEnterBackground:]
2014-01-25 11:41:48.431 MyApp[1957:60b] -[ATWAppDelegate stopAudio] AudioSession Error: Error Domain=NSOSStatusErrorDomain Code=560030580 "The operation couldn’t be completed. (OSStatus error 560030580.)"
2014-01-25 11:41:48.434 MyApp[1957:60b] -[ATWAppDelegate stopAudio] AudioSession Error: Error Domain=NSOSStatusErrorDomain Code=560030580 "The operation couldn’t be completed. (OSStatus error 560030580.)"
2014-01-25 11:41:48.454 MyApp[1957:60b] -[ATWAppDelegate stopAudio] AudioSession Error: Error Domain=NSOSStatusErrorDomain Code=560030580 "The operation couldn’t be completed. (OSStatus error 560030580.)"
2014-01-25 11:41:49.751 MyApp[1957:60b] -[ATWAppDelegate stopAudio] AudioSession Error: (null)

This is really short, because it doesn't care about stackoverflow :) if AVAudioSession don't want to close after several thousands tries (crash is inevitable then also). So, this can be only considered as a hack until Apple fix it. By the way, it's worth also to take control over starting AVAudioSession.

Full solution can look like this:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    NSLog(@"%s", __FUNCTION__);

    [self startAudio];
    return YES;
}

- (void)applicationDidEnterBackground:(UIApplication *)application {
    NSLog(@"%s", __FUNCTION__);

    // SpriteKit uses AVAudioSession for [SKAction playSoundFileNamed:]
    // AVAudioSession cannot be active while the application is in the background,
    // so we have to stop it when going in to background
    // and reactivate it when entering foreground.
    // This prevents audio crash.
    [self stopAudio];
}

- (void)applicationWillEnterForeground:(UIApplication *)application {
    NSLog(@"%s", __FUNCTION__);

    [self startAudio];
}

- (void)applicationWillTerminate:(UIApplication *)application {
    NSLog(@"%s", __FUNCTION__);

    [self stopAudio];
}

static BOOL isAudioSessionActive = NO;

- (void)startAudio {
    AVAudioSession *audioSession = [AVAudioSession sharedInstance];
    NSError *error = nil;

    if (audioSession.otherAudioPlaying) {
        [audioSession setCategory: AVAudioSessionCategoryAmbient error:&error];
    } else {
        [audioSession setCategory: AVAudioSessionCategorySoloAmbient error:&error];
    }

    if (!error) {
        [audioSession setActive:YES error:&error];
        isAudioSessionActive = YES;
    }

    NSLog(@"%s AVAudioSession Category: %@ Error: %@", __FUNCTION__, [audioSession category], error);
}

- (void)stopAudio {
    // Prevent background apps from duplicate entering if terminating an app.
    if (!isAudioSessionActive) return;

    AVAudioSession *audioSession = [AVAudioSession sharedInstance];
    NSError *error = nil;

    [audioSession setActive:NO error:&error];

    NSLog(@"%s AVAudioSession Error: %@", __FUNCTION__, error);

    if (error) {
        // It's not enough to setActive:NO
        // We have to deactivate it effectively (without that error),
        // so try again (and again... until success).
        [self stopAudio];
    } else {
        isAudioSessionActive = NO;
    }
}

This problem, however, is a piece of cake comparing to AVAudioSession interruptions in SpriteKit app. If we don't handle it, sooner or later we get into big troubles with memory leaks and CPU 99% (56% from [SKSoundSource queuedBufferCount] and 34% from [SKSoundSource isPlaying] - see Instruments), because SpriteKit is stubborn and "plays" sounds even they can't be played :)

As far as I found the easiest way is to setCategory:AVAudioSessionCategoryPlayAndRecord withOptions:AVAudioSessionCategoryOptionMixWithOthers. Any other AVAudioSession categories needs, I think, to avoid playFileNamed: at all. This can be done by making your own SKNode runAction: category for playing sounds method for example with AVAudioPlayer. But this is separate topic.

My full all-in-one solution with AVAudioPlayer implementation is here: http://iknowsomething.com/ios-sdk-spritekit-sound/

Edit: Fixed missing paren.

Solution 3:

I've had a similar problem with playing audio (though I'm not using audio in an SKAction node) with the same background crash as a result.

I tried to solve this by setting the paused property of my SKScene to YES, but when audio is playing there appears to be a bug in SpriteKit. In this situation, the update method actually gets called after paused is set to YES. Here is my update code:

- (void)update:(CFTimeInterval)currentTime
{
    /* Called before each frame is rendered */

    if (self.paused)
    {
        // Apple bug?
        NSLog(@"update: called while SKView.paused == YES!");
        return;
    }

    // update!
    [_activeLayer update];
}

When that NSLog is traced out, the app will then crash with the GL error.

The only way I've found to solve it is quite heavy handed. I have to remove and deallocate my entire SKView when entering the background.

In applicationDidEnterBackground I call a function in my ViewController that does this:

[self.playView removeFromSuperview];

Ensure that you do not have any strong references to the SKView as it must be deallocated for this to work.

In applicationWillEnterForeground I call a function that rebuilds my SKView like this:

CGRect rect = self.view.frame;

if (UIInterfaceOrientationIsLandscape(self.interfaceOrientation))
{
    CGFloat temp = rect.size.width;
    rect.size.width = rect.size.height;
    rect.size.height = temp;
}

SKView *skView = [[SKView alloc] initWithFrame:rect];

skView.autoresizingMask = UIViewAutoresizingFlexibleHeight |
                        UIViewAutoresizingFlexibleWidth;

[self.view insertSubview:skView atIndex:0];
self.playView = skView;

// Create and configure the scene.
self.myScene = [CustomScene sceneWithSize:skView.bounds.size];
self.myScene.scaleMode = SKSceneScaleModeResizeFill;

// Present the scene.
[skView presentScene:self.myScene];

Yeah, this feels like a total hack, but I think there is a bug in SpriteKit.

I hope this helps.

EDIT

Great accepted answer above. Unfortunately it doesn't work for me because I am using Audio Queues and need music to continue playing when my app is in the background.

Still waiting for a fix from Apple.