Add stream HRTF inputs and drop unused renderer gain
ci / ubuntu-latest (push) Failing after 17s
ci / macos-latest (push) Has been cancelled
ci / windows-latest (push) Has been cancelled

This commit is contained in:
2026-09-28 19:01:05 +08:00
parent e46b52c3d9
commit 697f130b66
7 changed files with 209 additions and 27 deletions
+5 -1
View File
@@ -2,7 +2,11 @@
#include <stdint.h> #include <stdint.h>
#if defined(_WIN32) /* EJOC_STATIC: this branch exists for building the sources directly into an application, where nothing is imported or exported. */
#if defined(EJOC_STATIC)
#define EJOC_API
#define EJOC_CALL __cdecl
#elif defined(_WIN32)
#if defined(EJOC_BUILD_DLL) #if defined(EJOC_BUILD_DLL)
#define EJOC_API __declspec(dllexport) #define EJOC_API __declspec(dllexport)
#else #else
+5 -1
View File
@@ -41,7 +41,11 @@
#define JOC_TASK_RESULT_VERSION 2u #define JOC_TASK_RESULT_VERSION 2u
#define JOC_EVENT_VERSION 1u #define JOC_EVENT_VERSION 1u
#if defined(_WIN32) /* JOC_STATIC: this branch exists for building the sources directly into an application, where nothing is imported or exported. */
#if defined(JOC_STATIC)
#define JOC_API
#define JOC_CALL __cdecl
#elif defined(_WIN32)
#if defined(JOC_BUILD_DLL) #if defined(JOC_BUILD_DLL)
#define JOC_API __declspec(dllexport) #define JOC_API __declspec(dllexport)
#else #else
+16
View File
@@ -25,6 +25,12 @@
#include "joc_core.h" #include "joc_core.h"
/* JOC_STATIC (building the sources directly into an application): joc_core.h above already installs the empty JOC_API. */
#if defined(JOC_STATIC) && !defined(JOC_API)
#define JOC_API
#define JOC_CALL __cdecl
#endif
#ifdef __cplusplus #ifdef __cplusplus
extern "C" { extern "C" {
#endif #endif
@@ -58,6 +64,16 @@ typedef struct joc_stream_config {
double gain_db; /* default 0 */ double gain_db; /* default 0 */
uint32_t native_threads; uint32_t native_threads;
uint32_t reserved; uint32_t reserved;
/* Binaural HRTF input, the same three shapes joc_task_config accepts: when
* hrtf_sofa_path is set the library compiles it with hrtf_cache_policy /
* hrtf_cache_dir / hrtf_radius_m and hrtf_path is unused; when
* personalized_headphone_path is set the Rosella runtime renders instead.
* hrtf_path stays the fallback/advanced input that reads a .jochrtf directly. */
const char* hrtf_sofa_path; /* SOFA SimpleFreeFieldHRIR input */
const char* personalized_headphone_path; /* Rosella .personalized_headphone input */
const char* hrtf_cache_dir; /* disk cache directory for SOFA compilation */
uint32_t hrtf_cache_policy; /* joc_hrtf_cache_policy: 0 none, 1 memory, 2 disk */
double hrtf_radius_m; /* SOFA measurement-radius shell, default 1.0 */
} joc_stream_config; } joc_stream_config;
typedef struct joc_stream_buffer { typedef struct joc_stream_buffer {
+10
View File
@@ -33,6 +33,16 @@ joc::stream::Config to_config(const joc_stream_config& config) {
} }
out.gain_db = config.gain_db; out.gain_db = config.gain_db;
out.native_threads = config.native_threads; out.native_threads = config.native_threads;
// The binaural HRTF inputs of joc_task_config, copied with the same defaults:
// the policy is taken verbatim (0 is "none", a real choice, not "unset") and
// the radius keeps its documented default of 1.0 when the field is not set.
if (config.hrtf_sofa_path != nullptr) { out.hrtf_sofa_path = config.hrtf_sofa_path; }
if (config.personalized_headphone_path != nullptr) {
out.personalized_headphone_path = config.personalized_headphone_path;
}
if (config.hrtf_cache_dir != nullptr) { out.hrtf_cache_dir = config.hrtf_cache_dir; }
out.hrtf_cache_policy = config.hrtf_cache_policy;
if (config.hrtf_radius_m > 0.0) { out.hrtf_radius_m = config.hrtf_radius_m; }
return out; return out;
} }
+127 -2
View File
@@ -7,6 +7,8 @@
#include "adm/adm_metadata.h" #include "adm/adm_metadata.h"
#include "foundation/status.h" #include "foundation/status.h"
#include "hrtf/jochrtf.h" #include "hrtf/jochrtf.h"
#include "hrtf/rosella_model.h"
#include "hrtf/sofa_cache.h"
#include "joc_bitstream/joc_parser.h" #include "joc_bitstream/joc_parser.h"
#include "joc_core/objects16.h" #include "joc_core/objects16.h"
@@ -53,6 +55,11 @@ void Stream::reset_state() {
if (binaural_ready_) { if (binaural_ready_) {
binaural_.reset(); binaural_.reset();
} }
if (rosella_ready_) {
rosella_.reset();
}
rosella_pending_.clear();
rosella_read_offset_ = 0;
} }
Status Stream::create(const Config& config) { Status Stream::create(const Config& config) {
@@ -96,9 +103,54 @@ Status Stream::create(const Config& config) {
output_channels_ = speaker_.layout.channel_count; output_channels_ = speaker_.layout.channel_count;
} else if (config_.output == JOC_STREAM_OUT_BINAURAL) { } else if (config_.output == JOC_STREAM_OUT_BINAURAL) {
binaural_enabled_ = true; binaural_enabled_ = true;
// The HRTF input precedence is joc_task_config's: a Rosella
// .personalized_headphone wins over a SOFA that the library compiles, and
// the compiled .jochrtf (hrtf_path) stays the fallback input.
if (!config_.personalized_headphone_path.empty()) {
hrtf::RosellaModel model;
Status status =
hrtf::load_personalized_headphone(config_.personalized_headphone_path, &model);
hrtf::RosellaRenderOptions render_options;
if (config_.binaural_mode == JOC_BINAURAL_NEAR) {
render_options.profile = hrtf::RosellaProfile::Near;
} else if (config_.binaural_mode == JOC_BINAURAL_FAR) {
render_options.profile = hrtf::RosellaProfile::Far;
}
render_options.object_delay_samples = config_.object_delay_samples;
render_options.tail_seconds = config_.tail_seconds;
if (status.ok()) {
status = rosella_.open(model, render_options);
}
if (!status.ok()) {
return Status::fail(status.code(), stage::kRender,
"binaural setup failed: " + status.message());
}
rosella_ready_ = true;
output_channels_ = 2;
} else {
hrtf::Field field; hrtf::Field field;
hrtf::Kernels kernels; hrtf::Kernels kernels;
Status status = hrtf::load_jochrtf(config_.hrtf_path, &field); Status status = Status::success();
if (!config_.hrtf_sofa_path.empty()) {
// The .jochrtf is an internal cache: the SOFA is the user-facing input.
hrtf::SofaFieldRequest request;
request.sofa_path = config_.hrtf_sofa_path;
request.options.shell_radius_m = config_.hrtf_radius_m;
request.cache_dir = config_.hrtf_cache_dir;
switch (config_.hrtf_cache_policy) {
case JOC_HRTF_CACHE_NONE: request.policy = hrtf::CachePolicy::None; break;
case JOC_HRTF_CACHE_DISK: request.policy = hrtf::CachePolicy::Disk; break;
default: request.policy = hrtf::CachePolicy::Memory; break;
}
std::string cache_path;
status = hrtf::load_or_compile_sofa_field(request, &field, &cache_path);
if (!status.ok()) {
return Status::fail(status.code(), stage::kRender,
"binaural setup failed: " + status.message());
}
} else {
status = hrtf::load_jochrtf(config_.hrtf_path, &field);
}
if (status.ok()) { if (status.ok()) {
status = config_.kernels_path.empty() status = config_.kernels_path.empty()
? (kernels = hrtf::builtin_kernels(), Status::success()) ? (kernels = hrtf::builtin_kernels(), Status::success())
@@ -118,6 +170,7 @@ Status Stream::create(const Config& config) {
} }
binaural_ready_ = true; binaural_ready_ = true;
output_channels_ = 2; output_channels_ = 2;
}
} else { } else {
output_channels_ = JOC_OUTPUT_CHANNELS; output_channels_ = JOC_OUTPUT_CHANNELS;
} }
@@ -273,6 +326,9 @@ Status Stream::render_objects16(const std::vector<float>& objects16) {
info_.samples_out += kFrameSamples; info_.samples_out += kFrameSamples;
return Status::success(); return Status::success();
} }
if (rosella_ready_) {
return render_rosella_objects16(objects16);
}
const Status submitted = const Status submitted =
binaural_.submit_frame(objects16.data(), binaural_.submit_frame(objects16.data(),
@@ -293,6 +349,55 @@ Status Stream::render_objects16(const std::vector<float>& objects16) {
return Status::success(); return Status::success();
} }
// The Rosella runtime is driven exactly like the SOFA runtime (the same frame,
// update, frame index, outer offset and object delay), but it renders in chunks
// of its own size (64 frames by default), so a frame usually yields either
// nothing or a whole chunk. Its output therefore waits in a FIFO and is released
// one frame's worth at a time, which keeps the stream's contract intact: pushing
// one syncframe leaves exactly JOC_FRAME_SAMPLES samples for the caller to pull,
// and nothing is dropped or counted twice. pull() and flush() release the rest.
Status Stream::render_rosella_objects16(const std::vector<float>& objects16) {
const Status submitted =
rosella_.submit_frame(objects16.data(),
pending_metadata_.has_update ? &pending_metadata_.update : nullptr,
static_cast<std::int64_t>(info_.frames_out),
pending_metadata_.outer_offset,
static_cast<std::int64_t>(config_.object_delay_samples));
if (!submitted.ok()) {
return submitted;
}
std::vector<double> produced;
rosella_.take_output(&produced);
if (!produced.empty()) {
rosella_pending_.insert(rosella_pending_.end(), produced.begin(), produced.end());
}
release_rosella_output(kFrameSamples);
info_.frames_out++;
// Counted as the runtime produces it, which is also how the SOFA path counts:
// the totals are identical, only the frame they appear on differs.
info_.samples_out += produced.size() / 2u;
return Status::success();
}
void Stream::release_rosella_output(std::size_t limit) {
if (!rosella_ready_ || limit == 0u) {
return;
}
const std::size_t count = std::min(limit, rosella_pending_samples());
if (count == 0u) {
return;
}
const std::size_t values = count * 2u;
for (std::size_t index = 0; index < values; ++index) {
output_.push_back(static_cast<float>(rosella_pending_[rosella_read_offset_ + index]));
}
rosella_read_offset_ += values;
if (rosella_read_offset_ == rosella_pending_.size()) {
rosella_pending_.clear();
rosella_read_offset_ = 0;
}
}
Status Stream::pull(float* destination, std::size_t capacity_samples, std::size_t* produced) { Status Stream::pull(float* destination, std::size_t capacity_samples, std::size_t* produced) {
if (produced != nullptr) { if (produced != nullptr) {
*produced = 0; *produced = 0;
@@ -300,6 +405,10 @@ Status Stream::pull(float* destination, std::size_t capacity_samples, std::size_
if (destination == nullptr || produced == nullptr) { if (destination == nullptr || produced == nullptr) {
return Status::fail(JOC_ERR_INVALID_ARGUMENT, stage::kOutput, "null pull buffer"); return Status::fail(JOC_ERR_INVALID_ARGUMENT, stage::kOutput, "null pull buffer");
} }
// Rendered Rosella samples that the frame-at-a-time release above has not
// handed over yet are still the caller's to take; releasing them here keeps
// buffered_samples() and the amount pull() can deliver the same number.
release_rosella_output(capacity_samples);
const std::size_t available = buffered_samples(); const std::size_t available = buffered_samples();
const std::size_t count = std::min(capacity_samples, available); const std::size_t count = std::min(capacity_samples, available);
if (count != 0u) { if (count != 0u) {
@@ -332,12 +441,28 @@ Status Stream::flush() {
} }
info_.samples_out += tail.size() / 2u; info_.samples_out += tail.size() / 2u;
} }
if (rosella_ready_) {
std::vector<double> tail;
const Status drained =
rosella_.finish(rosella_.finish_capacity(config_.tail_seconds), &tail);
if (!drained.ok()) {
return drained;
}
// Everything the runtime produced as the program is released first: the
// tail only sounds after it. The program samples were already counted by
// render_rosella_objects16, so only the tail is added here.
release_rosella_output(rosella_pending_samples());
for (const double value : tail) {
output_.push_back(static_cast<float>(value));
}
info_.samples_out += tail.size() / 2u;
}
info_.ended = 1; info_.ended = 1;
return Status::success(); return Status::success();
} }
Status Stream::reset() { Status Stream::reset() {
if (rebuilder_ == nullptr && !speaker_enabled_ && !binaural_ready_ && if (rebuilder_ == nullptr && !speaker_enabled_ && !binaural_ready_ && !rosella_ready_ &&
config_.input != JOC_STREAM_IN_PCM_OBJECTS16) { config_.input != JOC_STREAM_IN_PCM_OBJECTS16) {
return Status::fail(JOC_ERR_STATE, stage::kRender, "stream is not created"); return Status::fail(JOC_ERR_STATE, stage::kRender, "stream is not created");
} }
+26 -2
View File
@@ -9,6 +9,7 @@
#include "binaural/binaural_runtime.h" #include "binaural/binaural_runtime.h"
#include "eac3_transport/eac3_reader.h" #include "eac3_transport/eac3_reader.h"
#include "foundation/status.h" #include "foundation/status.h"
#include "hrtf/rosella_renderer.h"
#include "joc_core.h" #include "joc_core.h"
#include "joc_stream.h" #include "joc_stream.h"
#include "oamd/oamd_parser.h" #include "oamd/oamd_parser.h"
@@ -28,6 +29,13 @@ struct Config {
std::uint32_t object_delay_samples = 1473; std::uint32_t object_delay_samples = 1473;
double gain_db = 0.0; double gain_db = 0.0;
std::uint32_t native_threads = 0; std::uint32_t native_threads = 0;
// The three HRTF shapes of joc_task_config, in its precedence order: a Rosella
// model wins over a SOFA, and hrtf_path (.jochrtf) is the fallback.
std::string hrtf_sofa_path;
std::string personalized_headphone_path;
std::string hrtf_cache_dir;
std::uint32_t hrtf_cache_policy = JOC_HRTF_CACHE_MEMORY;
double hrtf_radius_m = 1.0;
}; };
struct Info { struct Info {
@@ -67,14 +75,26 @@ public:
Status reset(); Status reset();
const Info& info() const { return info_; } const Info& info() const { return info_; }
// Per-channel sample count, not the interleaved float count. // Per-channel sample count, not the interleaved float count. The Rosella
// runtime renders in its own chunk size, so its output waits in a FIFO before
// it is released one frame at a time; those samples are rendered and unpulled
// as well, so the reported backlog has to include them.
std::size_t buffered_samples() const { std::size_t buffered_samples() const {
return output_channels_ != 0u ? (output_.size() - read_offset_) / output_channels_ : 0u; return output_channels_ != 0u
? (output_.size() - read_offset_) / output_channels_ + rosella_pending_samples()
: 0u;
} }
private: private:
Status process_ready_frames(); Status process_ready_frames();
Status render_objects16(const std::vector<float>& objects16); Status render_objects16(const std::vector<float>& objects16);
Status render_rosella_objects16(const std::vector<float>& objects16);
// Moves at most `limit` rendered stereo samples per channel out of the FIFO
// into output_, oldest sample first.
void release_rosella_output(std::size_t limit);
std::size_t rosella_pending_samples() const {
return rosella_ready_ ? (rosella_pending_.size() - rosella_read_offset_) / 2u : 0u;
}
void reset_state(); void reset_state();
Config config_; Config config_;
@@ -92,9 +112,13 @@ private:
ejoc_renderer_handle rebuilder_ = nullptr; ejoc_renderer_handle rebuilder_ = nullptr;
speaker::SpeakerStep speaker_; speaker::SpeakerStep speaker_;
binaural::SofaBinauralRuntime binaural_; binaural::SofaBinauralRuntime binaural_;
hrtf::RosellaRuntime rosella_;
std::vector<double> rosella_pending_;
std::size_t rosella_read_offset_ = 0;
bool speaker_enabled_ = false; bool speaker_enabled_ = false;
bool binaural_enabled_ = false; bool binaural_enabled_ = false;
bool binaural_ready_ = false; bool binaural_ready_ = false;
bool rosella_ready_ = false;
float gain_ = 1.0f; float gain_ = 1.0f;
}; };
-1
View File
@@ -1209,7 +1209,6 @@ Status run(const joc_task_config& raw, const joc_event_sink* sink, joc_task_resu
} }
render_options.object_delay_samples = cfg.object_delay_samples; render_options.object_delay_samples = cfg.object_delay_samples;
render_options.tail_seconds = cfg.tail_seconds; render_options.tail_seconds = cfg.tail_seconds;
render_options.output_gain = 1.0;
if (status.ok()) { if (status.ok()) {
status = rosella.open(model, render_options); status = rosella.open(model, render_options);
} }