Add stream HRTF inputs and drop unused renderer gain
This commit is contained in:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+147
-22
@@ -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,28 +103,74 @@ 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;
|
||||||
hrtf::Field field;
|
// The HRTF input precedence is joc_task_config's: a Rosella
|
||||||
hrtf::Kernels kernels;
|
// .personalized_headphone wins over a SOFA that the library compiles, and
|
||||||
Status status = hrtf::load_jochrtf(config_.hrtf_path, &field);
|
// the compiled .jochrtf (hrtf_path) stays the fallback input.
|
||||||
if (status.ok()) {
|
if (!config_.personalized_headphone_path.empty()) {
|
||||||
status = config_.kernels_path.empty()
|
hrtf::RosellaModel model;
|
||||||
? (kernels = hrtf::builtin_kernels(), Status::success())
|
Status status =
|
||||||
: hrtf::load_kernels(config_.kernels_path, &kernels);
|
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::Kernels kernels;
|
||||||
|
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()) {
|
||||||
|
status = config_.kernels_path.empty()
|
||||||
|
? (kernels = hrtf::builtin_kernels(), Status::success())
|
||||||
|
: hrtf::load_kernels(config_.kernels_path, &kernels);
|
||||||
|
}
|
||||||
|
binaural::Profile profile = binaural::Profile::Mid;
|
||||||
|
if (status.ok() && config_.binaural_mode == JOC_BINAURAL_NEAR) {
|
||||||
|
profile = binaural::Profile::Near;
|
||||||
|
} else if (status.ok() && config_.binaural_mode == JOC_BINAURAL_FAR) {
|
||||||
|
profile = binaural::Profile::Far;
|
||||||
|
}
|
||||||
|
if (status.ok()) {
|
||||||
|
status = binaural_.open(field, kernels, profile);
|
||||||
|
}
|
||||||
|
if (!status.ok()) {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
binaural_ready_ = true;
|
||||||
|
output_channels_ = 2;
|
||||||
}
|
}
|
||||||
binaural::Profile profile = binaural::Profile::Mid;
|
|
||||||
if (status.ok() && config_.binaural_mode == JOC_BINAURAL_NEAR) {
|
|
||||||
profile = binaural::Profile::Near;
|
|
||||||
} else if (status.ok() && config_.binaural_mode == JOC_BINAURAL_FAR) {
|
|
||||||
profile = binaural::Profile::Far;
|
|
||||||
}
|
|
||||||
if (status.ok()) {
|
|
||||||
status = binaural_.open(field, kernels, profile);
|
|
||||||
}
|
|
||||||
if (!status.ok()) {
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
binaural_ready_ = true;
|
|
||||||
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
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user