Skip to content

Commit addcdc9

Browse files
authored
Implemented IOSAudioService (#347)
* Implemented AudioService for iOS * Changed sound to be possibly played multiple times simultaneously (like JavaFX AudioClip) * Considered José Pereda comments for PR, and added pendingPlay optimisation * Resolved PR comments
1 parent e88888d commit addcdc9

File tree

5 files changed

+564
-0
lines changed

5 files changed

+564
-0
lines changed
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
/*
2+
* Copyright (c) 2023, Gluon
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
*
17+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
18+
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
19+
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
20+
* DISCLAIMED. IN NO EVENT SHALL GLUON BE LIABLE FOR ANY
21+
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
22+
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
23+
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
24+
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
26+
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27+
*/
28+
package com.gluonhq.attach.audio.impl;
29+
30+
import com.gluonhq.attach.audio.Audio;
31+
import com.gluonhq.attach.audio.AudioService;
32+
import com.gluonhq.attach.storage.StorageService;
33+
34+
import java.io.File;
35+
import java.io.InputStream;
36+
import java.net.URL;
37+
import java.nio.file.Files;
38+
import java.nio.file.Path;
39+
import java.util.Optional;
40+
import java.util.concurrent.Executors;
41+
import java.util.concurrent.ScheduledExecutorService;
42+
import java.util.logging.Logger;
43+
44+
/**
45+
* @author Bruno Salmon
46+
*/
47+
public class IOSAudioService implements AudioService {
48+
49+
private static final Logger LOG = Logger.getLogger(IOSAudioService.class.getName());
50+
51+
static {
52+
System.loadLibrary("Audio");
53+
initAudio();
54+
}
55+
56+
@Override
57+
public Optional<Audio> loadSound(URL url) {
58+
return loadAudio(url, false);
59+
}
60+
61+
@Override
62+
public Optional<Audio> loadMusic(URL url) {
63+
return loadAudio(url, true);
64+
}
65+
66+
private Optional<Audio> loadAudio(URL url, boolean music) {
67+
if (url != null) {
68+
try {
69+
String fileLocation = copyToPrivateStorageIfNeeded(url);
70+
if (!fileLocation.isEmpty()) {
71+
int id = loadSoundImpl(fileLocation, music);
72+
if (id >= 0) // A negative value means the sound couldn't be loaded by iOS
73+
return Optional.of(new IOSAudio(id));
74+
}
75+
} catch (Exception e) {
76+
LOG.fine("Error while loading audio " + url + ": " + e);
77+
}
78+
}
79+
return Optional.empty();
80+
}
81+
82+
83+
// native
84+
private static native void initAudio(); // A call to this method is necessary before using the other native methods
85+
private static native int loadSoundImpl(String url, boolean music);
86+
private static native void setLooping(int id, boolean looping);
87+
private static native void setVolume(int id, double volume);
88+
private static native void play(int id);
89+
private static native void pause(int id);
90+
private static native void stop(int id);
91+
private static native void dispose(int id);
92+
93+
private File privateStorage;
94+
95+
/**
96+
* Copy file (if it doesn't exist) to private storage.
97+
* Throws an exception if any error occurred during copying.
98+
*
99+
* @param url where the file is
100+
* @return full path to file in private storage where it was copied
101+
*/
102+
private String copyToPrivateStorageIfNeeded(URL url) throws Exception {
103+
String extForm = url.toExternalForm();
104+
// iOS only supports audio local files, it doesn't support http streaming for example. So when it's not a local
105+
// file, we need to copy it first to the private storage before being able to play it.
106+
if (!extForm.startsWith("file:")) { // Note: this also applies for "resource:" as GraalVM resources can't be directly accessed by iOS
107+
108+
String fileName = extForm.substring(extForm.lastIndexOf("/") + 1);
109+
110+
if (privateStorage == null) {
111+
privateStorage = StorageService.create()
112+
.flatMap(StorageService::getPrivateStorage)
113+
.orElseThrow(() -> new RuntimeException("Error accessing Private Storage folder"));
114+
}
115+
116+
Path file = privateStorage.toPath()
117+
.resolve("assets")
118+
.resolve("audio")
119+
.resolve(fileName);
120+
121+
if (!Files.exists(file)) {
122+
if (!Files.exists(file.getParent())) {
123+
Files.createDirectories(file.getParent());
124+
}
125+
126+
try (InputStream input = url.openStream()) {
127+
Files.copy(input, file);
128+
}
129+
}
130+
131+
extForm = file.toUri().toString();
132+
}
133+
134+
return extForm;
135+
}
136+
137+
// All native calls are executed in a background thread to not hold the caller thread (which is probably the UI
138+
// thread). In particular the call to the play() method has been observed to take up to 50ms which is
139+
// noticeable in apps like games with 60 FPS (where time between frames = 16ms).
140+
private final static ScheduledExecutorService nativeExecutor = Executors.newSingleThreadScheduledExecutor();
141+
142+
private static class IOSAudio implements Audio {
143+
144+
private final int id; // identifier of the Audio to be passed to the native service
145+
private boolean pendingPlay; // flag used by play() to alleviate the Audio flow in extreme situations
146+
private boolean disposed; // true after calling dispose(), making this instance unusable anymore
147+
148+
public IOSAudio(int id) {
149+
this.id = id;
150+
}
151+
152+
@Override
153+
public void setLooping(boolean looping) {
154+
if (!disposed) {
155+
nativeExecutor.execute(() -> IOSAudioService.setLooping(id, looping));
156+
}
157+
}
158+
159+
@Override
160+
public void setVolume(double volume) {
161+
if (!disposed) {
162+
nativeExecutor.execute(() -> IOSAudioService.setVolume(id, volume));
163+
}
164+
}
165+
166+
@Override
167+
public void play() {
168+
// We set pendingPlay to true before the native play() call, and then back to false after that call.
169+
// In extreme situations (like observed with SpaceFX with many simultaneous explosions sounds), it can
170+
// happen that the game calls play() again even before the previous call has been executed. In that case,
171+
// we just drop that second call, as it doesn't make sense to start the same sound twice so closely. And
172+
// most important, this improves the performance (the game was noticeably slower when the native iOS sound
173+
// system was not alleviate in this way).
174+
if (!disposed && !pendingPlay) {
175+
pendingPlay = true;
176+
nativeExecutor.execute(() -> {
177+
IOSAudioService.play(id);
178+
pendingPlay = false;
179+
});
180+
}
181+
}
182+
183+
@Override
184+
public void pause() {
185+
if (!disposed) {
186+
nativeExecutor.execute(() -> IOSAudioService.pause(id));
187+
}
188+
}
189+
190+
@Override
191+
public void stop() {
192+
if (!disposed) {
193+
nativeExecutor.execute(() -> IOSAudioService.stop(id));
194+
}
195+
}
196+
197+
@Override
198+
public void dispose() {
199+
if (!disposed) {
200+
nativeExecutor.execute(() -> IOSAudioService.dispose(id));
201+
disposed = true;
202+
}
203+
}
204+
205+
@Override
206+
public boolean isDisposed() {
207+
return disposed;
208+
}
209+
}
210+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/*
2+
* Copyright (c) 2023, Gluon
3+
*
4+
* This program is free software: you can redistribute it and/or modify
5+
* it under the terms of the GNU General Public License as published by
6+
* the Free Software Foundation, either version 3 of the License, or
7+
* (at your option) any later version.
8+
9+
* This program is distributed in the hope that it will be useful,
10+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
* GNU General Public License for more details.
13+
*
14+
* You should have received a copy of the GNU General Public License
15+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
*
17+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
18+
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
19+
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
20+
* DISCLAIMED. IN NO EVENT SHALL GLUON BE LIABLE FOR ANY
21+
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
22+
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
23+
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
24+
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
25+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
26+
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
27+
*/
28+
29+
#import <UIKit/UIKit.h>
30+
#import <AVFoundation/AVFoundation.h>
31+
32+
#include "jni.h"
33+
#include "AttachMacros.h"
34+
35+
@interface Audio : NSObject {
36+
AVAudioPlayer *musicPlayer; // Used for music only (not sounds)
37+
NSData *soundBuffer; // Used for sound only (not music)
38+
NSMutableArray *soundPlayers; // Sounds may be played multiple times simultaneously
39+
}
40+
41+
@property(nonatomic, retain) AVAudioPlayer *musicPlayer;
42+
@property(nonatomic, retain) NSData *soundBuffer;
43+
@property(nonatomic, retain) NSMutableArray *soundPlayers;
44+
45+
@end
46+
47+
48+
@interface AudioService : UIViewController <UIApplicationDelegate> {}
49+
- (int) loadSoundImpl:(NSString *)url music:(bool)music;
50+
- (void) setLooping:(int)index looping:(bool)looping;
51+
- (void) setVolume:(int)index volume:(double)volume;
52+
- (void) play:(int)index;
53+
- (void) pause:(int)index;
54+
- (void) stop:(int)index;
55+
- (void) dispose:(int)index;
56+
- (void) logMessage:(NSString *)format, ...;
57+
@end

0 commit comments

Comments
 (0)