Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a7cdd52
audio: add polyphase sinc resampler and SourceSampleRate support
bassdr May 17, 2026
181a153
audio: optional float pipeline end-to-end (opt-in, s16 default)
bassdr May 30, 2026
a913932
Single source of truth for the F32 pipeline setting, fixing issues wh…
bassdr Jun 12, 2026
dbe90d1
audio: add IMidiSynth interface, MidiSynthManager, and FluidSynth bac…
bassdr May 30, 2026
34863ff
audio(FluidSynth): optional Graham-Smith volume curve modulators
bassdr May 29, 2026
7826959
audio(FluidSynth): SetReverbParams runtime method
bassdr May 30, 2026
2576688
audio(FluidSynth): LoadSoundFontFromMemory via custom sfloader
bassdr May 31, 2026
0be17ef
audio(FluidSynth): multi-sfont load + ProgramSelect + preset iteration
bassdr May 31, 2026
3287acc
audio(FluidSynth): expose voice-stat accessors
bassdr Jun 5, 2026
c7f1ed7
audio(FluidSynth): ratio-based bend helpers + direct pitch-wheel range
bassdr Jun 5, 2026
6176581
audio(FluidSynth): allow to raise synth.polyphony through caller-conf…
bassdr Jun 7, 2026
4e49964
audio(FluidSynth): add runtime SetMasterGain for master-volume tracking
bassdr Jun 13, 2026
4b0b978
Cleanup in-code comments
bassdr Jun 13, 2026
49406cf
audio: upmix the float pipeline to 5.1 for Raw 5.1 too
bassdr Jun 13, 2026
f910031
build: find FluidSynth via cmake config with pkg-config fallback
bassdr Jun 14, 2026
bf0c7cf
audio(FluidSynth): use non-deprecated per-group reverb setters
bassdr Jun 14, 2026
6e1c2ef
Fix fluidsynth include
bassdr Jun 14, 2026
0371056
fix(audio): route FluidSynth logging into the Ship logger
bassdr Jun 16, 2026
a8d6da8
refactor(FluidSynth): tidy log redirection + clang-format pass
bassdr Jun 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion include/libultraship/bridge/audiobridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,30 @@ API_EXPORT AudioChannelsSetting GetAudioChannels();
API_EXPORT int32_t GetNumAudioChannels();

/**
* @brief Submits a frame of PCM audio to the audio output device.
* @brief Submits a frame of s16 PCM audio to the audio output device (legacy).
*
* Default entry point preserved for libultraship consumers on the s16
* audio path. Forwards to AudioPlayer::Play(uint8_t*, size_t); valid only
* when the player is in s16 mode (the default).
*
* @param buf Interleaved sample data (stereo: L,R,… or surround: FL,FR,C,LFE,SL,SR,…).
* @param len Length of @p buf in bytes.
*/
API_EXPORT void AudioPlayerPlayFrame(const uint8_t* buf, size_t len);

/**
* @brief Submits a frame of float PCM audio to the audio output device.
*
* Float-pipeline entry point. Valid only when the player has been switched
* to float mode (see AudioPlayer::SetUseFloatPipeline). The full audio
* path — resample / optional mix-source sum / surround decode — runs in
* float at the device's output rate.
*
* @param buf Interleaved stereo float samples (L, R, L, R, …) in nominal [-1, 1] range.
* @param frames Number of stereo frames in @p buf.
*/
API_EXPORT void AudioPlayerPlayFrameF32(const float* buf, size_t frames);

/**
* @brief Changes the audio channel configuration at runtime.
*
Expand Down
35 changes: 35 additions & 0 deletions include/ship/audio/Audio.h
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#pragma once

#include <atomic>
#include <functional>
#include <string>
#include <memory>
#include <vector>
Expand Down Expand Up @@ -68,6 +70,33 @@ class Audio {
*/
AudioChannelsSetting GetAudioChannels() const;

/** @brief Returns whether the float (HD) audio pipeline is currently active. */
bool IsUsingFloatPipeline() const;

/**
* @brief Single authority for the float-pipeline mode.
*
* Updates the live flag, the settings new players inherit, and the current
* player (reopening its device in the matching format). Everything else --
* the producer's PlayF32-vs-Play choice and any newly constructed player --
* derives from this, so there is exactly one place the mode is owned.
*
* @return true if applied; false if the current player refused float mode
* (in which case the authority is reverted to the player's actual mode).
*/
bool SetUseFloatPipeline(bool enabled);

/**
* @brief Registers a callback invoked whenever a new AudioPlayer is
* initialised (backend switch, fallback to Null, startup).
*
* The new player already inherits the float-pipeline mode; this hook exists
* so the host can re-attach instance-bound state the player cannot carry
* across a rebuild (e.g. a FluidSynth mix source). Pass an empty function to
* clear it.
*/
void SetOnAudioPlayerInitialized(std::function<void()> callback);

protected:
/** @brief (Re)initialises the AudioPlayer for the current backend and channel settings. */
void InitAudioPlayer();
Expand Down Expand Up @@ -95,5 +124,11 @@ class Audio {
AudioSettings mAudioSettings;
std::shared_ptr<std::vector<AudioBackend>> mAvailableAudioBackends;
std::shared_ptr<Config> mConfig;

// Single source of truth for the float-pipeline mode. Lock-free so the audio
// producer can read it cheaply; written only by SetUseFloatPipeline, which
// also mirrors it into mAudioSettings (the template new players inherit).
std::atomic<bool> mUseFloatPipeline{ false };
std::function<void()> mOnAudioPlayerInitialized;
};
} // namespace Ship
132 changes: 122 additions & 10 deletions include/ship/audio/AudioPlayer.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
#include "stddef.h"
#include <string>
#include <memory>
#include <functional>
#include "ship/audio/AudioChannelsSetting.h"
#include "ship/audio/AudioResampler.h"
#include "ship/audio/SoundMatrixDecoder.h"

namespace Ship {
Expand All @@ -12,11 +14,20 @@ namespace Ship {
* @brief Configuration parameters shared by all AudioPlayer backends.
*/
struct AudioSettings {
int32_t SampleRate = 44100; ///< Output sample rate in Hz.
int32_t SampleRate = 48000; ///< Output sample rate in Hz.
int32_t SourceSampleRate = 0; ///< Source sample rate in Hz. (0 = same as SampleRate, no resampling)
int32_t SampleLength = 1024; ///< Number of samples per audio frame.
int32_t DesiredBuffered = 2480; ///< Target number of frames to keep buffered.
AudioChannelsSetting ChannelSetting =
AudioChannelsSetting::audioStereo; ///< Channel mode (stereo / 5.1 matrix / 5.1 raw).

/// When true, the AudioPlayer pipeline (Play / resampler / matrix decoder
/// / backend device format) runs in 32-bit float. Enables the optional
/// MixSource hook for sources rendered at the device output rate. When
/// false (default), the pipeline is interleaved int16 — same byte layout
/// and entry points the AudioPlayer always had, so existing libultraship
/// consumers keep working with no code change.
bool UseFloatPipeline = false;
};

/**
Expand All @@ -38,7 +49,7 @@ class AudioPlayer {
*/
AudioPlayer(AudioSettings settings) : mAudioSettings(settings) {
}
~AudioPlayer();
virtual ~AudioPlayer();

/**
* @brief Calls DoInit() and sets the initialised flag on success.
Expand All @@ -55,25 +66,42 @@ class AudioPlayer {
virtual int32_t Buffered() = 0;

/**
* @brief Submits a frame of PCM audio to the output device.
* @brief Submits a frame of PCM audio to the output device — legacy s16 path.
*
* If 5.1 surround output is configured and the channel setting requires matrix
* decoding, the stereo @p buf is first decoded to 6-channel surround before
* being passed to DoPlay().
* Default entry point preserved for libultraship consumers. Samples are
* interleaved signed 16-bit; the legacy resampler / matrix-decoder
* boundaries do the (lossy) s16↔float conversions internally. Calls
* here when @c UseFloatPipeline is true are a configuration mistake
* and emit a warning + drop the buffer.
*
* @param buf Interleaved samples:
* - Stereo: (L, R, L, R, …)
* - 5.1: (FL, FR, C, LFE, SL, SR, …)
* @param buf Interleaved s16 samples (Stereo: (L,R,…); 5.1: (FL,FR,C,LFE,SL,SR,…)).
* @param len Length of @p buf in bytes.
*/
void Play(const uint8_t* buf, size_t len);

/**
* @brief Submits a frame of PCM audio to the output device — float pipeline.
*
* Only valid when @c UseFloatPipeline is true. The audio path stays in
* 32-bit float through resample, optional MixSource summing + soft-clip,
* surround decode, and into the backend. The MixSource (if set) runs at
* the device output rate, so any secondary source can render at native
* device quality without traversing the resampler.
*
* @param buf Interleaved stereo float samples in nominal [-1, 1].
* @param frames Number of stereo frames in @p buf.
*/
void Play(const float* buf, size_t frames);

/** @brief Returns true if Init() has been called and succeeded. */
bool IsInitialized();

/** @brief Returns the configured output sample rate in Hz. */
int32_t GetSampleRate() const;

/** @brief Returns the configured source sample rate in Hz. */
int32_t GetSourceSampleRate() const;

/** @brief Returns the configured number of samples per audio frame. */
int32_t GetSampleLength() const;

Expand All @@ -89,6 +117,12 @@ class AudioPlayer {
*/
void SetSampleRate(int32_t rate);

/**
* @brief Sets the source sample rate.
* @param rate New sample rate in Hz.
*/
void SetSourceSampleRate(int32_t rate);

/**
* @brief Sets the number of samples per audio frame.
* @param length New frame size in samples.
Expand Down Expand Up @@ -118,6 +152,49 @@ class AudioPlayer {
*/
int32_t GetNumOutputChannels() const;

/**
* @brief Callback signature for a secondary stereo audio source mixed in
* after the resampler.
*
* The callback fills @p stereoOut with @p frames frames of interleaved
* stereo float at the device's output rate (GetSampleRate()), which
* lets the source bypass the resampler entirely — the resampler runs
* only over the primary input stream. The mix sums the two sources
* with a tanh-style soft-clip before surround decoding (if any).
*/
using MixSource = std::function<void(float* stereoOut, int frames)>;

/**
* @brief Installs a secondary audio source whose contribution is mixed
* in at the output rate, post-resampler.
*
* Only meaningful when @c UseFloatPipeline is true (the s16 legacy path
* has no mix step). Pass @c nullptr to remove the source. Thread-safe
* with respect to the audio thread only in the sense that
* std::function assignment is sequentially consistent on x86; callers
* in practice swap this once at synth install/teardown.
*
* @return true if accepted; false (and ignored) when the player is in
* the s16 legacy mode.
*/
bool SetMixSource(MixSource source);

/**
* @brief Switches the pipeline between legacy s16 and float HD modes
* at runtime.
*
* Closes the audio device, updates @c UseFloatPipeline, and reopens
* the device with the matching format. The Play overload that matches
* the new mode is the only one valid until the next switch.
*
* @return true if the device successfully reinitialised in the new
* mode. On failure the previous mode is restored.
*/
bool SetUseFloatPipeline(bool enabled);

/** @brief Returns whether the float pipeline mode is active. */
bool IsUsingFloatPipeline() const { return mAudioSettings.UseFloatPipeline; }

protected:
/**
* @brief Opens and configures the platform audio device.
Expand Down Expand Up @@ -146,8 +223,43 @@ class AudioPlayer {
virtual void DoPlay(const uint8_t* buf, size_t len) = 0;

private:
/// Picks the right channel count (stereo for float mode, output channel
/// count for legacy s16 mode) and (re)constructs mResampler. No-op when
/// the rates already match.
void RebuildResampler();

/// Whether a stereo->5.1 matrix decoder is required for the current
/// (channel, pipeline) combination. Matrix 5.1 always needs it; Raw 5.1
/// needs it only in float mode (there the source is stereo, so the engine's
/// native 6-channel output isn't available to pass through). Raw 5.1 on the
/// s16 path keeps passing the engine's native 6 channels straight through.
bool NeedsMatrixDecoder() const;

/// (Re)creates or releases mSoundMatrixDecoder to match NeedsMatrixDecoder().
/// Call after any channel-setting or pipeline-mode change.
void EnsureMatrixDecoder();

std::unique_ptr<SoundMatrixDecoder>
mSoundMatrixDecoder; ///< Stereo-to-surround decoder (active in matrix-5.1 mode).
mSoundMatrixDecoder; ///< Stereo-to-surround decoder (Matrix 5.1, or Raw 5.1 in float mode).
std::unique_ptr<AudioResampler> mResampler;

// Fixed-size scratch buffers — no heap allocation on the audio hot path.
// Sized for the worst-case ratio and channel count of the data the buffer
// holds at its stage. mResampleBuf holds *stereo* output-rate frames
// (resample step), so 2 channels suffice; mMixSourceBuf likewise holds
// stereo frames the secondary source writes into. mSurroundBuf is sized
// for 6 channels of output-rate frames (matrix-5.1 final output) so the
// decoder has somewhere to write before DoPlay. 16384 gives comfortable
// headroom for 32k→48k @ SampleLength=1024 (ceil(1024 * 3/2) * 6 = 9216)
// and for higher device rates (e.g. 32k→96k).
static constexpr size_t kResampleBufSamples = 16384;
std::array<float, kResampleBufSamples> mResampleBuf{};
std::array<float, kResampleBufSamples> mMixSourceBuf{};
// Legacy s16 path uses its own scratch so both code paths can coexist
// without retypeing the float buffer. 16384 × 2 B = 32 KB.
std::array<int16_t, kResampleBufSamples> mResampleBufS16{};

MixSource mMixSource;

AudioSettings mAudioSettings;
bool mInitialized = false;
Expand Down
90 changes: 90 additions & 0 deletions include/ship/audio/AudioResampler.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#pragma once

#include <cstdint>
#include <vector>

namespace Ship {

/*
* AudioResampler — polyphase sinc resampler for integer ratios.
*
* Designed for the specific case of console audio upsampling from 32000 Hz
* to 48000 Hz (ratio 3/2 exact). Works for any integer ratio P/Q where
* P = outRate / gcd(outRate, inRate) and Q = inRate / gcd(outRate, inRate).
*
* Architecture:
* - Polyphase decomposition of a windowed-sinc lowpass filter.
* - Filter cutoff at min(inRate, outRate) / 2 to prevent aliasing.
* - Kaiser window (beta=6) for a good stopband attenuation (~60 dB).
* - For 32k→48k: P=3, Q=2, 8 taps per phase → 24 total filter coefficients.
*
* Usage:
* AudioResampler r(32000, 48000, numChannels);
* r.Process(inFloat, inFrames, outFloat, outFrames);
*
* Process() returns the number of output frames actually written.
* State (history samples) is preserved between calls for continuous streams.
* Samples are interleaved float in nominal [-1, 1] range; the polyphase
* filter is unity-gain so peaks slightly above 1.0 may pass through and
* should be soft-clipped by the caller (or before reaching the backend).
*/
class AudioResampler {
public:
AudioResampler(int32_t inRate, int32_t outRate, int32_t numChannels);

/* Resample inFrames input frames into outBuf.
* Returns number of output frames written.
* outBuf must be large enough for ceil(inFrames * outRate / inRate) frames.
*
* Two overloads:
* - float in / float out is the canonical path used by the float audio
* pipeline. Samples are interleaved float in nominal [-1, 1].
* - int16_t in / int16_t out is the legacy entry point preserved for
* libultraship consumers still on the s16 path. It converts at the
* boundaries and clamps the output to the s16 range; the inner DSP
* is identical (the filter coefficients live in float either way). */
int32_t Process(const float* inBuf, int32_t inFrames, float* outBuf, int32_t maxOutFrames);
int32_t Process(const int16_t* inBuf, int32_t inFrames, int16_t* outBuf, int32_t maxOutFrames);

/* Maximum output frames for a given number of input frames. */
int32_t MaxOutputFrames(int32_t inFrames) const;

/* Reset history (e.g. on stream discontinuity). */
void Reset();

private:
int32_t mInRate;
int32_t mOutRate;
int32_t mNumChannels;

/* Rational ratio P/Q after GCD reduction */
int32_t mP; /* upsample factor */
int32_t mQ; /* downsample factor */

/* Polyphase filter — mNumPhases phases × mTapsPerPhase taps */
static constexpr int kTapsPerPhase = 8;
int32_t mNumPhases; /* = P */
std::vector<float> mCoeffs; /* [phase * kTapsPerPhase + tap] */

/* Current phase index in [0, P) */
int32_t mPhase;

/* History buffer: kTapsPerPhase-1 frames per channel for convolution state */
std::vector<float> mHistory; /* [(kTapsPerPhase-1) * numChannels] */

void BuildFilter();
static float BesselI0(float x);
static float KaiserWindow(int n, int N, float beta);
static float Sinc(float x);

static inline int32_t GCD(int32_t a, int32_t b) {
while (b) {
int32_t t = b;
b = a % b;
a = t;
}
return a;
}
};

} // namespace Ship
2 changes: 1 addition & 1 deletion include/ship/audio/CoreAudioAudioPlayer.h
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ class CoreAudioAudioPlayer : public AudioPlayer {
* @param settings Sample rate, buffer size, desired buffered frames, and channel mode.
*/
CoreAudioAudioPlayer(AudioSettings settings);
~CoreAudioAudioPlayer();
~CoreAudioAudioPlayer() override;

/**
* @brief Returns the number of audio frames currently queued in the ring buffer.
Expand Down
Loading