L'Audio et l'audio spacial
Nous voulons construire une classe qui va charger, ranger et jouer nos assets audio (.m4a, .mp3, …) pour notre application. Dans un premier temps on ajoutera ce dont on a besoin, ensuite nous l'adapterons pour C++ grâce à CFTypeRef.
Pour ce faire on utilisera le langage Objective-C++ (combiné de Objective-C & C++) ainsi que le Framework Apple (pas de wrapper cpp disponible) PHASE (Physical Audio Spatialization Engine).
Ajoute-le : dans Xcode clique sur : MSLearn.xcodeproj (le premier), dans "Targets" → "LearnMSL MacOS" → Build Phases → "Link Binary With Libraries" → + → Apple SDKs → "PHASE.framework" & ajoute aussi "AVFAudio.framework"
Apprendre de toi-même :
C'est souvent une question de documentation, si tu veux réaliser quelque chose, tu te documentes. D'ailleurs si toi aussi tu crées un programme public, il sera préférable que tu ajoutes un fichier 'README.md' contenant l'explication, …
La classe en Objective-C
Objective-C est le langage natif d'Apple. Pas de class, pas de :: — une classe se déclare avec @interface et s'implémente avec @implementation. Les méthodes commencent par - (instance) ou + (classe). C'est ce que tu lis dans la documentation Apple.
Présente aussi pour comparer avec notre adaptation Objective-C++/C++ que l'on utilise dans nos projets, je démontre d'abord la consultation de la documentation (non-disponible en C++) puis l'équivalent C++, ensuite on adaptera encore pour l'ARC.
/// Objective-C — Exemple — Audio.h — update:18/05/26
#import <PHASE/PHASE.h>
@interface Audio : NSObject
// - désigne une méthode d'instance (+ désignerait une méthode de classe)
- (instancetype)initWithResourcePath:(NSString*)resourcePath; // le chemin de nos fichiers .m4a
@end
/// Objective-C++ — Exemple — Audio.h — update:18/05/26
// Nous allons commencer à écrire notre propre classe dédiée comme suit :
#ifndef AUDIO_H
#define AUDIO_H
#include <string> // std::string
#import <PHASE/PHASE.h> // le framework Apple
#include "NonCopyable.h" // une de nos classes (vue dans ch.canonique)
class Audio : public NonCopyable
{
public:
Audio(const std::string& resourcePath); // le chemin de nos fichiers .m4a
~Audio();
Ouvre la documentation, source de vérité pour tout framework Apple, dense mais structurée : ici. Nous avons toujours accès aux classes Apple avec clic droit sur la 'classe'. Il existe un PDF en ligne sur les spécifications de l'API Metal.
Le menu de droite (sidebar) est organisé par groupes thématiques : Setup, Assets, Events, Mixers… C'est ta boussole. Quand tu cherches une fonctionnalité, commence par le groupe, pas par chercher un nom exact. [C]class [E]enumerator [P]protocol…
C'est une classe, on fait comme d'habitude :
/// Objective-C++ — Exemple — Audio.h — update:18/05/26
private:
PHASEEngine* m_engine; // l'objet nécessaire à notre tâche
};
#endif // AUDIO_H
Il est dit dans PHASE > Setup, avec un exemple, pour créer un objet moteur il faut l'initialiser avec une valeur pour l'argv/paramètre de la méthode d'instance 'initWithUpdateMode:' qui sélectionne le contrôle et le temps de lecture.
Il faut aussi le démarrer (pas de shader ici, on communique avec les haut-parleurs via notre application) et l'arrêter (lorsque l'application se fermera ou que la classe sera détruite si l'on a décidé de ne plus en avoir besoin après un certain temps d'exécution).
Nous savons également qu'il faut allouer de la mémoire pour chaque objet → via les méthodes [[alloc]] mise en place par Apple.
Nous descendons vers PHASEEngine > Topics. Deux modes pour la [M]méthode d'initialisation sont trouvés dans la documentation dont 'PHASEUpdateModeAutomatic' : lire des sons sans avoir à maintenir une boucle de mise à jour stricte.
/// Objective-C — Exemple — Audio.m — update:18/05/26
#import "Audio.h"
@implementation Audio
{
PHASEEngine* _engine; // membre privé — ARC gère la durée de vie
}
- (instancetype)initWithResourcePath:(NSString*)resourcePath
{
self = [super init];
if (self)
{
_engine = [[PHASEEngine alloc] initWithUpdateMode:PHASEUpdateModeAutomatic];
NSError* error = nil;
if (![_engine startAndReturnError:&error])
{
NSLog(@"Error starting PHASE Engine: %@", error.localizedDescription);
}
}
return self;
}
- (void)dealloc // équivalent du destructeur
{
[_engine stop];
}
/// Objective-C++ — Exemple — Audio.mm — update:18/05/26
#include "Audio.h"
Audio::Audio(const std::string& resourcePath)
{ // appel objet Objective-C → 1ière étape : alloc + init
m_engine = [[PHASEEngine alloc] initWithUpdateMode:PHASEUpdateModeAutomatic];
NSError __autoreleasing* error = nil; // juste une classe qui gère les erreurs
// [m_engine startAndReturnError:&error]; → 2ième étape : démarrer le moteur
if (![m_engine startAndReturnError:&error])
{ // gestion de l'erreur à notre sauce
NSLog(@"Error starting PHASE Engine: %@", error.localizedDescription);
assert(false);
}
}
Audio::~Audio()
{ // 3ième et dernière étape : arrêter le moteur
[m_engine stop];
}
Dans la sidebar et/ou la doc du SDK, on descend en recherchant ce que l'on souhaite d'autre de ce framework, on décide également de créer une fonction pour allouer ces classes :
PHASESoundAsset[C] regroupe les données audio source. Le framework nécessite un mélangeur pour lire un actif sonore (comme PHASESamplerNodeDefinition), et les nœuds d'événements sonores combinent l'actif avec un mélangeur. L'actif sonore à un nœud d'événement sonore fourni par l'identifiant passé dans la fonction registerSoundAssetAtURL:identifier:assetType:channelLayout:normalizationMode:error:
La fonction commence par la copie de la classe NSError.
Tout ce que nous voulons c'est PHASESoundAsset, mais la classe nécessite une NSURL, qui elle même nécessite une NSString, tout comme elle nécessite la gestion d'une erreur potentielle, le nom de ton asset individuelle qu'on enverra via l'appel de cette fonction, l'engine, le type d'asset ou pour finir, le mode de normalization.
Ensuite on vérifie que c'est opérationnel : if (!soundAsset).
/// Objective-C — Exemple — Audio.m — update:18/05/26
- (void)loadStereoSound:(NSString*)resourcePath assetName:(NSString*)assetName
{
NSError* error = nil;
NSString* urlString = [NSString stringWithFormat:@"%@/%@", resourcePath, assetName];
NSURL* url = [NSURL URLWithString:urlString];
PHASESoundAsset* soundAsset = [_engine.assetRegistry
registerSoundAssetAtURL:url
identifier:assetName
assetType:PHASEAssetTypeResident
channelLayout:nil
normalizationMode:PHASENormalizationModeDynamic
error:&error];
if (!soundAsset)
{
NSLog(@"Error registering sound asset %@: %@", urlString, error.localizedDescription);
return;
}
AVAudioChannelLayout* channelLayout = [[AVAudioChannelLayout alloc]
initWithLayoutTag:kAudioChannelLayoutTag_Stereo];
PHASEChannelMixerDefinition* channelMixerDefinition = [[PHASEChannelMixerDefinition alloc]
initWithChannelLayout:channelLayout];
PHASESamplerNodeDefinition* samplerNodeDefinition = [[PHASESamplerNodeDefinition alloc]
initWithSoundAssetIdentifier:assetName
mixerDefinition:channelMixerDefinition];
samplerNodeDefinition.playbackMode = PHASEPlaybackModeOneShot;
[samplerNodeDefinition setCalibrationMode:PHASECalibrationModeRelativeSpl level:0];
NSString* eventIdentifier = [NSString stringWithFormat:@"%@_event", assetName];
PHASESoundEventNodeAsset* soundEventAsset = [_engine.assetRegistry
registerSoundEventAssetWithRootNode:samplerNodeDefinition
identifier:eventIdentifier
error:&error];
if (!soundEventAsset)
{
NSLog(@"Error creating sound event for asset: %@", error.localizedDescription);
}
}
/// Objective-C++ — Exemple — Audio.mm — update:18/05/26
void Audio::loadStereoSound(const std::string& resourcePath, const std::string& assetName)
{
NSError* __autoreleasing error = nil;
NSString* urlString = [NSString stringWithFormat:@"%s/%s", resourcePath.c_str(),assetName.c_str()];
NSURL* url = [NSURL URLWithString:urlString];
NSString* assetIdentifier = [NSString stringWithUTF8String:assetName.c_str()];
PHASESoundAsset* soundAsset = [m_engine.assetRegistry
registerSoundAssetAtURL:url
identifier:assetIdentifier
assetType:PHASEAssetTypeResident
channelLayout:nil
normalizationMode:PHASENormalizationModeDynamic
error:&error];
if (!soundAsset)
{
NSLog(@"Error registering sound asset %@: %@", urlString, error.localizedDescription);
assert(false);
}
On veut également stocker plusieurs sons dans une hiérarchie nodes grâce à 'PHASESoundEventNodeAsset'
/// Objective-C++ — Exemple — Audio.mm — update:18/05/26
// pour initialiser les paramètres de 'PHASEChannelMixerDefinition' (framework AVFAudio)
AVAudioChannelLayout* channelLayout = [[AVAudioChannelLayout alloc]
initWithLayoutTag:kAudioChannelLayoutTag_Stereo];
// Prototype si tu n'as pas accès à la documentation dans l'immédiat :
// - (instancetype)initWithChannelLayout:(AVAudioChannelLayout*)layout;
// Pour initialiser les paramètres (signature) de 'PHASESamplerNodeDefinition' nous avons besoin de :
PHASEChannelMixerDefinition* channelMixerDefinition = [[PHASEChannelMixerDefinition alloc]
initWithChannelLayout:channelLayout];
PHASESamplerNodeDefinition* samplerNodeDefinition = [[PHASESamplerNodeDefinition alloc]
initWithSoundAssetIdentifier:[NSString
stringWithUTF8String:assetName.c_str()]
mixerDefinition:channelMixerDefinition];
'OneShot' joue une fois et s'arrête — 'Looping' boucle en continu
samplerNodeDefinition.playbackMode = PHASEPlaybackModeOneShot;
[samplerNodeDefinition setCalibrationMode:PHASECalibrationModeRelativeSpl level:0];
NSString* eventIdentifier = [NSString stringWithFormat:@"%s_event", assetName.c_str()];
PHASESoundEventNodeAsset* soundEventAsset = [m_engine.assetRegistry
registerSoundEventAssetWithRootNode:samplerNodeDefinition
identifier:eventIdentifier
error:&error];
if (!soundEventAsset)
{
NSLog(@"Error creating sound event for asset: %@", error.localizedDescription);
assert(false);
}
}
Ne pas oublier de rajouter les prototypes des fonctions dans le header.
PHASESoundEvent[C] objet/classe qui détermine quel audio jouer.. Je vous laisse découvrir la documentation qui suit le même principe...
/// Objective-C — Exemple — Audio.m — update:18/05/26
- (void)playSoundEvent:(NSString*)identifier
{
NSError* error = nil;
PHASESoundEvent* soundEvent = [[PHASESoundEvent alloc]
initWithEngine:_engine
assetIdentifier:[NSString stringWithFormat:@"%@_event", identifier]
error:&error];
if (!soundEvent)
{
NSLog(@"Unable to play sound event with identifier: %@", identifier);
return;
}
[soundEvent startWithCompletion:nil];
}
@end
/// Objective-C++ — Exemple — Audio.mm — update:18/05/26
void Audio::playSoundEvent(const std::string& identifier)
{
NSError* __autoreleasing error = nil;
PHASESoundEvent* soundEvent = [[PHASESoundEvent alloc]
initWithEngine:m_engine
assetIdentifier:[NSString stringWithFormat:@"%s_event", identifier.c_str()]
error:&error];
if (!soundEvent)
{
NSLog(@"Unable to play sound event with identifier: %s", identifier.c_str());
assert(false);
}
[soundEvent startWithCompletion:nil];
}
Fichier final pour l'inclure en C++
On déclare nos fonctions en public pour les appeler en C++
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/* + + */
/* File: Audio.hpp (Compatible-C++) +++ +++ */
/* + + */
/* By: Laboitederemdal + + */
/* + + */
/* Created: 27/10/2025 15:45:19 + + + + + + */
/* Updated: 18/05/2026 */
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#ifndef AUDIO_HPP
#define AUDIO_HPP
#include <string>
#include <vector>
#include <CoreFoundation/CFBase.h> // pour CFTypeRef
#include "NonCopyable.hpp"
class Audio : public NonCopyable
{
public:
Audio(const std::string& resourcePath);
~Audio();
void loadStereoSound(const std::string& resourcePath, const std::string& assetName);
void playSoundEvent(const std::string& identifier); // init donc renvoi void
private:
struct SoundData // Track backing data to release:
{
CFTypeRef asset;
CFTypeRef event;
std::string assetName;
std::string eventName;
};
std::vector<SoundData> m_soundData;
// Une base « générique » non typée à tout objet Core Foundation et des fonctions polymorphes sur eux
CFTypeRef m_phaseEngine; // Remplace PHASEEngine et fait le pont
};
#endif // AUDIO_HPP
Un header .hpp inclus par du C++ pur ne peut pas contenir de types Objective-C (PHASEEngine*, LongRunningHaptics*…). Le compilateur ne les connaît pas.
CFTypeRef est un pointeur générique de CoreFoundation — typedef const void* — compatible avec n'importe quel objet ObjC. Il joue le rôle de conteneur opaque : le header C++ voit un pointeur void, et le .mm sait ce qu'il contient réellement.
Trois opérations suffisent :
// 1. Stocker — sortir l'objet de l'ARC et en confier la propriété au C++
m_phaseEngine = CFBridgingRetain(engine); // ARC → manuel
_haptics = CFBridgingRetain(lrh);
// 2. Récupérer — cast sans transfert de propriété (__bridge = "juste regarder")
PHASEEngine* engine = (__bridge PHASEEngine*)m_phaseEngine;
LongRunningHaptics* lrh = (__bridge LongRunningHaptics*)_haptics;
// 3. Libérer — dans le destructeur, à la place de l'ARC
CFRelease(m_phaseEngine);
CFRelease(_haptics);
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
/* + + */
/* File: Audio.mm (Compatible-C++) +++ +++ */
/* + + */
/* By: Laboitederemdal + + */
/* + + */
/* Created: 27/10/2025 15:45:19 + + + + + + */
/* Updated: 18/05/2026 */
/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */
#import <PHASE/PHASE.h> // on inclut pas dans le header partagé C++
#include "Audio.hpp"
Audio::Audio(const std::string& resourcePath)
{
NSError __autoreleasing* error = nil;
PHASEEngine* engine = [[PHASEEngine alloc] initWithUpdateMode:PHASEUpdateModeAutomatic];
// démarre le moteur :
if (![engine startAndReturnError:&error])
{
NSLog(@"Error starting PHASE engine: %@", error.localizedDescription);
assert(false);
}
m_phaseEngine = CFBridgingRetain(engine); // ARC → manuel
}
Audio::~Audio()
{
PHASEEngine* engine = (__bridge PHASEEngine*)m_phaseEngine;
[engine stop];
for (auto&& soundData : m_soundData) // On libère manuellement et proprement
{
CFRelease(soundData.event);
CFRelease(soundData.asset);
[engine.assetRegistry unregisterAssetWithIdentifier:
[NSString stringWithUTF8String:soundData.assetName.c_str()] completion:nil];
[engine.assetRegistry unregisterAssetWithIdentifier:
[NSString stringWithUTF8String:soundData.eventName.c_str()] completion:nil];
}
CFRelease(m_phaseEngine);
}
void Audio::loadStereoSound(const std::string& resourcePath, const std::string& assetName)
{
NSError* __autoreleasing error = nil;
PHASEEngine* engine = (__bridge PHASEEngine*)m_phaseEngine;
NSString* urlString = [NSString stringWithFormat:@"%s/%s", resourcePath.c_str(),assetName.c_str()];
NSURL* url = [NSURL URLWithString:urlString];
NSString* assetIdentifier = [NSString stringWithUTF8String:assetName.c_str()];
PHASESoundAsset* soundAsset = [engine.assetRegistry
registerSoundAssetAtURL:url
identifier:assetIdentifier
assetType:PHASEAssetTypeResident
channelLayout:nil
normalizationMode:PHASENormalizationModeDynamic
error:&error];
if (!soundAsset)
{
NSLog(@"Error registering sound asset %@: %@", urlString, error.localizedDescription);
assert(false);
}
AVAudioChannelLayout* channelLayout = [[AVAudioChannelLayout alloc]
initWithLayoutTag:kAudioChannelLayoutTag_Stereo];
PHASEChannelMixerDefinition* channelMixerDefinition = [[PHASEChannelMixerDefinition alloc]
initWithChannelLayout:channelLayout];
PHASESamplerNodeDefinition* samplerNodeDefinition = [[PHASESamplerNodeDefinition alloc]
initWithSoundAssetIdentifier:[NSString
stringWithUTF8String:assetName.c_str()]
mixerDefinition:channelMixerDefinition];
samplerNodeDefinition.playbackMode = PHASEPlaybackModeOneShot;
[samplerNodeDefinition setCalibrationMode:PHASECalibrationModeRelativeSpl level:0];
NSString* eventIdentifier = [NSString stringWithFormat:@"%s_event", assetName.c_str()];
PHASESoundEventNodeAsset* soundEventAsset = [engine.assetRegistry
registerSoundEventAssetWithRootNode:samplerNodeDefinition
identifier:eventIdentifier
error:&error];
if (!soundEventAsset)
{
NSLog(@"Error creating sound event for asset: %@", error.localizedDescription);
assert(false);
}
// Save sound asset and event for releasing durning shutdown
// CFBridgingRetain transfers ownership from ARC to manual management,
// so the destructor can release these assets deliberately.
m_soundData.emplace_back( SoundData {
CFBridgingRetain(soundAsset), // asset
CFBridgingRetain(soundEventAsset), // event
assetIdentifier.UTF8String, // assetName
eventIdentifier.UTF8String // eventName
});
}
void Audio::playSoundEvent(const std::string& identifier)
{
NSError* __autoreleasing error = nil;
PHASEEngine* engine = (__bridge PHASEEngine*)m_phaseEngine;
PHASESoundEvent* soundEvent = [[PHASESoundEvent alloc]
initWithEngine:engine
assetIdentifier:[NSString stringWithFormat:@"%s_event", identifier.c_str()]
error:&error];
if (!soundEvent)
{
NSLog(@"Unable to play sound event with identifier: %s", identifier.c_str());
assert(false);
}
[soundEvent startWithCompletion:nil];
}
Utilisation immédiate
// Usage en Objective-C pour comparer
Audio* audio = [[Audio alloc] initWithResourcePath:@"/path/to/sounds"];
[audio loadStereoSound:@"/path/to/sounds" assetName:@"victoire.m4a"];
[audio playSoundEvent:@"victoire.m4a"];
/// C++ — Renderer.hpp — update:18/05/26
#include "Audio.hpp"
private:
std::unique_ptr<Audio> m_audioEngine;
/// C++ — Renderer.cpp — update:18/05/26 — constructeur, peu importe
{
m_audioEngine = std::make_unique<Audio>(resourcePath);
m_audioEngine->loadStereoSound(resourcePath, "mrSpoiler.mp3");
m_audioEngine->loadStereoSound(resourcePath, "Test.m4a");
}
/// C++ — Renderer.cpp — update:18/05/26 — update(), où tu veux
m_globalUniforms.cameraUniforms = m_camera.updateUniforms();
if (frame % 5000 == 10)
m_audioEngine->playSoundEvent("mrSpoiler.mp3");
if (m_input.keySpace)
m_audioEngine->playSoundEvent("Test.m4a");
Notre classe nous permet de loader des fichiers externes au format .mp3 & .m4a dès qu'on le décide. Nous pouvons commencer à jouer les morceaux à la demande (spam).