@@ -0,0 +1,346 @@
|
||||
#include "eac3_scan.h"
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace joc_eac3 {
|
||||
namespace {
|
||||
|
||||
constexpr std::uint16_t kEac3Syncword = 0x0B77;
|
||||
constexpr std::uint16_t kEmdfSyncword = 0x5838;
|
||||
constexpr std::uint8_t kIdOamd = 11;
|
||||
constexpr std::uint8_t kIdJoc = 14;
|
||||
constexpr std::size_t kMaxPayloads = 16;
|
||||
|
||||
// MSB-first bit reader with an explicit limit, matching the core's reader
|
||||
// semantics (reads past the limit fail rather than returning zeros).
|
||||
class BitReader {
|
||||
public:
|
||||
BitReader(const std::uint8_t* data, std::size_t limit_bits, std::size_t start_bit)
|
||||
: data_(data), limit_(limit_bits), position_(start_bit) {}
|
||||
|
||||
std::uint32_t read(unsigned count) {
|
||||
std::uint32_t value = 0;
|
||||
for (unsigned i = 0; i < count; ++i) {
|
||||
if (position_ >= limit_) {
|
||||
failed_ = true;
|
||||
return 0;
|
||||
}
|
||||
const unsigned byte = data_[position_ >> 3];
|
||||
value = (value << 1) | ((byte >> (7u - (position_ & 7u))) & 1u);
|
||||
++position_;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
bool skip(std::size_t count) {
|
||||
if (position_ + count > limit_) {
|
||||
failed_ = true;
|
||||
return false;
|
||||
}
|
||||
position_ += count;
|
||||
return true;
|
||||
}
|
||||
|
||||
// value, then a continuation bit; an unterminated chain is a syntax error.
|
||||
bool variable_bits(unsigned width, unsigned max_groups, std::uint32_t* out) {
|
||||
std::uint32_t value = 0;
|
||||
for (unsigned group = 0; group < max_groups; ++group) {
|
||||
value += read(width);
|
||||
if (failed_) return false;
|
||||
const std::uint32_t more = read(1);
|
||||
if (failed_) return false;
|
||||
if (more == 0u) {
|
||||
if (out != nullptr) *out = value;
|
||||
return true;
|
||||
}
|
||||
value = (value + 1u) << width;
|
||||
}
|
||||
failed_ = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
std::size_t position() const { return position_; }
|
||||
bool failed() const { return failed_; }
|
||||
void set_failed() { failed_ = true; }
|
||||
|
||||
private:
|
||||
const std::uint8_t* data_ = nullptr;
|
||||
std::size_t limit_ = 0;
|
||||
std::size_t position_ = 0;
|
||||
bool failed_ = false;
|
||||
};
|
||||
|
||||
struct Payload {
|
||||
std::uint8_t id = 0;
|
||||
std::size_t bit_offset = 0;
|
||||
std::size_t size = 0;
|
||||
};
|
||||
|
||||
struct Container {
|
||||
std::size_t start_bit = 0;
|
||||
std::size_t raw_size = 0;
|
||||
std::size_t payload_count = 0;
|
||||
Payload payloads[kMaxPayloads] = {};
|
||||
|
||||
bool has(std::uint8_t id) const {
|
||||
for (std::size_t i = 0; i < payload_count; ++i) {
|
||||
if (payloads[i].id == id) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
bool parse_container(const std::uint8_t* data, std::size_t size_bits, std::size_t start_bit,
|
||||
Container* out) {
|
||||
if (start_bit + 16u > size_bits) return false;
|
||||
BitReader reader(data, size_bits, start_bit);
|
||||
if (reader.read(16) != kEmdfSyncword) return false;
|
||||
|
||||
const std::uint32_t length = reader.read(16);
|
||||
const std::size_t body_start = reader.position();
|
||||
const std::size_t body_end = body_start + static_cast<std::size_t>(length) * 8u;
|
||||
if (reader.failed() || body_end > size_bits) return false;
|
||||
|
||||
BitReader body(data, body_end, body_start);
|
||||
std::uint32_t version = body.read(2);
|
||||
if (body.failed()) return false;
|
||||
if (version == 3u) {
|
||||
std::uint32_t extra = 0;
|
||||
if (!body.variable_bits(2, 8, &extra)) return false;
|
||||
version += extra;
|
||||
}
|
||||
std::uint32_t key_id = body.read(3);
|
||||
if (body.failed()) return false;
|
||||
if (key_id == 7u) {
|
||||
std::uint32_t extra = 0;
|
||||
if (!body.variable_bits(3, 8, &extra)) return false;
|
||||
key_id += extra;
|
||||
}
|
||||
// TS 103 420 JOC is version 0 / key_id 0; the strict check also rejects
|
||||
// false 0x5838 markers that happen to sit inside audio data.
|
||||
if (version != 0u || key_id != 0u) return false;
|
||||
|
||||
Container container;
|
||||
container.start_bit = start_bit;
|
||||
bool terminated = false;
|
||||
while (body.position() + 5u <= body_end) {
|
||||
std::uint32_t payload_id = body.read(5);
|
||||
if (body.failed()) return false;
|
||||
if (payload_id == 0u) {
|
||||
terminated = true;
|
||||
break;
|
||||
}
|
||||
if (payload_id == 0x1Fu) {
|
||||
std::uint32_t extra = 0;
|
||||
if (!body.variable_bits(5, 8, &extra)) return false;
|
||||
payload_id += extra;
|
||||
}
|
||||
for (std::size_t i = 0; i < container.payload_count; ++i) {
|
||||
if (container.payloads[i].id == static_cast<std::uint8_t>(payload_id)) return false;
|
||||
}
|
||||
if (container.payload_count >= kMaxPayloads) return false;
|
||||
|
||||
const std::uint32_t has_sample_offset = body.read(1);
|
||||
if (body.failed()) return false;
|
||||
if (has_sample_offset != 0u) {
|
||||
(void)body.read(12);
|
||||
}
|
||||
if (body.read(1) != 0u) {
|
||||
std::uint32_t ignored = 0;
|
||||
if (!body.variable_bits(11, 8, &ignored)) return false;
|
||||
}
|
||||
if (body.read(1) != 0u) {
|
||||
std::uint32_t ignored = 0;
|
||||
if (!body.variable_bits(2, 8, &ignored)) return false;
|
||||
}
|
||||
if (body.read(1) != 0u) {
|
||||
if (!body.skip(8)) return false;
|
||||
}
|
||||
if (body.failed()) return false;
|
||||
if (body.read(1) == 0u) {
|
||||
bool frame_aligned = false;
|
||||
if (has_sample_offset == 0u) {
|
||||
frame_aligned = body.read(1) != 0u;
|
||||
if (frame_aligned && !body.skip(2)) return false;
|
||||
}
|
||||
if ((has_sample_offset != 0u || frame_aligned) && !body.skip(7)) return false;
|
||||
}
|
||||
if (body.failed()) return false;
|
||||
|
||||
std::uint32_t payload_size = 0;
|
||||
if (!body.variable_bits(8, 8, &payload_size)) return false;
|
||||
const std::size_t payload_bits = static_cast<std::size_t>(payload_size) * 8u;
|
||||
if (body.position() + payload_bits > body_end) return false;
|
||||
|
||||
Payload& entry = container.payloads[container.payload_count++];
|
||||
entry.id = static_cast<std::uint8_t>(payload_id);
|
||||
entry.bit_offset = body.position();
|
||||
entry.size = payload_size;
|
||||
if (!body.skip(payload_bits)) return false;
|
||||
}
|
||||
if (!terminated) return false;
|
||||
container.raw_size = 4u + static_cast<std::size_t>(length);
|
||||
*out = container;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Candidate 0x5838 positions over the eight bit alignments, ascending.
|
||||
void marker_offsets(const std::uint8_t* data, std::size_t size, std::size_t* out,
|
||||
std::size_t capacity, std::size_t* count) {
|
||||
*count = 0;
|
||||
if (size < 4u) return;
|
||||
for (std::size_t shift = 0; shift < 8u; ++shift) {
|
||||
const std::size_t aligned_len = (shift == 0u) ? size : (size - 1u);
|
||||
for (std::size_t i = 0; i + 1u < aligned_len; ++i) {
|
||||
std::uint8_t first = 0;
|
||||
std::uint8_t second = 0;
|
||||
if (shift == 0u) {
|
||||
first = data[i];
|
||||
second = data[i + 1u];
|
||||
} else {
|
||||
first = static_cast<std::uint8_t>(
|
||||
((static_cast<std::uint16_t>(data[i]) << shift) |
|
||||
(static_cast<std::uint16_t>(data[i + 1u]) >> (8u - shift))) &
|
||||
0xFFu);
|
||||
second = static_cast<std::uint8_t>(
|
||||
((static_cast<std::uint16_t>(data[i + 1u]) << shift) |
|
||||
(static_cast<std::uint16_t>(data[i + 2u]) >> (8u - shift))) &
|
||||
0xFFu);
|
||||
}
|
||||
if (first == 0x58u && second == 0x38u) {
|
||||
if (*count < capacity) out[(*count)++] = i * 8u + shift;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool contains_payload(const std::uint8_t* frame, std::size_t size, std::uint8_t wanted,
|
||||
std::size_t* first_bit) {
|
||||
for (std::size_t at = 0; at + 1u < size; ++at) {
|
||||
if (frame[at] != 0x58u || frame[at + 1u] != 0x38u) continue;
|
||||
Container container;
|
||||
if (parse_container(frame, size * 8u, at * 8u, &container) && container.has(wanted)) {
|
||||
if (first_bit != nullptr) *first_bit = at * 8u;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (std::size_t shift = 1; shift < 8u; ++shift) {
|
||||
const std::size_t aligned_len = size - 1u;
|
||||
for (std::size_t i = 0; i + 1u < aligned_len; ++i) {
|
||||
const std::uint8_t first = static_cast<std::uint8_t>(
|
||||
((static_cast<std::uint16_t>(frame[i]) << shift) |
|
||||
(static_cast<std::uint16_t>(frame[i + 1u]) >> (8u - shift))) &
|
||||
0xFFu);
|
||||
const std::uint8_t second = static_cast<std::uint8_t>(
|
||||
((static_cast<std::uint16_t>(frame[i + 1u]) << shift) |
|
||||
(static_cast<std::uint16_t>(frame[i + 2u]) >> (8u - shift))) &
|
||||
0xFFu);
|
||||
if (first != 0x58u || second != 0x38u) continue;
|
||||
Container container;
|
||||
if (parse_container(frame, size * 8u, i * 8u + shift, &container) &&
|
||||
container.has(wanted)) {
|
||||
if (first_bit != nullptr) *first_bit = i * 8u + shift;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::size_t frame_bytes_at(const std::uint8_t* data, std::size_t size, std::size_t offset) {
|
||||
if (data == nullptr || offset + 4u > size) return 0;
|
||||
const std::uint16_t syncword =
|
||||
static_cast<std::uint16_t>((static_cast<std::uint16_t>(data[offset]) << 8) | data[offset + 1]);
|
||||
if (syncword != kEac3Syncword) return 0;
|
||||
const std::size_t words =
|
||||
(static_cast<std::size_t>(data[offset + 2] & 0x07u) << 8) | data[offset + 3];
|
||||
return (words + 1u) * 2u;
|
||||
}
|
||||
|
||||
bool frame_has_joc(const std::uint8_t* frame, std::size_t frame_bytes) {
|
||||
if (frame == nullptr || frame_bytes < 8u) return false;
|
||||
|
||||
std::size_t offsets[4096];
|
||||
std::size_t count = 0;
|
||||
marker_offsets(frame, frame_bytes, offsets, 4096, &count);
|
||||
|
||||
Container matches[16];
|
||||
std::size_t match_count = 0;
|
||||
for (std::size_t i = 0; i < count; ++i) {
|
||||
Container candidate;
|
||||
if (!parse_container(frame, frame_bytes * 8u, offsets[i], &candidate)) continue;
|
||||
if (candidate.has(kIdOamd) && candidate.has(kIdJoc)) {
|
||||
if (match_count < 16) matches[match_count++] = candidate;
|
||||
}
|
||||
}
|
||||
if (match_count == 0) return false;
|
||||
|
||||
// A payload may contain bytes that look like another container; only
|
||||
// top-level ones count.
|
||||
Container top_level[16];
|
||||
std::size_t top_count = 0;
|
||||
for (std::size_t i = 0; i < match_count; ++i) {
|
||||
bool nested = false;
|
||||
for (std::size_t j = 0; j < top_count; ++j) {
|
||||
if (top_level[j].start_bit < matches[i].start_bit &&
|
||||
matches[i].start_bit < top_level[j].start_bit + top_level[j].raw_size * 8u) {
|
||||
nested = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!nested && top_count < 16) top_level[top_count++] = matches[i];
|
||||
}
|
||||
// Exactly one is what the core requires; more than one is ambiguous there.
|
||||
return top_count == 1u;
|
||||
}
|
||||
|
||||
ScanResult scan(const std::uint8_t* data, std::size_t size, std::size_t max_frames) {
|
||||
ScanResult result;
|
||||
if (data == nullptr || size < 8u) {
|
||||
result.detail = "buffer too small";
|
||||
return result;
|
||||
}
|
||||
|
||||
std::size_t offset = 0;
|
||||
std::size_t frames = 0;
|
||||
std::size_t with_joc = 0;
|
||||
bool first = true;
|
||||
while (frames < max_frames) {
|
||||
const std::size_t frame_bytes = frame_bytes_at(data, size, offset);
|
||||
if (frame_bytes == 0) {
|
||||
result.detail = frames == 0 ? "no E-AC-3 syncword at the start" : "stream walk ended";
|
||||
break;
|
||||
}
|
||||
if (offset + frame_bytes > size) {
|
||||
result.detail = "buffer ends inside a syncframe";
|
||||
break;
|
||||
}
|
||||
if (first) {
|
||||
result.first_frame_bytes = frame_bytes;
|
||||
first = false;
|
||||
} else if (frame_bytes != result.first_frame_bytes) {
|
||||
result.all_frames_same_size = false;
|
||||
}
|
||||
if (frame_has_joc(data + offset, frame_bytes)) ++with_joc;
|
||||
++frames;
|
||||
offset += frame_bytes;
|
||||
}
|
||||
|
||||
result.frames_examined = frames;
|
||||
result.frames_with_joc = with_joc;
|
||||
if (frames == 0) {
|
||||
result.joc = JocState::kUnknown;
|
||||
if (result.detail[0] == '\0') result.detail = "no syncframes";
|
||||
} else if (with_joc == frames) {
|
||||
result.joc = JocState::kYes;
|
||||
result.detail = "every examined syncframe carries the JOC EMDF container";
|
||||
} else {
|
||||
result.joc = JocState::kNo;
|
||||
result.detail = "at least one examined syncframe has no JOC EMDF container";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace joc_eac3
|
||||
@@ -0,0 +1,51 @@
|
||||
// E-AC-3 syncframe walking and JOC detection.
|
||||
//
|
||||
// The plugin must decide "is this file E-AC-3 JOC" from the bitstream alone,
|
||||
// because container metadata (MP4 dec3, Matroska A_EAC3) says only "E-AC-3" and
|
||||
// the JOC flag inside it is frequently missing.
|
||||
//
|
||||
// The criterion implemented here is deliberately the same one the rendering core
|
||||
// applies before it will render a frame: a syncframe carries JOC when it holds
|
||||
// exactly one top-level EMDF container (syncword 0x5838, any of the eight bit
|
||||
// alignments) whose payload list contains both ID11 (OAMD) and ID14 (JOC).
|
||||
// Agreeing with the core matters: a looser test would claim files the core then
|
||||
// refuses, which is worse than declining them.
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
namespace joc_eac3 {
|
||||
|
||||
// E-AC-3 syncframe header fields the plugin needs.
|
||||
struct FrameHeader {
|
||||
std::size_t frame_bytes = 0;
|
||||
std::size_t offset = 0;
|
||||
};
|
||||
|
||||
enum class JocState {
|
||||
kYes, // every frame examined carried a JOC EMDF container
|
||||
kNo, // at least one frame examined did not
|
||||
kUnknown, // not enough data, or the stream is not walkable E-AC-3
|
||||
};
|
||||
|
||||
struct ScanResult {
|
||||
JocState joc = JocState::kUnknown;
|
||||
std::size_t frames_examined = 0;
|
||||
std::size_t frames_with_joc = 0;
|
||||
std::size_t first_frame_bytes = 0;
|
||||
bool all_frames_same_size = true;
|
||||
const char* detail = "";
|
||||
};
|
||||
|
||||
// Walks up to `max_frames` syncframes of `data` and classifies the stream.
|
||||
ScanResult scan(const std::uint8_t* data, std::size_t size, std::size_t max_frames);
|
||||
|
||||
// Length of the syncframe starting at data[offset], or 0 when there is no valid
|
||||
// header there. Mirrors the core's frmsiz handling exactly.
|
||||
std::size_t frame_bytes_at(const std::uint8_t* data, std::size_t size, std::size_t offset);
|
||||
|
||||
// True when this single syncframe carries the JOC EMDF container.
|
||||
bool frame_has_joc(const std::uint8_t* frame, std::size_t frame_bytes);
|
||||
|
||||
} // namespace joc_eac3
|
||||
@@ -0,0 +1,263 @@
|
||||
// The foobar2000 input component: E-AC-3 JOC files.
|
||||
//
|
||||
// One deliberate behaviour dominates this file: open() decides from the bitstream
|
||||
// whether the file is E-AC-3 JOC, and throws exception_io_unsupported_format when
|
||||
// it is not. A plain E-AC-3 file must be handed back to the decoder the user
|
||||
// already had, so that installing this component cannot change how ordinary
|
||||
// Dolby Digital Plus files sound.
|
||||
|
||||
#include <SDK/foobar2000-lite.h>
|
||||
|
||||
#include <SDK/audio_chunk.h>
|
||||
#include <SDK/exception_io.h>
|
||||
#include <SDK/file_info.h>
|
||||
#include <SDK/input.h>
|
||||
#include <SDK/input_file_type.h>
|
||||
#include <SDK/input_impl.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <string>
|
||||
|
||||
#include "eac3_scan.h"
|
||||
#include "joc_decode.h"
|
||||
#include "log.h"
|
||||
#include "prefs.h"
|
||||
#include "settings.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr std::size_t kSniffBytes = 256u * 1024u;
|
||||
constexpr std::size_t kRunFrames = 4096u;
|
||||
constexpr unsigned kSampleRate = 48000;
|
||||
|
||||
// Identity in the decoder priority table.
|
||||
|
||||
const GUID g_decoder_guid = {0x9c3f1d58, 0x27ab, 0x4e64, {0xb0, 0x93, 0x5e, 0x1c, 0xd7, 0x48, 0x2f, 0xa6}};
|
||||
|
||||
const char* output_name(const joc_decode::Settings& settings) {
|
||||
return (settings.output == joc_decode::Output::kBinaural) ? "binaural (HRTF)" : "speaker";
|
||||
}
|
||||
|
||||
const char* binaural_mode_name(std::uint32_t mode) {
|
||||
switch (mode) {
|
||||
case 1: return "near";
|
||||
case 2: return "far";
|
||||
default: return "mid";
|
||||
}
|
||||
}
|
||||
|
||||
std::string file_name_of(const std::string& path) {
|
||||
const std::string::size_type slash = path.find_last_of("\\/");
|
||||
return slash == std::string::npos ? path : path.substr(slash + 1);
|
||||
}
|
||||
|
||||
class input_joc : public input_stubs {
|
||||
public:
|
||||
void open(service_ptr_t<file> hint, const char* path, t_input_open_reason reason,
|
||||
abort_callback& abort) {
|
||||
if (reason == input_open_info_write) throw exception_tagging_unsupported();
|
||||
m_path = (path != nullptr) ? path : "";
|
||||
|
||||
service_ptr_t<file> source = hint;
|
||||
input_open_file_helper(source, path, reason, abort);
|
||||
m_file = source;
|
||||
|
||||
pfc::array_t<t_uint8> buffer;
|
||||
buffer.set_size(kSniffBytes);
|
||||
const std::size_t got = m_file->read(buffer.get_ptr(), kSniffBytes, abort);
|
||||
const joc_eac3::ScanResult scan = joc_eac3::scan(buffer.get_ptr(), got, 8);
|
||||
|
||||
joc_log::line("decoder: open \"%s\" reason=%d bytes=%llu frames=%llu with_joc=%llu",
|
||||
m_path.c_str(), static_cast<int>(reason),
|
||||
static_cast<unsigned long long>(got),
|
||||
static_cast<unsigned long long>(scan.frames_examined),
|
||||
static_cast<unsigned long long>(scan.frames_with_joc));
|
||||
|
||||
if (scan.joc != joc_eac3::JocState::kYes) {
|
||||
// Hand the file to the next entry in the priority table.
|
||||
joc_log::line("decoder: yielding to the built-in decoder (%s)", scan.detail);
|
||||
throw exception_io_unsupported_format();
|
||||
}
|
||||
joc_log::line("decoder: claiming this file as E-AC-3 JOC");
|
||||
|
||||
// The core passes URLs ("file://C:\..."). ffmpeg and the file APIs need a
|
||||
// native path, and a URL we cannot map to one is a file we cannot decode.
|
||||
try {
|
||||
m_native_path = filesystem::g_get_native_path(m_path.c_str());
|
||||
} catch (const pfc::exception& error) {
|
||||
joc_log::line("decoder: not a native file path (%s): %s", m_path.c_str(),
|
||||
error.what());
|
||||
throw exception_io_unsupported_format();
|
||||
}
|
||||
joc_log::line("decoder: native path \"%s\"", m_native_path.get_ptr());
|
||||
}
|
||||
|
||||
void get_info(file_info& info, abort_callback& abort) {
|
||||
(void)abort;
|
||||
const joc_decode::FileProbe probe = joc_decode::probe_file(m_native_path.get_ptr());
|
||||
const joc_decode::Settings settings = joc_settings::current();
|
||||
|
||||
unsigned channels = 2;
|
||||
std::string render;
|
||||
if (settings.output == joc_decode::Output::kBinaural) {
|
||||
channels = 2;
|
||||
render = std::string("binaural (") + binaural_mode_name(settings.binaural_mode) + ")";
|
||||
} else {
|
||||
const unsigned layout_channels = joc_decode::speaker_channels(settings.speaker_layout);
|
||||
channels = layout_channels != 0 ? layout_channels : 6;
|
||||
render = "speaker " + settings.speaker_layout;
|
||||
}
|
||||
|
||||
info.info_set("codec", "E-AC-3 JOC");
|
||||
info.info_set("codec_long", "E-AC-3 JOC (Dolby Atmos)");
|
||||
info.info_set("encoding", "lossy");
|
||||
info.info_set_int("samplerate", kSampleRate);
|
||||
info.info_set_int("channels", channels);
|
||||
info.info_set_int("bitspersample", 32);
|
||||
info.info_set("bitspersample_extra", "floating-point");
|
||||
info.set_length(probe.duration_seconds);
|
||||
if (probe.duration_seconds > 0.0) {
|
||||
const t_filesize bytes = m_file.is_valid() ? m_file->get_size(abort) : filesize_invalid;
|
||||
if (bytes != filesize_invalid && bytes > 0) {
|
||||
info.info_set_bitrate(static_cast<t_int64>(
|
||||
static_cast<double>(bytes) * 8.0 / probe.duration_seconds / 1000.0));
|
||||
}
|
||||
}
|
||||
// Custom fields: visible in Properties and usable as %joc_*% in title
|
||||
// formatting, which is how a user can tell a JOC file from a plain one.
|
||||
info.info_set("joc_render", render.c_str());
|
||||
info.info_set("joc_hrtf", settings.hrtf_file.empty()
|
||||
? "(未设置)"
|
||||
: file_name_of(settings.hrtf_file).c_str());
|
||||
info.info_set_int("joc_frames", static_cast<t_int64>(probe.frames));
|
||||
info.info_set("joc_scan", probe.detail.c_str());
|
||||
joc_log::line("decoder: get_info duration=%.3f s frames=%llu channels=%u render=%s",
|
||||
probe.duration_seconds, static_cast<unsigned long long>(probe.frames),
|
||||
channels, render.c_str());
|
||||
}
|
||||
|
||||
t_filestats2 get_stats2(uint32_t flags, abort_callback& abort) {
|
||||
if (m_file.is_valid()) return m_file->get_stats2_(flags, abort);
|
||||
throw exception_io_unsupported_format();
|
||||
}
|
||||
|
||||
void decode_initialize(unsigned flags, abort_callback& abort) {
|
||||
(void)abort;
|
||||
m_settings = joc_settings::current();
|
||||
joc_log::line("decoder: initialize flags=0x%X settings: %s", flags,
|
||||
joc_settings::describe(m_settings).c_str());
|
||||
|
||||
std::string error;
|
||||
if (!m_engine.start(m_native_path.get_ptr(), m_settings, &error)) {
|
||||
joc_log::line("decoder: engine start failed: %s", error.c_str());
|
||||
// The most common cause by far, and the one a user can act on.
|
||||
if (m_settings.output == joc_decode::Output::kBinaural &&
|
||||
m_settings.hrtf_file.empty()) {
|
||||
throw exception_io_data(
|
||||
"JOC:双耳渲染需要先指定 HRTF 文件(Preferences -> Tools -> JOC 解码器),"
|
||||
"或把输出改为扬声器布局。");
|
||||
}
|
||||
throw exception_io_data(error.c_str());
|
||||
}
|
||||
m_channels = m_engine.channels();
|
||||
if (m_channels == 0) m_channels = 2;
|
||||
m_buffer.resize(kRunFrames * m_channels);
|
||||
m_frames_delivered = 0;
|
||||
m_reported = false;
|
||||
joc_log::line("decoder: engine ready, %u output channel(s), %u frames per read",
|
||||
m_channels, static_cast<unsigned>(kRunFrames));
|
||||
}
|
||||
|
||||
bool decode_run(audio_chunk& chunk, abort_callback& abort) {
|
||||
(void)abort;
|
||||
std::string error;
|
||||
const std::size_t frames = m_engine.read(m_buffer.data(), kRunFrames, &error);
|
||||
if (frames == 0) {
|
||||
if (!error.empty()) {
|
||||
joc_log::line("decoder: read failed: %s", error.c_str());
|
||||
throw exception_io_data(error.c_str());
|
||||
}
|
||||
joc_log::line("decoder: end of stream after %llu frames",
|
||||
static_cast<unsigned long long>(m_frames_delivered));
|
||||
return false;
|
||||
}
|
||||
|
||||
chunk.set_data_size(frames * m_channels);
|
||||
chunk.set_channels(m_channels, audio_chunk::g_guess_channel_config(m_channels));
|
||||
chunk.set_sample_rate(kSampleRate);
|
||||
chunk.set_sample_count(frames);
|
||||
std::memcpy(chunk.get_data(), m_buffer.data(),
|
||||
frames * m_channels * sizeof(audio_sample));
|
||||
|
||||
m_frames_delivered += frames;
|
||||
if (!m_reported) {
|
||||
m_reported = true;
|
||||
float peak = 0.0f;
|
||||
for (std::size_t i = 0; i < frames * m_channels; ++i) {
|
||||
const float value = m_buffer[i] < 0.0f ? -m_buffer[i] : m_buffer[i];
|
||||
if (value > peak) peak = value;
|
||||
}
|
||||
joc_log::line("decoder: first %llu frames delivered (%u ch), peak %.6f",
|
||||
static_cast<unsigned long long>(frames), m_channels,
|
||||
static_cast<double>(peak));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void decode_seek(double, abort_callback&) {
|
||||
// The renderer is stateful and has no seek; can_seek() says so.
|
||||
throw exception_io_unsupported_format();
|
||||
}
|
||||
|
||||
bool decode_can_seek() { return false; }
|
||||
|
||||
size_t extended_param(const GUID& type, size_t arg1, void* arg2, size_t arg2size) {
|
||||
(void)arg1;
|
||||
(void)arg2;
|
||||
(void)arg2size;
|
||||
if (type == input_params::seeking_expensive) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void retag(const file_info&, abort_callback&) { throw exception_tagging_unsupported(); }
|
||||
void remove_tags(abort_callback&) { throw exception_tagging_unsupported(); }
|
||||
|
||||
static bool g_is_our_path(const char* path, const char* extension) {
|
||||
(void)path;
|
||||
// Claim by extension, then decide from the bitstream in open(): a file that
|
||||
// turns out not to carry JOC is handed back with
|
||||
// exception_io_unsupported_format, and the core moves on to the next
|
||||
// decoder in its priority table.
|
||||
return (extension != nullptr) &&
|
||||
((stricmp_utf8(extension, "eac3") == 0) || (stricmp_utf8(extension, "ec3") == 0));
|
||||
}
|
||||
|
||||
static bool g_is_our_content_type(const char* content_type) {
|
||||
(void)content_type;
|
||||
// Container dispatch (MP4 ec-3, Matroska A_EAC3) is not claimed: only bare
|
||||
// E-AC-3 streams are handled, so let the container readers have them.
|
||||
return false;
|
||||
}
|
||||
|
||||
static GUID g_get_guid() { return g_decoder_guid; }
|
||||
static const char* g_get_name() { return "JOC decoder (E-AC-3 JOC / Dolby Atmos)"; }
|
||||
static GUID g_get_preferences_guid() { return joc_prefs::page_guid(); }
|
||||
static bool g_is_low_merit() { return false; }
|
||||
|
||||
private:
|
||||
service_ptr_t<file> m_file;
|
||||
std::string m_path;
|
||||
pfc::string8 m_native_path;
|
||||
joc_decode::Settings m_settings;
|
||||
joc_decode::Engine m_engine;
|
||||
std::vector<float> m_buffer;
|
||||
unsigned m_channels = 2;
|
||||
std::uint64_t m_frames_delivered = 0;
|
||||
bool m_reported = false;
|
||||
};
|
||||
|
||||
static input_singletrack_factory_t<input_joc> g_input_joc_factory;
|
||||
|
||||
} // namespace
|
||||
|
||||
DECLARE_FILE_TYPE_EX("eac3;ec3", "E-AC-3 JOC file", "E-AC-3 JOC files");
|
||||
@@ -0,0 +1,721 @@
|
||||
#include "joc_decode.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
// The kernel copy that is compiled into this component; see kernel/.
|
||||
#include "../kernel/include/joc_core.h"
|
||||
#include "../kernel/include/joc_stream.h"
|
||||
|
||||
#include "eac3_scan.h"
|
||||
#include "log.h"
|
||||
|
||||
namespace joc_decode {
|
||||
namespace {
|
||||
|
||||
constexpr std::size_t kEac3Chunk = 96u * 1024u; // bytes read per push
|
||||
constexpr std::size_t kBedFramesChunk = 8192u; // staging capacity, in frames
|
||||
constexpr std::size_t kBedChannels = 6; // ffmpeg -ac 6
|
||||
// A read on an anonymous pipe only completes once the whole request is available,
|
||||
// so each read asks for a slice ffmpeg can always fill, and the pipe buffer is
|
||||
// sized well above that slice. Requesting the whole staging buffer deadlocks:
|
||||
// ffmpeg fills the pipe and blocks, while the reader waits for more than the pipe
|
||||
// can ever hold.
|
||||
constexpr std::size_t kBedReadBytes = 48u * 1024u;
|
||||
constexpr std::size_t kBedPipeBytes = 1u << 20;
|
||||
constexpr std::size_t kFrameSamples = JOC_FRAME_SAMPLES;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Kernel entry points.
|
||||
//
|
||||
// The kernel's own C++ sources are part of this component (see kernel/, a copy of
|
||||
// the project's source tree), so the public C ABI is linked in directly. There is
|
||||
// nothing to load at run time, and no way for the component and the renderer to
|
||||
// disagree about which ABI they were built against.
|
||||
// ---------------------------------------------------------------------------
|
||||
struct CoreApi {
|
||||
joc_error(JOC_CALL* create)(const joc_stream_config*, joc_stream**) = joc_stream_create;
|
||||
joc_error(JOC_CALL* push)(joc_stream*, const joc_stream_buffer*, std::uint32_t*,
|
||||
std::uint32_t*) = joc_stream_push;
|
||||
joc_error(JOC_CALL* pull)(joc_stream*, joc_stream_buffer*, std::uint32_t*) = joc_stream_pull;
|
||||
joc_error(JOC_CALL* flush)(joc_stream*) = joc_stream_flush;
|
||||
joc_error(JOC_CALL* status)(const joc_stream*, joc_stream_status_info*) = joc_stream_status;
|
||||
joc_error(JOC_CALL* destroy)(joc_stream*) = joc_stream_destroy;
|
||||
std::uint32_t(JOC_CALL* abi_version)() = joc_abi_version;
|
||||
const char*(JOC_CALL* version_string)() = joc_version_string;
|
||||
const char*(JOC_CALL* error_name)(joc_error) = joc_error_name;
|
||||
};
|
||||
|
||||
std::wstring utf8_to_wide(const std::string& text) {
|
||||
if (text.empty()) return {};
|
||||
const int needed = MultiByteToWideChar(CP_UTF8, 0, text.c_str(),
|
||||
static_cast<int>(text.size()), nullptr, 0);
|
||||
std::wstring out(static_cast<std::size_t>(needed), L'\0');
|
||||
MultiByteToWideChar(CP_UTF8, 0, text.c_str(), static_cast<int>(text.size()), out.data(),
|
||||
needed);
|
||||
return out;
|
||||
}
|
||||
|
||||
bool file_exists(const std::string& path) {
|
||||
const std::wstring wide = utf8_to_wide(path);
|
||||
if (wide.empty()) return false;
|
||||
const DWORD attributes = GetFileAttributesW(wide.c_str());
|
||||
return attributes != INVALID_FILE_ATTRIBUTES &&
|
||||
(attributes & FILE_ATTRIBUTE_DIRECTORY) == 0;
|
||||
}
|
||||
|
||||
// The renderer is inside this binary, so "loading" it is just an ABI check.
|
||||
bool load_api(const std::string& explicit_path, CoreApi* api, std::string* error) {
|
||||
(void)explicit_path;
|
||||
if (api->abi_version() != JOC_ABI_VERSION) {
|
||||
if (error != nullptr) *error = "the bundled renderer has an unexpected ABI";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const char* error_text(const CoreApi& api, joc_error code) {
|
||||
if (api.error_name != nullptr) {
|
||||
const char* name = api.error_name(code);
|
||||
if (name != nullptr) return name;
|
||||
}
|
||||
return "unknown core error";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ffmpeg child process producing the 5.1 core PCM on a pipe.
|
||||
// ---------------------------------------------------------------------------
|
||||
class BedProcess {
|
||||
public:
|
||||
~BedProcess() { stop(); }
|
||||
|
||||
bool start(const std::string& ffmpeg_path, const std::string& input_path,
|
||||
const std::wstring& stderr_path, std::string* error) {
|
||||
SECURITY_ATTRIBUTES attributes{};
|
||||
attributes.nLength = sizeof(attributes);
|
||||
attributes.bInheritHandle = TRUE;
|
||||
HANDLE read_end = nullptr;
|
||||
HANDLE write_end = nullptr;
|
||||
if (CreatePipe(&read_end, &write_end, &attributes, static_cast<DWORD>(kBedPipeBytes)) == FALSE) {
|
||||
if (error != nullptr) *error = "cannot create the core PCM pipe";
|
||||
return false;
|
||||
}
|
||||
// Only the child's end is inheritable.
|
||||
SetHandleInformation(read_end, HANDLE_FLAG_INHERIT, 0);
|
||||
|
||||
HANDLE null_input = CreateFileW(L"NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
&attributes, OPEN_EXISTING, 0, nullptr);
|
||||
HANDLE error_file = CreateFileW(stderr_path.c_str(), GENERIC_WRITE,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE, &attributes,
|
||||
CREATE_ALWAYS, 0, nullptr);
|
||||
|
||||
std::wstring command = L"\"" + utf8_to_wide(ffmpeg_path) + L"\"";
|
||||
command += L" -hide_banner -loglevel error -nostdin -y -i \"";
|
||||
command += utf8_to_wide(input_path);
|
||||
// Identical to the reference CLI's core decode: 5.1 interleaved float32
|
||||
// at 48 kHz, which is the layout the rendering core expects
|
||||
// (L R C LFE Ls Rs).
|
||||
command += L"\" -map 0:a:0 -vn -ac 6 -ar 48000 -c:a pcm_f32le -f f32le -";
|
||||
|
||||
STARTUPINFOW startup{};
|
||||
startup.cb = sizeof(startup);
|
||||
startup.dwFlags = STARTF_USESTDHANDLES;
|
||||
startup.hStdInput = null_input;
|
||||
startup.hStdOutput = write_end;
|
||||
startup.hStdError = (error_file != INVALID_HANDLE_VALUE) ? error_file : null_input;
|
||||
|
||||
PROCESS_INFORMATION process{};
|
||||
std::vector<wchar_t> mutable_command(command.begin(), command.end());
|
||||
mutable_command.push_back(L'\0');
|
||||
const BOOL created = CreateProcessW(nullptr, mutable_command.data(), nullptr, nullptr,
|
||||
TRUE, CREATE_NO_WINDOW, nullptr, nullptr, &startup,
|
||||
&process);
|
||||
CloseHandle(write_end);
|
||||
if (null_input != INVALID_HANDLE_VALUE) CloseHandle(null_input);
|
||||
if (error_file != INVALID_HANDLE_VALUE) CloseHandle(error_file);
|
||||
if (created == FALSE) {
|
||||
CloseHandle(read_end);
|
||||
if (error != nullptr) *error = "cannot start ffmpeg for the 5.1 core PCM";
|
||||
return false;
|
||||
}
|
||||
CloseHandle(process.hThread);
|
||||
pipe_ = read_end;
|
||||
process_ = process.hProcess;
|
||||
joc_log::line("core bed: ffmpeg started (pid %lu)", process.dwProcessId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Returns bytes read; 0 means end of stream.
|
||||
std::size_t read(void* destination, std::size_t bytes) {
|
||||
if (pipe_ == nullptr) return 0;
|
||||
DWORD got = 0;
|
||||
if (ReadFile(pipe_, destination, static_cast<DWORD>(bytes), &got, nullptr) == FALSE) {
|
||||
return 0;
|
||||
}
|
||||
return got;
|
||||
}
|
||||
|
||||
void stop() {
|
||||
if (pipe_ != nullptr) {
|
||||
CloseHandle(pipe_);
|
||||
pipe_ = nullptr;
|
||||
}
|
||||
if (process_ != nullptr) {
|
||||
// ffmpeg is normally gone by now (its stdout was drained); ask it to
|
||||
// finish rather than killing it, and only then let go.
|
||||
if (WaitForSingleObject(process_, 5000) == WAIT_TIMEOUT) {
|
||||
TerminateProcess(process_, 1);
|
||||
}
|
||||
CloseHandle(process_);
|
||||
process_ = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
bool finished() const { return pipe_ == nullptr; }
|
||||
|
||||
private:
|
||||
HANDLE pipe_ = nullptr;
|
||||
HANDLE process_ = nullptr;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small file reader (wide paths, no CRT locale involved).
|
||||
// ---------------------------------------------------------------------------
|
||||
class InputFile {
|
||||
public:
|
||||
~InputFile() { close(); }
|
||||
bool open(const std::string& path) {
|
||||
handle_ = CreateFileW(utf8_to_wide(path).c_str(), GENERIC_READ,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING,
|
||||
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_SEQUENTIAL_SCAN, nullptr);
|
||||
return handle_ != INVALID_HANDLE_VALUE;
|
||||
}
|
||||
std::size_t read(void* destination, std::size_t bytes) {
|
||||
if (handle_ == INVALID_HANDLE_VALUE) return 0;
|
||||
DWORD got = 0;
|
||||
if (ReadFile(handle_, destination, static_cast<DWORD>(bytes), &got, nullptr) == FALSE) {
|
||||
return 0;
|
||||
}
|
||||
return got;
|
||||
}
|
||||
std::uint64_t size() const {
|
||||
LARGE_INTEGER value{};
|
||||
if (handle_ == INVALID_HANDLE_VALUE || GetFileSizeEx(handle_, &value) == FALSE) return 0;
|
||||
return static_cast<std::uint64_t>(value.QuadPart);
|
||||
}
|
||||
void close() {
|
||||
if (handle_ != INVALID_HANDLE_VALUE) {
|
||||
CloseHandle(handle_);
|
||||
handle_ = INVALID_HANDLE_VALUE;
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
HANDLE handle_ = INVALID_HANDLE_VALUE;
|
||||
};
|
||||
|
||||
const char* const kLayouts[] = {"2.0", "3.0", "3.1", "4.0", "5.0", "5.1", "5.1.2",
|
||||
"5.1.4", "6.1", "7.0", "7.1", "7.1.2", "7.1.4", "9.1.4",
|
||||
"9.1.6", "22.2"};
|
||||
const unsigned kLayoutChannels[] = {2, 3, 4, 4, 5, 6, 8, 10, 7, 7, 8, 10, 12, 14, 16, 24};
|
||||
|
||||
} // namespace
|
||||
|
||||
const char* const* speaker_layouts(std::size_t* count) {
|
||||
if (count != nullptr) *count = sizeof(kLayouts) / sizeof(kLayouts[0]);
|
||||
return kLayouts;
|
||||
}
|
||||
|
||||
unsigned speaker_channels(const std::string& layout) {
|
||||
for (std::size_t i = 0; i < sizeof(kLayouts) / sizeof(kLayouts[0]); ++i) {
|
||||
if (layout == kLayouts[i]) return kLayoutChannels[i];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
std::string component_directory() {
|
||||
HMODULE self = nullptr;
|
||||
if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
|
||||
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
reinterpret_cast<LPCWSTR>(&component_directory), &self) == FALSE) {
|
||||
return {};
|
||||
}
|
||||
wchar_t path[4096] = {};
|
||||
const DWORD length = GetModuleFileNameW(self, path, 4096);
|
||||
if (length == 0) return {};
|
||||
const std::wstring text(path, length);
|
||||
const std::wstring::size_type slash = text.find_last_of(L"\\/");
|
||||
if (slash == std::wstring::npos) return {};
|
||||
const std::wstring directory = text.substr(0, slash);
|
||||
const int needed = WideCharToMultiByte(CP_UTF8, 0, directory.c_str(),
|
||||
static_cast<int>(directory.size()), nullptr, 0,
|
||||
nullptr, nullptr);
|
||||
std::string out(static_cast<std::size_t>(needed), '\0');
|
||||
WideCharToMultiByte(CP_UTF8, 0, directory.c_str(), static_cast<int>(directory.size()),
|
||||
out.data(), needed, nullptr, nullptr);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string resolve_hrtf_file(const Settings& settings) {
|
||||
if (!settings.hrtf_file.empty()) return settings.hrtf_file;
|
||||
const std::string directory = component_directory();
|
||||
if (directory.empty()) return {};
|
||||
// Same convention as the reference CLI next to its executable:
|
||||
// <base>\HRTF\binaural.sofa or <base>\HRTF\binaural.personalized_headphone.
|
||||
const char* name = (settings.hrtf_source == HrtfSource::kRosella)
|
||||
? "binaural.personalized_headphone"
|
||||
: "binaural.sofa";
|
||||
return directory + "\\HRTF\\" + name;
|
||||
}
|
||||
|
||||
FileProbe probe_file(const std::string& path, std::size_t max_scan_bytes) {
|
||||
FileProbe probe;
|
||||
InputFile file;
|
||||
if (!file.open(path)) {
|
||||
probe.detail = "cannot open";
|
||||
return probe;
|
||||
}
|
||||
const std::uint64_t size = file.size();
|
||||
|
||||
// A Media Library scan calls this for every file, so the whole stream is
|
||||
// walked only when that is cheap; otherwise the first window is enough,
|
||||
// because E-AC-3 syncframes in a stream like this are all the same size.
|
||||
const std::uint64_t kFullWalkLimit = 16u * 1024u * 1024u;
|
||||
const std::size_t window =
|
||||
(max_scan_bytes != 0) ? max_scan_bytes : 256u * 1024u;
|
||||
std::vector<std::uint8_t> buffer(static_cast<std::size_t>(
|
||||
(size < kFullWalkLimit && size > 0) ? size : window));
|
||||
const std::size_t got = file.read(buffer.data(), buffer.size());
|
||||
if (got < 8) {
|
||||
probe.detail = "file too small to be E-AC-3";
|
||||
return probe;
|
||||
}
|
||||
|
||||
const joc_eac3::ScanResult scan = joc_eac3::scan(buffer.data(), got, 8);
|
||||
probe.readable = true;
|
||||
probe.joc = (scan.joc == joc_eac3::JocState::kYes);
|
||||
probe.detail = scan.detail;
|
||||
if (scan.frames_examined == 0 || scan.first_frame_bytes == 0) {
|
||||
probe.detail = "not a bare E-AC-3 stream";
|
||||
return probe;
|
||||
}
|
||||
|
||||
std::uint64_t frames = 0;
|
||||
if (buffer.size() == size) {
|
||||
std::size_t offset = 0;
|
||||
while (true) {
|
||||
const std::size_t bytes = joc_eac3::frame_bytes_at(buffer.data(), got, offset);
|
||||
if (bytes == 0 || offset + bytes > got) break;
|
||||
offset += bytes;
|
||||
++frames;
|
||||
}
|
||||
probe.detail = "frame count walked over the whole file";
|
||||
} else if (scan.all_frames_same_size) {
|
||||
frames = size / scan.first_frame_bytes;
|
||||
probe.detail = "frame count extrapolated from a constant frame size";
|
||||
} else {
|
||||
// Variable frame size: count in the window and scale by the byte ratio.
|
||||
std::size_t offset = 0;
|
||||
std::uint64_t seen = 0;
|
||||
while (true) {
|
||||
const std::size_t bytes = joc_eac3::frame_bytes_at(buffer.data(), got, offset);
|
||||
if (bytes == 0 || offset + bytes > got) break;
|
||||
offset += bytes;
|
||||
++seen;
|
||||
}
|
||||
frames = (offset != 0) ? static_cast<std::uint64_t>(
|
||||
(static_cast<double>(size) / static_cast<double>(offset)) *
|
||||
static_cast<double>(seen))
|
||||
: 0;
|
||||
probe.detail = "frame count estimated from a variable frame size";
|
||||
}
|
||||
|
||||
probe.frames = frames;
|
||||
probe.sample_rate = 48000;
|
||||
probe.channels = 6; // the core audio of an E-AC-3 JOC stream
|
||||
probe.duration_seconds =
|
||||
static_cast<double>(frames) * static_cast<double>(kFrameSamples) / 48000.0;
|
||||
return probe;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Engine
|
||||
// ---------------------------------------------------------------------------
|
||||
struct Engine::Impl {
|
||||
CoreApi api;
|
||||
joc_stream* stream = nullptr;
|
||||
InputFile eac3;
|
||||
BedProcess bed;
|
||||
Settings settings;
|
||||
unsigned channels = 0;
|
||||
std::uint64_t frames_queued = 0; // E-AC-3 frames handed to the core
|
||||
std::uint64_t bed_frames_pushed = 0;
|
||||
bool eac3_eof = false;
|
||||
bool bed_eof = false;
|
||||
bool flushed = false;
|
||||
unsigned trace_count = 0;
|
||||
unsigned read_calls = 0;
|
||||
std::size_t eac3_carry = 0; // trailing partial syncframe, for exact counting
|
||||
std::uint64_t bed_bytes_read = 0;
|
||||
std::vector<std::uint8_t> eac3_buffer;
|
||||
std::vector<float> bed_buffer;
|
||||
std::size_t bed_staged_bytes = 0; // bytes staged at the front of bed_buffer
|
||||
std::vector<float> pull_buffer;
|
||||
};
|
||||
|
||||
Engine::Engine() : impl_(new Impl()) {}
|
||||
|
||||
Engine::~Engine() {
|
||||
stop();
|
||||
delete impl_;
|
||||
}
|
||||
|
||||
unsigned Engine::channels() const { return impl_->channels; }
|
||||
|
||||
void Engine::stop() {
|
||||
Impl& impl = *impl_;
|
||||
if (impl.stream != nullptr && impl.api.destroy != nullptr) {
|
||||
impl.api.destroy(impl.stream);
|
||||
impl.stream = nullptr;
|
||||
}
|
||||
impl.bed.stop();
|
||||
impl.eac3.close();
|
||||
}
|
||||
|
||||
bool Engine::start(const std::string& input_path, const Settings& settings, std::string* error) {
|
||||
Impl& impl = *impl_;
|
||||
impl.settings = settings;
|
||||
impl.eac3_buffer.resize(kEac3Chunk);
|
||||
impl.bed_buffer.resize(kBedFramesChunk * kBedChannels);
|
||||
|
||||
if (!load_api(std::string(), &impl.api, error)) return false;
|
||||
const std::uint32_t abi = impl.api.abi_version();
|
||||
const char* version = impl.api.version_string != nullptr ? impl.api.version_string() : "?";
|
||||
joc_log::line("core: in-process renderer %s (abi %u), component built against abi %u",
|
||||
version, abi, static_cast<unsigned>(JOC_ABI_VERSION));
|
||||
if (abi != JOC_ABI_VERSION) {
|
||||
joc_log::line("core: ABI mismatch; refusing to continue");
|
||||
if (error != nullptr) *error = "the bundled renderer ABI does not match the component";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!impl.eac3.open(input_path)) {
|
||||
if (error != nullptr) *error = "cannot open the input file";
|
||||
return false;
|
||||
}
|
||||
|
||||
joc_stream_config config{};
|
||||
config.struct_size = sizeof(config);
|
||||
config.struct_version = 1;
|
||||
config.input = JOC_STREAM_IN_EAC3;
|
||||
config.output = (settings.output == Output::kBinaural) ? JOC_STREAM_OUT_BINAURAL
|
||||
: JOC_STREAM_OUT_SPEAKER;
|
||||
config.speaker_layout_name = settings.speaker_layout.c_str();
|
||||
config.speaker_metadata_offset = 0;
|
||||
config.binaural_mode = settings.binaural_mode;
|
||||
// The filter bank tables live in the library (hrtf::builtin_kernels in
|
||||
// kernel_tables.cpp), so there is no table path to configure and none is
|
||||
// passed: the field stays null. The same goes for the compiled-HRTF cache:
|
||||
// it is the renderer's internal business, not a user choice.
|
||||
config.kernels_path = nullptr;
|
||||
config.hrtf_path = nullptr;
|
||||
// Only binaural output reads an HRTF at all: a speaker layout must not be
|
||||
// blocked by a missing HRTF file, and must not have to name one.
|
||||
std::string hrtf_in_use;
|
||||
if (settings.output == Output::kBinaural) {
|
||||
hrtf_in_use = resolve_hrtf_file(settings);
|
||||
const std::string& hrtf_file = hrtf_in_use;
|
||||
if (hrtf_file.empty()) {
|
||||
if (error != nullptr) {
|
||||
*error = "双耳渲染需要 HRTF 文件,但组件目录无法确定;请在设置页里显式指定路径";
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (!file_exists(hrtf_file)) {
|
||||
if (error != nullptr) {
|
||||
*error = std::string("找不到 HRTF 文件:") + hrtf_file +
|
||||
(settings.hrtf_file.empty() ? "(默认位置,可在设置页里指定其它路径)"
|
||||
: "");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (settings.hrtf_source == HrtfSource::kRosella) {
|
||||
config.personalized_headphone_path = hrtf_file.c_str();
|
||||
} else {
|
||||
config.hrtf_sofa_path = hrtf_file.c_str();
|
||||
}
|
||||
}
|
||||
config.hrtf_cache_policy = settings.hrtf_cache_policy;
|
||||
config.hrtf_cache_dir =
|
||||
settings.hrtf_cache_dir.empty() ? nullptr : settings.hrtf_cache_dir.c_str();
|
||||
config.hrtf_radius_m = settings.hrtf_radius_m;
|
||||
config.binaural_tail_seconds = settings.tail_seconds;
|
||||
config.object_delay_samples = settings.object_delay_samples;
|
||||
config.gain_db = settings.gain_db;
|
||||
config.native_threads = settings.native_threads;
|
||||
|
||||
const joc_error created = impl.api.create(&config, &impl.stream);
|
||||
if (created != JOC_OK) {
|
||||
if (error != nullptr) {
|
||||
*error = std::string("cannot create the render stream: ") +
|
||||
error_text(impl.api, created);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
joc_stream_status_info status{};
|
||||
status.struct_size = sizeof(status);
|
||||
status.struct_version = 1;
|
||||
if (impl.api.status(impl.stream, &status) == JOC_OK && status.output_channels != 0u) {
|
||||
impl.channels = status.output_channels;
|
||||
} else {
|
||||
impl.channels = (settings.output == Output::kBinaural)
|
||||
? 2u
|
||||
: speaker_channels(settings.speaker_layout);
|
||||
}
|
||||
joc_log::line("core: stream created, %u output channel(s), layout=%s, hrtf=%s", impl.channels,
|
||||
settings.speaker_layout.c_str(),
|
||||
hrtf_in_use.empty() ? "(none)" : hrtf_in_use.c_str());
|
||||
|
||||
const std::wstring stderr_path = [] {
|
||||
HMODULE self = nullptr;
|
||||
GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
|
||||
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
reinterpret_cast<LPCWSTR>(&speaker_channels), &self);
|
||||
wchar_t path[4096] = {};
|
||||
const DWORD length = GetModuleFileNameW(self, path, 4096);
|
||||
if (length == 0) return std::wstring(L"joc_ffmpeg.log");
|
||||
const std::wstring text(path, length);
|
||||
const std::wstring::size_type slash = text.find_last_of(L"\\/");
|
||||
return slash == std::wstring::npos ? std::wstring(L"joc_ffmpeg.log")
|
||||
: text.substr(0, slash) + L"\\joc_ffmpeg.log";
|
||||
}();
|
||||
if (!impl.bed.start(settings.ffmpeg_path, input_path, stderr_path, error)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::size_t Engine::read(float* destination, std::size_t frames, std::string* error) {
|
||||
Impl& impl = *impl_;
|
||||
if (impl.stream == nullptr || frames == 0) return 0;
|
||||
const bool trace = impl.trace_count < 6;
|
||||
++impl.read_calls;
|
||||
if ((impl.read_calls % 50u) == 0u) {
|
||||
joc_log::line("engine: call %u queued=%llu bed=%llu staged_bytes=%llu eac3_eof=%d bed_eof=%d flushed=%d",
|
||||
impl.read_calls, static_cast<unsigned long long>(impl.frames_queued),
|
||||
static_cast<unsigned long long>(impl.bed_frames_pushed),
|
||||
static_cast<unsigned long long>(impl.bed_staged_bytes), impl.eac3_eof ? 1 : 0,
|
||||
impl.bed_eof ? 1 : 0, impl.flushed ? 1 : 0);
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
if (trace) {
|
||||
joc_log::line("engine: loop eac3_eof=%d bed_eof=%d queued=%llu bed=%llu staged_bytes=%llu",
|
||||
impl.eac3_eof ? 1 : 0, impl.bed_eof ? 1 : 0,
|
||||
static_cast<unsigned long long>(impl.frames_queued),
|
||||
static_cast<unsigned long long>(impl.bed_frames_pushed),
|
||||
static_cast<unsigned long long>(impl.bed_staged_bytes));
|
||||
}
|
||||
// Feed the metadata stream, keeping it only a little ahead of the bed so
|
||||
// the core's pairing queue stays small.
|
||||
// A test-side input limit has to look like end of file, or the loop below
|
||||
// never reaches its terminal state.
|
||||
if (impl.settings.input_frame_limit != 0 &&
|
||||
impl.frames_queued >= impl.settings.input_frame_limit) {
|
||||
impl.eac3_eof = true;
|
||||
}
|
||||
|
||||
if (!impl.eac3_eof && impl.frames_queued <= impl.bed_frames_pushed + 2u &&
|
||||
(impl.settings.input_frame_limit == 0 ||
|
||||
impl.frames_queued < impl.settings.input_frame_limit)) {
|
||||
// The tail of a chunk is usually the head of the next syncframe. It
|
||||
// stays at the front of the buffer so the frame count stays exact
|
||||
// across chunk boundaries: counting each chunk on its own loses one
|
||||
// frame at the end, which leaves the bed a frame short and the last
|
||||
// frame of the file unrendered.
|
||||
const std::size_t got = impl.eac3.read(impl.eac3_buffer.data() + impl.eac3_carry,
|
||||
impl.eac3_buffer.size() - impl.eac3_carry);
|
||||
const std::size_t total = impl.eac3_carry + got;
|
||||
if (total == 0) {
|
||||
impl.eac3_eof = true;
|
||||
} else {
|
||||
std::size_t offset = 0;
|
||||
std::uint64_t complete = 0;
|
||||
while (true) {
|
||||
const std::size_t bytes =
|
||||
joc_eac3::frame_bytes_at(impl.eac3_buffer.data(), total, offset);
|
||||
if (bytes == 0 || offset + bytes > total) break;
|
||||
offset += bytes;
|
||||
++complete;
|
||||
}
|
||||
// With an input limit the chunk is cut at a frame boundary: the
|
||||
// renderer's output depends on how many frames it was given, so a
|
||||
// limit that overshoots to the end of the read buffer would not
|
||||
// reproduce a run that stopped earlier.
|
||||
std::size_t push_bytes = offset;
|
||||
std::uint64_t pushed_frames = complete;
|
||||
if (impl.settings.input_frame_limit != 0) {
|
||||
const std::uint64_t room =
|
||||
impl.settings.input_frame_limit - impl.frames_queued;
|
||||
if (complete > room) {
|
||||
pushed_frames = room;
|
||||
std::size_t walk = 0;
|
||||
for (std::uint64_t index = 0; index < pushed_frames; ++index) {
|
||||
const std::size_t bytes = joc_eac3::frame_bytes_at(
|
||||
impl.eac3_buffer.data(), total, walk);
|
||||
if (bytes == 0 || walk + bytes > total) break;
|
||||
walk += bytes;
|
||||
}
|
||||
push_bytes = walk;
|
||||
}
|
||||
}
|
||||
joc_stream_buffer input{};
|
||||
input.struct_size = sizeof(input);
|
||||
input.struct_version = 1;
|
||||
input.kind = JOC_STREAM_IN_EAC3;
|
||||
input.bytes = impl.eac3_buffer.data();
|
||||
input.byte_count = static_cast<std::uint32_t>(push_bytes);
|
||||
const joc_error pushed = impl.api.push(impl.stream, &input, nullptr, nullptr);
|
||||
if (pushed != JOC_OK) {
|
||||
if (error != nullptr) {
|
||||
*error = std::string("E-AC-3 push failed: ") +
|
||||
error_text(impl.api, pushed);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
impl.frames_queued += pushed_frames;
|
||||
impl.eac3_carry = total - push_bytes;
|
||||
if (impl.eac3_carry != 0 && push_bytes != 0) {
|
||||
std::memmove(impl.eac3_buffer.data(), impl.eac3_buffer.data() + push_bytes,
|
||||
impl.eac3_carry);
|
||||
}
|
||||
if (got == 0) impl.eac3_eof = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Feed the core PCM until the bed has caught up with the metadata. The
|
||||
// core pairs one syncframe with exactly 1536 bed samples, so only whole
|
||||
// frames are pushed; whatever is left over stays at the front of the
|
||||
// staging buffer. Staging counted in bytes, not samples: a pipe read may
|
||||
// return any number of bytes, and rounding a read down to whole samples
|
||||
// quietly drops the rest of the stream's alignment.
|
||||
while (!impl.bed_eof && impl.bed_frames_pushed < impl.frames_queued) {
|
||||
constexpr std::size_t kFrameBytes = kFrameSamples * kBedChannels * sizeof(float);
|
||||
const std::size_t whole_frames = impl.bed_staged_bytes / kFrameBytes;
|
||||
if (whole_frames != 0) {
|
||||
joc_stream_buffer input{};
|
||||
input.struct_size = sizeof(input);
|
||||
input.struct_version = 1;
|
||||
input.kind = JOC_STREAM_IN_CORE_PCM;
|
||||
input.pcm = impl.bed_buffer.data();
|
||||
input.channels = static_cast<std::uint32_t>(kBedChannels);
|
||||
input.sample_rate = 48000;
|
||||
input.sample_count = static_cast<std::uint32_t>(whole_frames * kFrameSamples);
|
||||
const joc_error pushed = impl.api.push(impl.stream, &input, nullptr, nullptr);
|
||||
if (pushed != JOC_OK) {
|
||||
if (error != nullptr) {
|
||||
*error = std::string("core PCM push failed: ") +
|
||||
error_text(impl.api, pushed);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
impl.bed_frames_pushed += whole_frames;
|
||||
const std::size_t pushed_bytes = whole_frames * kFrameBytes;
|
||||
impl.bed_staged_bytes -= pushed_bytes;
|
||||
if (impl.bed_staged_bytes != 0) {
|
||||
std::memmove(impl.bed_buffer.data(),
|
||||
reinterpret_cast<const std::uint8_t*>(impl.bed_buffer.data()) +
|
||||
pushed_bytes,
|
||||
impl.bed_staged_bytes);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Less than one whole frame staged: read more.
|
||||
const std::size_t capacity_bytes = impl.bed_buffer.size() * sizeof(float);
|
||||
const std::size_t room = capacity_bytes - impl.bed_staged_bytes;
|
||||
const std::size_t want =
|
||||
(room < kBedReadBytes) ? room : kBedReadBytes;
|
||||
if (want == 0) break; // cannot happen while capacity exceeds a frame
|
||||
const std::size_t bytes = impl.bed.read(
|
||||
reinterpret_cast<std::uint8_t*>(impl.bed_buffer.data()) + impl.bed_staged_bytes,
|
||||
want);
|
||||
if (bytes == 0) {
|
||||
impl.bed_eof = true;
|
||||
break;
|
||||
}
|
||||
impl.bed_bytes_read += bytes;
|
||||
impl.bed_staged_bytes += bytes;
|
||||
}
|
||||
|
||||
// Take whatever the renderer has.
|
||||
joc_stream_buffer output{};
|
||||
output.struct_size = sizeof(output);
|
||||
output.struct_version = 1;
|
||||
output.kind = (impl.settings.output == Output::kBinaural) ? JOC_STREAM_OUT_BINAURAL
|
||||
: JOC_STREAM_OUT_SPEAKER;
|
||||
output.channels = impl.channels;
|
||||
output.sample_rate = 48000;
|
||||
output.sample_count = static_cast<std::uint32_t>(frames);
|
||||
output.out_pcm = destination;
|
||||
std::uint32_t produced = 0;
|
||||
const joc_error pulled = impl.api.pull(impl.stream, &output, &produced);
|
||||
if (pulled != JOC_OK) {
|
||||
if (error != nullptr) {
|
||||
*error = std::string("pull failed: ") + error_text(impl.api, pulled);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (produced != 0u) {
|
||||
if (trace) {
|
||||
++impl.trace_count;
|
||||
joc_log::line("engine: pulled %u frame(s) on the %u%s attempt", produced,
|
||||
impl.trace_count, impl.trace_count == 1 ? "st" : "th");
|
||||
}
|
||||
return produced;
|
||||
}
|
||||
|
||||
// Nothing more can arrive once the metadata stream is drained and the bed
|
||||
// has caught up with it. The bed process is normally stopped as a
|
||||
// consequence of that catch-up rather than by reaching its own end, so
|
||||
// waiting for bed_eof here would spin forever.
|
||||
const bool no_more_input =
|
||||
impl.eac3_eof &&
|
||||
(impl.bed_eof || impl.bed_frames_pushed >= impl.frames_queued);
|
||||
if (no_more_input) {
|
||||
if (!impl.flushed) {
|
||||
const joc_error flushed = impl.api.flush(impl.stream);
|
||||
if (flushed != JOC_OK) {
|
||||
if (error != nullptr) {
|
||||
*error = std::string("flush failed: ") + error_text(impl.api, flushed);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
impl.flushed = true;
|
||||
joc_stream_status_info status{};
|
||||
status.struct_size = sizeof(status);
|
||||
status.struct_version = 1;
|
||||
if (impl.api.status(impl.stream, &status) == JOC_OK) {
|
||||
joc_log::line(
|
||||
"core: exhausted (eac3 frames queued=%llu, bed frames pushed=%llu); "
|
||||
"core counters frames_in=%llu frames_out=%llu samples_out=%llu "
|
||||
"buffered=%llu",
|
||||
static_cast<unsigned long long>(impl.frames_queued),
|
||||
static_cast<unsigned long long>(impl.bed_frames_pushed),
|
||||
static_cast<unsigned long long>(status.frames_in),
|
||||
static_cast<unsigned long long>(status.frames_out),
|
||||
static_cast<unsigned long long>(status.samples_out),
|
||||
static_cast<unsigned long long>(status.buffered_samples));
|
||||
joc_log::line("core: bed bytes read=%llu, staged samples left=%llu",
|
||||
static_cast<unsigned long long>(impl.bed_bytes_read),
|
||||
static_cast<unsigned long long>(impl.bed_staged_bytes));
|
||||
}
|
||||
continue; // drain the tail
|
||||
}
|
||||
return 0; // genuinely done
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace joc_decode
|
||||
@@ -0,0 +1,108 @@
|
||||
// Decode engine: E-AC-3 JOC file -> rendered PCM, without any foobar2000 types.
|
||||
//
|
||||
// Kept free of fb2k headers on purpose: the same code is what a headless harness
|
||||
// drives when the output has to be compared byte for byte against joc_cli.
|
||||
//
|
||||
// Two inputs are needed to render, and they come from different places:
|
||||
// * the bare E-AC-3 syncframes -- the metadata stream -- read straight from the
|
||||
// file, since the plugin only claims bare .eac3/.ec3 today;
|
||||
// * the 5.1 core PCM of those same frames -- the rendering core does not decode
|
||||
// the core (it has no AC-3 decoder at all), so ffmpeg produces it exactly the
|
||||
// way the reference CLI does.
|
||||
//
|
||||
// The core is loaded from joc_core.dll through the public C ABI and called via
|
||||
// function pointers. Static linking is not an option: the core's static library
|
||||
// carries the internals but not the API layer in src/api, and the plugin is not
|
||||
// allowed to compile core sources. Loading explicitly also lets the component
|
||||
// ship its own copy of the DLL and check its ABI version at start-up.
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace joc_decode {
|
||||
|
||||
enum class Output {
|
||||
kBinaural = 0, // 2 channels, HRTF rendering
|
||||
kSpeaker = 1, // N channels, named layout
|
||||
};
|
||||
|
||||
// Which HRTF to render with. Both inputs are user-facing model/data files; how
|
||||
// the renderer gets from them to its internal filter field is its own business.
|
||||
enum class HrtfSource {
|
||||
kSofa = 0,
|
||||
kRosella = 1,
|
||||
};
|
||||
|
||||
struct Settings {
|
||||
Output output = Output::kBinaural;
|
||||
std::string speaker_layout = "7.1";
|
||||
HrtfSource hrtf_source = HrtfSource::kSofa;
|
||||
// Empty means the default file in the default folder, see resolve_hrtf_file().
|
||||
std::string hrtf_file;
|
||||
std::string hrtf_cache_dir; // SOFA compile cache (disk policy)
|
||||
unsigned hrtf_cache_policy = 1; // 0 none, 1 memory, 2 disk
|
||||
double hrtf_radius_m = 1.0; // SOFA measurement-radius shell
|
||||
std::uint32_t binaural_mode = 3; // JOC_BINAURAL_MID
|
||||
double gain_db = 0.0;
|
||||
double tail_seconds = 5.0;
|
||||
std::uint32_t object_delay_samples = 1473;
|
||||
std::uint32_t native_threads = 0;
|
||||
std::string ffmpeg_path = "ffmpeg";
|
||||
// Stop feeding the renderer after this many input syncframes and flush, which
|
||||
// is what the reference CLI's --duration does. Zero means "the whole file".
|
||||
// Only the comparison harness sets it; playback leaves it at zero.
|
||||
std::uint64_t input_frame_limit = 0;
|
||||
};
|
||||
|
||||
// Channel count of a named layout, or 0 when the layout is not one of the
|
||||
// layouts the core accepts.
|
||||
unsigned speaker_channels(const std::string& layout);
|
||||
|
||||
// Directory this component's DLL was loaded from. The default HRTF folder is
|
||||
// <that directory>\HRTF, the same place the reference CLI looks next to its exe.
|
||||
std::string component_directory();
|
||||
|
||||
// The HRTF file a configuration will actually use: the configured path, or the
|
||||
// default file in the default folder when none is configured. Empty when the
|
||||
// component directory cannot be determined.
|
||||
std::string resolve_hrtf_file(const Settings& settings);
|
||||
|
||||
// Every layout the core accepts, in the order a settings page should list them.
|
||||
const char* const* speaker_layouts(std::size_t* count);
|
||||
|
||||
// Result of examining a file without rendering anything.
|
||||
struct FileProbe {
|
||||
bool readable = false;
|
||||
bool joc = false; // every examined syncframe carries JOC
|
||||
std::uint32_t sample_rate = 0;
|
||||
std::uint32_t channels = 0; // core 5.1 layout, i.e. 6 (including LFE)
|
||||
std::uint64_t frames = 0; // E-AC-3 syncframes in the file
|
||||
double duration_seconds = 0.0;
|
||||
std::string detail;
|
||||
};
|
||||
|
||||
// Walks the file's syncframes. Cheap enough for a Media Library scan: it only
|
||||
// does pointer arithmetic over the stream, no decoding, no HRTF work.
|
||||
FileProbe probe_file(const std::string& path, std::size_t max_scan_bytes = 0);
|
||||
|
||||
class Engine {
|
||||
public:
|
||||
Engine();
|
||||
~Engine();
|
||||
Engine(const Engine&) = delete;
|
||||
Engine& operator=(const Engine&) = delete;
|
||||
|
||||
bool start(const std::string& input_path, const Settings& settings, std::string* error);
|
||||
// Interleaved float32; returns frames produced per channel, 0 means end of stream.
|
||||
std::size_t read(float* destination, std::size_t frames, std::string* error);
|
||||
unsigned channels() const;
|
||||
void stop();
|
||||
|
||||
private:
|
||||
struct Impl;
|
||||
Impl* impl_;
|
||||
};
|
||||
|
||||
} // namespace joc_decode
|
||||
@@ -0,0 +1,26 @@
|
||||
// Force-included into every translation unit of this project (ForcedIncludeFiles).
|
||||
//
|
||||
// The SDK is written against the canonical component PCH, which pulls in large
|
||||
// parts of the standard library before any SDK header is seen. Without a PCH the
|
||||
// SDK headers can be the first thing a translation unit includes, and several of
|
||||
// them use std:: types without including the header that declares them (initquit.h
|
||||
// needs <functional>, for one). Listing the standard headers here keeps that
|
||||
// dependency explicit instead of relying on what the compiler happens to include
|
||||
// transitively.
|
||||
#pragma once
|
||||
|
||||
// The kernel's C++ sources are compiled into this component (kernel\joc_kernel.vcxproj),
|
||||
// so the shared headers must not mark their entry points as dllimport.
|
||||
#define JOC_STATIC 1
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <algorithm>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <type_traits>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
#include "log.h"
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <ctime>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
|
||||
namespace joc_log {
|
||||
namespace {
|
||||
|
||||
std::mutex g_mutex;
|
||||
FILE* g_file = nullptr;
|
||||
bool g_opened = false;
|
||||
std::string g_path; // UTF-8, for reporting
|
||||
std::wstring g_wide_path; // for _wfopen
|
||||
unsigned long long g_lines = 0;
|
||||
bool g_capped = false;
|
||||
|
||||
// A Media Library scan calls into us for every file; the cap keeps a long
|
||||
// session from filling the disk, and says so once when it is reached.
|
||||
constexpr unsigned long long kMaxLines = 500000;
|
||||
|
||||
std::wstring module_directory() {
|
||||
HMODULE module = nullptr;
|
||||
if (!GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
|
||||
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
reinterpret_cast<LPCWSTR>(&module_directory), &module)) {
|
||||
return {};
|
||||
}
|
||||
wchar_t buffer[4096] = {};
|
||||
const DWORD length = GetModuleFileNameW(module, buffer, static_cast<DWORD>(4096));
|
||||
if (length == 0) return {};
|
||||
std::wstring dir(buffer, length);
|
||||
const std::wstring::size_type slash = dir.find_last_of(L"\\/");
|
||||
if (slash != std::wstring::npos) dir.resize(slash);
|
||||
return dir;
|
||||
}
|
||||
|
||||
std::string to_utf8(const std::wstring& text) {
|
||||
if (text.empty()) return {};
|
||||
const int needed = WideCharToMultiByte(CP_UTF8, 0, text.c_str(),
|
||||
static_cast<int>(text.size()), nullptr, 0,
|
||||
nullptr, nullptr);
|
||||
std::string out(static_cast<std::string::size_type>(needed), '\0');
|
||||
WideCharToMultiByte(CP_UTF8, 0, text.c_str(), static_cast<int>(text.size()),
|
||||
out.data(), needed, nullptr, nullptr);
|
||||
return out;
|
||||
}
|
||||
|
||||
void open_locked() {
|
||||
g_opened = true;
|
||||
|
||||
wchar_t from_env[4096] = {};
|
||||
const DWORD env_length = GetEnvironmentVariableW(L"JOC_LOG", from_env,
|
||||
static_cast<DWORD>(4096));
|
||||
if (env_length > 0 && env_length < 4096) {
|
||||
g_wide_path.assign(from_env, env_length);
|
||||
} else {
|
||||
const std::wstring dir = module_directory();
|
||||
if (dir.empty()) return;
|
||||
g_wide_path = dir + L"\\joc_decoder.log";
|
||||
}
|
||||
|
||||
g_path = to_utf8(g_wide_path);
|
||||
g_file = _wfopen(g_wide_path.c_str(), L"wb");
|
||||
if (g_file == nullptr) {
|
||||
g_path.clear();
|
||||
return;
|
||||
}
|
||||
// Unbuffered: the log stays readable while the process is running.
|
||||
std::setvbuf(g_file, nullptr, _IONBF, 0);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void open() {
|
||||
std::lock_guard<std::mutex> guard(g_mutex);
|
||||
if (!g_opened) open_locked();
|
||||
}
|
||||
|
||||
const char* path() {
|
||||
std::lock_guard<std::mutex> guard(g_mutex);
|
||||
return g_path.c_str();
|
||||
}
|
||||
|
||||
void line(const char* fmt, ...) {
|
||||
va_list args;
|
||||
va_start(args, fmt);
|
||||
char text[2048];
|
||||
std::vsnprintf(text, sizeof(text), fmt, args);
|
||||
va_end(args);
|
||||
|
||||
std::lock_guard<std::mutex> guard(g_mutex);
|
||||
if (!g_opened) open_locked();
|
||||
if (g_file == nullptr) return;
|
||||
if (g_lines >= kMaxLines) {
|
||||
if (!g_capped) {
|
||||
g_capped = true;
|
||||
std::fputs("[log capped]\n", g_file);
|
||||
}
|
||||
return;
|
||||
}
|
||||
++g_lines;
|
||||
|
||||
SYSTEMTIME now;
|
||||
GetLocalTime(&now);
|
||||
std::fprintf(g_file, "%02u:%02u:%02u.%03u [t%05lu] %s\n", now.wHour, now.wMinute,
|
||||
now.wSecond, now.wMilliseconds,
|
||||
static_cast<unsigned long>(GetCurrentThreadId()), text);
|
||||
}
|
||||
|
||||
} // namespace joc_log
|
||||
@@ -0,0 +1,23 @@
|
||||
// Line-oriented diagnostic log for the component.
|
||||
//
|
||||
// Deliberately free of SDK/PFC dependencies so it can be reused by the offline
|
||||
// harness and so that it is safe to call from any thread the core queries us on.
|
||||
//
|
||||
// Path: %JOC_LOG% if set, otherwise <directory of this DLL>\joc_decoder.log.
|
||||
// The file is truncated once per process, line-buffered, and every line is
|
||||
// flushed so the log can be read while foobar2000 is still running.
|
||||
#pragma once
|
||||
|
||||
namespace joc_log {
|
||||
|
||||
// Opens (truncating) the log. Called lazily by line(); call it explicitly to
|
||||
// get the banner before anything else in the process logs.
|
||||
void open();
|
||||
|
||||
// Absolute path of the log file, or "" when it could not be opened.
|
||||
const char* path();
|
||||
|
||||
// printf-style, thread safe, appends one line.
|
||||
void line(const char* fmt, ...);
|
||||
|
||||
} // namespace joc_log
|
||||
@@ -0,0 +1,57 @@
|
||||
// Component entry point: identity, and the one line of start-up diagnostics.
|
||||
//
|
||||
// foobar2000-lite.h is the SDK's recommended entry header; the rest are pulled in
|
||||
// on a need-to-use basis.
|
||||
|
||||
#include <SDK/foobar2000-lite.h>
|
||||
|
||||
#include <SDK/componentversion.h>
|
||||
#include <SDK/coreversion.h>
|
||||
#include <SDK/initquit.h>
|
||||
|
||||
#include "log.h"
|
||||
|
||||
// Kept in one place: the string reported to foobar2000 and written to the log
|
||||
// must not drift apart.
|
||||
#define JOC_VERSION "0.1.0"
|
||||
|
||||
DECLARE_COMPONENT_VERSION("JOC decoder (E-AC-3 JOC)", JOC_VERSION,
|
||||
"Plays E-AC-3 JOC (Dolby Atmos) files: the JOC objects are "
|
||||
"rendered to binaural (SOFA or Rosella HRTF) or to a speaker "
|
||||
"layout up to 7.1.");
|
||||
|
||||
// Refuses to run when the file has been renamed; the troubleshooter relies on it.
|
||||
VALIDATE_COMPONENT_FILENAME("foo_input_joc.dll");
|
||||
|
||||
namespace {
|
||||
|
||||
// Writes one banner per session so a support log always starts with the versions
|
||||
// it was produced by.
|
||||
class joc_initquit : public initquit {
|
||||
public:
|
||||
void on_init() override {
|
||||
joc_log::open();
|
||||
joc_log::line("=== foo_input_joc %s ===", JOC_VERSION);
|
||||
joc_log::line("foobar2000 core : %s", core_version_info::g_get_version_string());
|
||||
joc_log::line("component file : %s", core_api::get_my_file_name());
|
||||
joc_log::line("profile path : %s", core_api::get_profile_path());
|
||||
joc_log::line("portable mode : %s",
|
||||
core_api::is_portable_mode_enabled() ? "yes" : "no");
|
||||
joc_log::line("log file : %s", joc_log::path());
|
||||
joc_log::line("compiled as : %s",
|
||||
#if defined(_M_IX86)
|
||||
"x86 (32-bit)"
|
||||
#elif defined(_M_X64)
|
||||
"x64"
|
||||
#else
|
||||
"other"
|
||||
#endif
|
||||
);
|
||||
}
|
||||
|
||||
void on_quit() override { joc_log::line("=== foo_input_joc shutdown ==="); }
|
||||
};
|
||||
|
||||
FB2K_SERVICE_FACTORY(joc_initquit);
|
||||
|
||||
} // namespace
|
||||
+448
@@ -0,0 +1,448 @@
|
||||
// Preferences page for the JOC decoder.
|
||||
//
|
||||
// Implemented directly on preferences_page_v3 / preferences_page_instance with a
|
||||
// plain Win32 modeless dialog. The SDK's preferences_page_impl<> helper would
|
||||
// pull in the ATL/WTL dialog framework, which the SDK does not ship; nothing in
|
||||
// this page needs it.
|
||||
|
||||
#include <SDK/foobar2000-lite.h>
|
||||
|
||||
#include <SDK/core_api.h>
|
||||
#include <SDK/preferences_page.h>
|
||||
|
||||
#include <windows.h>
|
||||
#include <commdlg.h>
|
||||
#include <uxtheme.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
#include "joc_decode.h"
|
||||
#include "prefs.h"
|
||||
#include "log.h"
|
||||
#include "resource.h"
|
||||
#include "settings.h"
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr GUID kPrefsGuid = {0x71d4a2f8, 0x9c35, 0x4e60, {0x8b, 0x12, 0x7d, 0xa4, 0x63, 0xf0, 0x2c, 0x95}};
|
||||
|
||||
std::wstring wide(const std::string& text) {
|
||||
if (text.empty()) return {};
|
||||
const int needed = MultiByteToWideChar(CP_UTF8, 0, text.c_str(),
|
||||
static_cast<int>(text.size()), nullptr, 0);
|
||||
std::wstring out(static_cast<std::size_t>(needed), L'\0');
|
||||
MultiByteToWideChar(CP_UTF8, 0, text.c_str(), static_cast<int>(text.size()), out.data(),
|
||||
needed);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string narrow(const std::wstring& text) {
|
||||
if (text.empty()) return {};
|
||||
const int needed = WideCharToMultiByte(CP_UTF8, 0, text.c_str(),
|
||||
static_cast<int>(text.size()), nullptr, 0, nullptr,
|
||||
nullptr);
|
||||
std::string out(static_cast<std::size_t>(needed), '\0');
|
||||
WideCharToMultiByte(CP_UTF8, 0, text.c_str(), static_cast<int>(text.size()), out.data(),
|
||||
needed, nullptr, nullptr);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string get_text(HWND control) {
|
||||
const int length = GetWindowTextLengthW(control);
|
||||
std::wstring buffer(static_cast<std::size_t>(length) + 1, L'\0');
|
||||
GetWindowTextW(control, buffer.data(), length + 1);
|
||||
buffer.resize(static_cast<std::size_t>(length));
|
||||
return narrow(buffer);
|
||||
}
|
||||
|
||||
void set_text(HWND control, const std::string& text) {
|
||||
SetWindowTextW(control, wide(text).c_str());
|
||||
}
|
||||
|
||||
// Layouts are offered up to 7.1 on purpose: output chains handle eight channels
|
||||
// comfortably, and wider layouts are only meaningful when writing a file.
|
||||
struct LayoutChoice {
|
||||
const char* name;
|
||||
const char* label;
|
||||
};
|
||||
const LayoutChoice kLayoutChoices[] = {
|
||||
{"2.0", "2.0 (立体声)"}, {"3.0", "3.0"}, {"3.1", "3.1"}, {"4.0", "4.0"},
|
||||
{"5.0", "5.0"}, {"5.1", "5.1"}, {"6.1", "6.1"}, {"7.0", "7.0"},
|
||||
{"7.1", "7.1"},
|
||||
};
|
||||
|
||||
const char* const kModeNames[] = {"near", "far", "mid"};
|
||||
const char* const kModeLabels[] = {"near(近场)", "far(远场)", "mid(默认)"};
|
||||
|
||||
unsigned mode_index(unsigned mode) {
|
||||
switch (mode) {
|
||||
case 1: return 0;
|
||||
case 2: return 1;
|
||||
default: return 2;
|
||||
}
|
||||
}
|
||||
|
||||
unsigned mode_value(unsigned index) {
|
||||
switch (index) {
|
||||
case 0: return 1;
|
||||
case 1: return 2;
|
||||
default: return 3;
|
||||
}
|
||||
}
|
||||
|
||||
bool pick_file(HWND owner, std::string& path, const wchar_t* title,
|
||||
const wchar_t* filter) {
|
||||
wchar_t buffer[4096] = {};
|
||||
const std::wstring current = wide(path);
|
||||
if (!current.empty() && current.size() < 4090) {
|
||||
std::wcsncpy(buffer, current.c_str(), 4090);
|
||||
}
|
||||
OPENFILENAMEW dialog{};
|
||||
dialog.lStructSize = sizeof(dialog);
|
||||
dialog.hwndOwner = owner;
|
||||
dialog.lpstrFile = buffer;
|
||||
dialog.nMaxFile = 4096;
|
||||
dialog.lpstrTitle = title;
|
||||
dialog.lpstrFilter = filter;
|
||||
dialog.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR;
|
||||
if (GetOpenFileNameW(&dialog) == FALSE) return false;
|
||||
path = narrow(buffer);
|
||||
return true;
|
||||
}
|
||||
|
||||
class prefs_instance : public preferences_page_instance {
|
||||
public:
|
||||
prefs_instance(HWND parent, preferences_page_callback::ptr callback)
|
||||
: m_callback(callback), m_initial(joc_settings::read()) {
|
||||
m_hwnd = CreateDialogParamW(core_api::get_my_instance(), MAKEINTRESOURCE(IDD_JOC_PREFS),
|
||||
parent, &prefs_instance::dialog_proc,
|
||||
reinterpret_cast<LPARAM>(this));
|
||||
if (m_hwnd == nullptr) {
|
||||
joc_log::line("prefs: CreateDialogParamW failed (error %lu)", GetLastError());
|
||||
} else {
|
||||
joc_log::line("prefs: page created, hwnd=%p", static_cast<void*>(m_hwnd));
|
||||
}
|
||||
}
|
||||
|
||||
~prefs_instance() {
|
||||
if (m_hwnd != nullptr) DestroyWindow(m_hwnd);
|
||||
}
|
||||
|
||||
t_uint32 get_state() override {
|
||||
t_uint32 state = preferences_state::resettable | preferences_state::dark_mode_supported;
|
||||
if (changed()) state |= preferences_state::changed;
|
||||
return state;
|
||||
}
|
||||
|
||||
HWND get_wnd() override { return m_hwnd; }
|
||||
|
||||
void apply() override {
|
||||
const joc_settings::Values values = read_controls();
|
||||
joc_settings::write(values);
|
||||
m_initial = values;
|
||||
joc_log::line("prefs: applied %s", joc_settings::describe(values).c_str());
|
||||
update_status();
|
||||
notify();
|
||||
}
|
||||
|
||||
void reset() override {
|
||||
load_controls(joc_settings::defaults());
|
||||
joc_log::line("prefs: reset to defaults");
|
||||
notify();
|
||||
}
|
||||
|
||||
private:
|
||||
static INT_PTR CALLBACK dialog_proc(HWND hwnd, UINT message, WPARAM wparam, LPARAM lparam) {
|
||||
prefs_instance* self = nullptr;
|
||||
if (message == WM_INITDIALOG) {
|
||||
self = reinterpret_cast<prefs_instance*>(lparam);
|
||||
self->m_hwnd = hwnd;
|
||||
SetWindowLongPtrW(hwnd, DWLP_USER, lparam);
|
||||
} else {
|
||||
self = reinterpret_cast<prefs_instance*>(GetWindowLongPtrW(hwnd, DWLP_USER));
|
||||
}
|
||||
if (self == nullptr) return FALSE;
|
||||
return self->handle(message, wparam, lparam);
|
||||
}
|
||||
|
||||
INT_PTR handle(UINT message, WPARAM wparam, LPARAM lparam) {
|
||||
switch (message) {
|
||||
case WM_INITDIALOG:
|
||||
load_controls(joc_settings::read());
|
||||
apply_dark_mode();
|
||||
update_status();
|
||||
return TRUE;
|
||||
case WM_COMMAND: {
|
||||
const int id = LOWORD(wparam);
|
||||
const int code = HIWORD(wparam);
|
||||
if (id == IDC_BROWSE_HRTF) {
|
||||
std::string path = get_text(GetDlgItem(m_hwnd, IDC_EDIT_HRTF));
|
||||
// The filter follows the selected source, because that is what
|
||||
// decides the format the file has to be in.
|
||||
const wchar_t* filter = L"SOFA (*.sofa)\0*.sofa\0所有文件 (*.*)\0*.*\0\0";
|
||||
const wchar_t* title = L"选择 SOFA HRTF 文件";
|
||||
if (IsDlgButtonChecked(m_hwnd, IDC_RADIO_HRTF_ROSELLA) == BST_CHECKED) {
|
||||
filter = L"Rosella 模型 (*.personalized_headphone)\0"
|
||||
L"*.personalized_headphone\0所有文件 (*.*)\0*.*\0\0";
|
||||
title = L"选择 Rosella 个性化模型";
|
||||
}
|
||||
if (pick_file(m_hwnd, path, title, filter)) {
|
||||
set_text(GetDlgItem(m_hwnd, IDC_EDIT_HRTF), path);
|
||||
notify();
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
if (id == IDC_RADIO_HRTF_SOFA || id == IDC_RADIO_HRTF_ROSELLA) {
|
||||
update_enabled_state();
|
||||
update_status();
|
||||
notify();
|
||||
return TRUE;
|
||||
}
|
||||
if (id == IDC_BROWSE_FFMPEG) {
|
||||
std::string path = get_text(GetDlgItem(m_hwnd, IDC_EDIT_FFMPEG));
|
||||
if (pick_file(m_hwnd, path, L"选择 ffmpeg.exe",
|
||||
L"可执行文件 (*.exe)\0*.exe\0所有文件 (*.*)\0*.*\0\0")) {
|
||||
set_text(GetDlgItem(m_hwnd, IDC_EDIT_FFMPEG), path);
|
||||
notify();
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
if (id == IDC_RADIO_BINAURAL || id == IDC_RADIO_SPEAKER) {
|
||||
update_enabled_state();
|
||||
notify();
|
||||
return TRUE;
|
||||
}
|
||||
if (id == IDC_CHECK_GAIN) {
|
||||
update_enabled_state();
|
||||
notify();
|
||||
return TRUE;
|
||||
}
|
||||
if (code == EN_CHANGE || code == CBN_SELCHANGE) {
|
||||
notify();
|
||||
return TRUE;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case WM_CTLCOLORSTATIC:
|
||||
case WM_CTLCOLORBTN: {
|
||||
if (!m_dark) break;
|
||||
HDC dc = reinterpret_cast<HDC>(wparam);
|
||||
SetTextColor(dc, RGB(0xF0, 0xF0, 0xF0));
|
||||
SetBkColor(dc, RGB(0x20, 0x20, 0x20));
|
||||
return reinterpret_cast<INT_PTR>(m_dark_brush);
|
||||
}
|
||||
case WM_CTLCOLOREDIT:
|
||||
case WM_CTLCOLORLISTBOX: {
|
||||
if (!m_dark) break;
|
||||
HDC dc = reinterpret_cast<HDC>(wparam);
|
||||
SetTextColor(dc, RGB(0xF0, 0xF0, 0xF0));
|
||||
SetBkColor(dc, RGB(0x2B, 0x2B, 0x2B));
|
||||
return reinterpret_cast<INT_PTR>(m_dark_edit_brush);
|
||||
}
|
||||
case WM_ERASEBKGND: {
|
||||
if (!m_dark) break;
|
||||
RECT area{};
|
||||
GetClientRect(m_hwnd, &area);
|
||||
FillRect(reinterpret_cast<HDC>(wparam), &area, m_dark_brush);
|
||||
return TRUE;
|
||||
}
|
||||
case WM_DESTROY:
|
||||
if (m_dark_brush != nullptr) DeleteObject(m_dark_brush);
|
||||
if (m_dark_edit_brush != nullptr) DeleteObject(m_dark_edit_brush);
|
||||
m_dark_brush = m_dark_edit_brush = nullptr;
|
||||
m_hwnd = nullptr;
|
||||
return TRUE;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
void load_controls(const joc_settings::Values& values) {
|
||||
CheckRadioButton(m_hwnd, IDC_RADIO_BINAURAL, IDC_RADIO_SPEAKER,
|
||||
values.output == 0 ? IDC_RADIO_BINAURAL : IDC_RADIO_SPEAKER);
|
||||
|
||||
HWND layout = GetDlgItem(m_hwnd, IDC_COMBO_LAYOUT);
|
||||
SendMessageW(layout, CB_RESETCONTENT, 0, 0);
|
||||
int selected = 0;
|
||||
for (unsigned i = 0; i < sizeof(kLayoutChoices) / sizeof(kLayoutChoices[0]); ++i) {
|
||||
SendMessageW(layout, CB_ADDSTRING, 0,
|
||||
reinterpret_cast<LPARAM>(wide(kLayoutChoices[i].label).c_str()));
|
||||
if (values.speaker_layout == kLayoutChoices[i].name) selected = static_cast<int>(i);
|
||||
}
|
||||
SendMessageW(layout, CB_SETCURSEL, selected, 0);
|
||||
|
||||
HWND mode = GetDlgItem(m_hwnd, IDC_COMBO_MODE);
|
||||
SendMessageW(mode, CB_RESETCONTENT, 0, 0);
|
||||
for (unsigned i = 0; i < 3; ++i) {
|
||||
SendMessageW(mode, CB_ADDSTRING, 0,
|
||||
reinterpret_cast<LPARAM>(wide(kModeLabels[i]).c_str()));
|
||||
}
|
||||
SendMessageW(mode, CB_SETCURSEL, mode_index(values.binaural_mode), 0);
|
||||
|
||||
CheckRadioButton(m_hwnd, IDC_RADIO_HRTF_SOFA, IDC_RADIO_HRTF_ROSELLA,
|
||||
values.hrtf_source == joc_settings::HrtfSource::kRosella
|
||||
? IDC_RADIO_HRTF_ROSELLA
|
||||
: IDC_RADIO_HRTF_SOFA);
|
||||
set_text(GetDlgItem(m_hwnd, IDC_EDIT_HRTF), values.hrtf_file);
|
||||
set_text(GetDlgItem(m_hwnd, IDC_EDIT_FFMPEG), values.ffmpeg_path);
|
||||
|
||||
char text[64] = {};
|
||||
std::snprintf(text, sizeof(text), "%.2f", values.tail_seconds);
|
||||
set_text(GetDlgItem(m_hwnd, IDC_EDIT_TAIL), text);
|
||||
std::snprintf(text, sizeof(text), "%.2f", values.gain_db);
|
||||
set_text(GetDlgItem(m_hwnd, IDC_EDIT_GAIN), text);
|
||||
|
||||
SendMessageW(GetDlgItem(m_hwnd, IDC_CHECK_GAIN), BM_SETCHECK,
|
||||
values.gain_enabled ? BST_CHECKED : BST_UNCHECKED, 0);
|
||||
update_enabled_state();
|
||||
}
|
||||
|
||||
joc_settings::Values read_controls() const {
|
||||
joc_settings::Values values;
|
||||
values.output = IsDlgButtonChecked(m_hwnd, IDC_RADIO_SPEAKER) == BST_CHECKED ? 1 : 0;
|
||||
|
||||
const int layout_index =
|
||||
static_cast<int>(SendDlgItemMessageW(m_hwnd, IDC_COMBO_LAYOUT, CB_GETCURSEL, 0, 0));
|
||||
if (layout_index >= 0 &&
|
||||
layout_index < static_cast<int>(sizeof(kLayoutChoices) / sizeof(kLayoutChoices[0]))) {
|
||||
values.speaker_layout = kLayoutChoices[layout_index].name;
|
||||
}
|
||||
const int mode_sel =
|
||||
static_cast<int>(SendDlgItemMessageW(m_hwnd, IDC_COMBO_MODE, CB_GETCURSEL, 0, 0));
|
||||
values.binaural_mode = mode_value(mode_sel < 0 ? 2u : static_cast<unsigned>(mode_sel));
|
||||
|
||||
values.hrtf_source = IsDlgButtonChecked(m_hwnd, IDC_RADIO_HRTF_ROSELLA) == BST_CHECKED
|
||||
? joc_settings::HrtfSource::kRosella
|
||||
: joc_settings::HrtfSource::kSofa;
|
||||
values.hrtf_file = get_text(GetDlgItem(m_hwnd, IDC_EDIT_HRTF));
|
||||
values.ffmpeg_path = get_text(GetDlgItem(m_hwnd, IDC_EDIT_FFMPEG));
|
||||
values.tail_seconds =
|
||||
std::atof(get_text(GetDlgItem(m_hwnd, IDC_EDIT_TAIL)).c_str());
|
||||
values.gain_db = std::atof(get_text(GetDlgItem(m_hwnd, IDC_EDIT_GAIN)).c_str());
|
||||
values.gain_enabled =
|
||||
IsDlgButtonChecked(m_hwnd, IDC_CHECK_GAIN) == BST_CHECKED;
|
||||
// Advanced values are not on this page; keep whatever is stored.
|
||||
const joc_settings::Values stored = joc_settings::read();
|
||||
values.object_delay_samples = stored.object_delay_samples;
|
||||
values.native_threads = stored.native_threads;
|
||||
return values;
|
||||
}
|
||||
|
||||
// Nothing on this page is ever disabled: a greyed control reads as an option
|
||||
// that cannot be used, and none of these settings is invalid in the other
|
||||
// mode. A speaker layout chosen while binaural output is active simply has no
|
||||
// effect until the output is switched back, and the gain field describes what
|
||||
// the switch would apply. What is in effect is stated in the status line.
|
||||
void update_enabled_state() { update_status(); }
|
||||
|
||||
void update_status() {
|
||||
const joc_settings::Values values = joc_settings::read();
|
||||
std::string status;
|
||||
if (values.output != 0) {
|
||||
char text[96] = {};
|
||||
const unsigned channels = joc_decode::speaker_channels(values.speaker_layout);
|
||||
std::snprintf(text, sizeof(text), "扬声器布局 %s(%u 声道)",
|
||||
values.speaker_layout.c_str(), channels);
|
||||
status = text;
|
||||
} else {
|
||||
// Show what will actually be read, so an empty box is not a mystery.
|
||||
joc_decode::Settings effective = joc_settings::current();
|
||||
effective.hrtf_source =
|
||||
static_cast<joc_decode::HrtfSource>(values.hrtf_source);
|
||||
effective.hrtf_file = values.hrtf_file;
|
||||
const std::string file = joc_decode::resolve_hrtf_file(effective);
|
||||
status = std::string(joc_settings::hrtf_source_name(values.hrtf_source)) + ":" +
|
||||
(file.empty() ? std::string("无法确定默认路径")
|
||||
: (values.hrtf_file.empty() ? "默认 " + file : file));
|
||||
}
|
||||
// Say what is actually in effect, including the parts that do not apply
|
||||
// to the selected output mode, so nothing has to be greyed out.
|
||||
char gain[96] = {};
|
||||
if (values.gain_enabled) {
|
||||
std::snprintf(gain, sizeof(gain), "\n增益开 %.2f dB", values.gain_db);
|
||||
} else {
|
||||
std::snprintf(gain, sizeof(gain), "\n增益关(输出不衰减)");
|
||||
}
|
||||
status += gain;
|
||||
set_text(GetDlgItem(m_hwnd, IDC_LABEL_STATUS), status);
|
||||
}
|
||||
|
||||
bool changed() const {
|
||||
const joc_settings::Values now = read_controls();
|
||||
if (now.output != m_initial.output) return true;
|
||||
if (now.speaker_layout != m_initial.speaker_layout) return true;
|
||||
if (now.hrtf_source != m_initial.hrtf_source) return true;
|
||||
if (now.hrtf_file != m_initial.hrtf_file) return true;
|
||||
if (now.binaural_mode != m_initial.binaural_mode) return true;
|
||||
if (now.gain_enabled != m_initial.gain_enabled) return true;
|
||||
if (now.gain_db != m_initial.gain_db) return true;
|
||||
if (now.tail_seconds != m_initial.tail_seconds) return true;
|
||||
if (now.ffmpeg_path != m_initial.ffmpeg_path) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
void notify() {
|
||||
if (m_callback.is_valid()) m_callback->on_state_changed();
|
||||
}
|
||||
|
||||
// Dark mode without the SDK's ATL helper: ask uxtheme for the app mode and
|
||||
// theme the dialog and its children. Ordinals 132/133/135 are undocumented
|
||||
// but have been stable since Windows 1809 and are what every themed dialog
|
||||
// uses; failing to resolve them is not fatal, the page just stays light.
|
||||
void apply_dark_mode() {
|
||||
HMODULE uxtheme = LoadLibraryW(L"uxtheme.dll");
|
||||
if (uxtheme == nullptr) return;
|
||||
using should_apps_use_dark_mode_t = BOOL(WINAPI*)();
|
||||
using allow_dark_mode_for_window_t = BOOL(WINAPI*)(HWND, BOOL);
|
||||
auto should_dark = reinterpret_cast<should_apps_use_dark_mode_t>(
|
||||
GetProcAddress(uxtheme, MAKEINTRESOURCEA(132)));
|
||||
auto allow_dark = reinterpret_cast<allow_dark_mode_for_window_t>(
|
||||
GetProcAddress(uxtheme, MAKEINTRESOURCEA(133)));
|
||||
if (should_dark == nullptr || should_dark() == FALSE) return;
|
||||
|
||||
if (allow_dark != nullptr) allow_dark(m_hwnd, TRUE);
|
||||
SetWindowTheme(m_hwnd, L"DarkMode_Explorer", nullptr);
|
||||
EnumChildWindows(
|
||||
m_hwnd,
|
||||
[](HWND child, LPARAM) -> BOOL {
|
||||
SetWindowTheme(child, L"DarkMode_Explorer", nullptr);
|
||||
return TRUE;
|
||||
},
|
||||
0);
|
||||
m_dark_brush = CreateSolidBrush(RGB(0x20, 0x20, 0x20));
|
||||
m_dark_edit_brush = CreateSolidBrush(RGB(0x2B, 0x2B, 0x2B));
|
||||
m_dark = true;
|
||||
joc_log::line("prefs: dark mode applied");
|
||||
}
|
||||
|
||||
HWND m_hwnd = nullptr;
|
||||
preferences_page_callback::ptr m_callback;
|
||||
joc_settings::Values m_initial;
|
||||
bool m_dark = false;
|
||||
HBRUSH m_dark_brush = nullptr;
|
||||
HBRUSH m_dark_edit_brush = nullptr;
|
||||
};
|
||||
|
||||
class prefs_page : public preferences_page_v3 {
|
||||
public:
|
||||
const char* get_name() override { return "JOC 解码器"; }
|
||||
GUID get_guid() override { return kPrefsGuid; }
|
||||
GUID get_parent_guid() override { return preferences_page::guid_tools; }
|
||||
|
||||
preferences_page_instance::ptr instantiate(HWND parent,
|
||||
preferences_page_callback::ptr callback) override {
|
||||
return fb2k::service_new<prefs_instance>(parent, callback);
|
||||
}
|
||||
};
|
||||
|
||||
static preferences_page_factory_t<prefs_page> g_prefs_page_factory;
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace joc_prefs {
|
||||
|
||||
GUID page_guid() { return kPrefsGuid; }
|
||||
|
||||
} // namespace joc_prefs
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
// Preferences page entry point.
|
||||
#pragma once
|
||||
|
||||
#include <SDK/foobar2000-lite.h>
|
||||
|
||||
namespace joc_prefs {
|
||||
|
||||
// GUID of the preferences page, so the input entry can point the decoder
|
||||
// priority table at it.
|
||||
GUID page_guid();
|
||||
|
||||
} // namespace joc_prefs
|
||||
@@ -0,0 +1,52 @@
|
||||
#pragma code_page(65001)
|
||||
#include <windows.h>
|
||||
#include "resource.h"
|
||||
|
||||
// The page is embedded by the host, which already provides the frame: the style,
|
||||
// the absence of a CAPTION statement and the font are exactly what the SDK's own
|
||||
// sample page uses (SDK\foobar2000\foo_sample\foo_sample.rc). A CAPTION statement
|
||||
// implies WS_CAPTION, which would draw a second frame inside the host's and shift
|
||||
// every control down by the caption height -- visible but not clickable.
|
||||
IDD_JOC_PREFS DIALOGEX 0, 0, 330, 226
|
||||
STYLE DS_SETFONT | WS_CHILD
|
||||
FONT 8, "Microsoft Sans Serif", 400, 0, 0x0
|
||||
BEGIN
|
||||
GROUPBOX "输出", IDC_GROUP_OUTPUT, 7, 5, 316, 64
|
||||
CONTROL "双耳(HRTF)", IDC_RADIO_BINAURAL, "Button",
|
||||
BS_AUTORADIOBUTTON | WS_GROUP | WS_TABSTOP, 16, 20, 72, 10
|
||||
CONTROL "扬声器布局", IDC_RADIO_SPEAKER, "Button",
|
||||
BS_AUTORADIOBUTTON | WS_TABSTOP, 16, 38, 60, 10
|
||||
LTEXT "布局", IDC_LABEL_LAYOUT, 80, 39, 22, 10
|
||||
COMBOBOX IDC_COMBO_LAYOUT, 104, 36, 60, 120,
|
||||
CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
|
||||
LTEXT "双耳模式", IDC_LABEL_MODE, 172, 22, 42, 10
|
||||
COMBOBOX IDC_COMBO_MODE, 216, 19, 96, 90,
|
||||
CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP
|
||||
LTEXT "房间尾音(秒)", IDC_LABEL_TAIL, 172, 41, 46, 10
|
||||
EDITTEXT IDC_EDIT_TAIL, 220, 38, 40, 13, ES_AUTOHSCROLL
|
||||
|
||||
GROUPBOX "HRTF", IDC_GROUP_HRTF, 7, 74, 316, 84
|
||||
CONTROL "SOFA 文件", IDC_RADIO_HRTF_SOFA, "Button",
|
||||
BS_AUTORADIOBUTTON | WS_GROUP | WS_TABSTOP, 16, 89, 56, 10
|
||||
CONTROL "Rosella 个性化模型", IDC_RADIO_HRTF_ROSELLA, "Button",
|
||||
BS_AUTORADIOBUTTON | WS_TABSTOP, 78, 89, 78, 10
|
||||
LTEXT "HRTF 文件(留空=默认位置)", IDC_LABEL_HRTF, 16, 106, 92, 10
|
||||
EDITTEXT IDC_EDIT_HRTF, 110, 103, 164, 13, ES_AUTOHSCROLL
|
||||
PUSHBUTTON "浏览…", IDC_BROWSE_HRTF, 279, 103, 38, 13
|
||||
LTEXT "留空时读组件目录下 HRTF\\binaural.sofa 或 binaural.personalized_headphone;SOFA 由渲染器内部编译,滤波组表已编入库中。",
|
||||
IDC_LABEL_HRTF_HINT, 16, 122, 300, 30
|
||||
|
||||
GROUPBOX "增益", IDC_GROUP_GAIN, 7, 162, 152, 56
|
||||
CONTROL "应用增益", IDC_CHECK_GAIN, "Button",
|
||||
BS_AUTOCHECKBOX | WS_TABSTOP, 16, 178, 58, 10
|
||||
LTEXT "dB", IDC_LABEL_GAIN, 80, 178, 12, 10
|
||||
EDITTEXT IDC_EDIT_GAIN, 96, 175, 40, 13, ES_AUTOHSCROLL
|
||||
LTEXT "双耳渲染后可能超过 0 dBFS,可用负值衰减。",
|
||||
IDC_LABEL_GAIN_HINT, 16, 194, 134, 20
|
||||
|
||||
GROUPBOX "其它", IDC_GROUP_MISC, 165, 162, 158, 56
|
||||
LTEXT "ffmpeg.exe", IDC_LABEL_FFMPEG, 173, 178, 44, 10
|
||||
EDITTEXT IDC_EDIT_FFMPEG, 216, 175, 62, 13, ES_AUTOHSCROLL
|
||||
PUSHBUTTON "浏览…", IDC_BROWSE_FFMPEG, 282, 175, 35, 13
|
||||
LTEXT "", IDC_LABEL_STATUS, 173, 194, 144, 20
|
||||
END
|
||||
@@ -0,0 +1,34 @@
|
||||
// Control identifiers for the JOC decoder preferences page.
|
||||
#pragma once
|
||||
|
||||
#define IDD_JOC_PREFS 2001
|
||||
|
||||
#define IDC_GROUP_OUTPUT 2010
|
||||
#define IDC_RADIO_BINAURAL 2011
|
||||
#define IDC_RADIO_SPEAKER 2012
|
||||
#define IDC_COMBO_LAYOUT 2013
|
||||
#define IDC_LABEL_LAYOUT 2014
|
||||
#define IDC_LABEL_MODE 2015
|
||||
#define IDC_COMBO_MODE 2016
|
||||
#define IDC_LABEL_TAIL 2017
|
||||
#define IDC_EDIT_TAIL 2018
|
||||
|
||||
#define IDC_GROUP_HRTF 2020
|
||||
#define IDC_RADIO_HRTF_SOFA 2021
|
||||
#define IDC_RADIO_HRTF_ROSELLA 2022
|
||||
#define IDC_LABEL_HRTF 2024
|
||||
#define IDC_EDIT_HRTF 2025
|
||||
#define IDC_BROWSE_HRTF 2026
|
||||
#define IDC_LABEL_HRTF_HINT 2027
|
||||
|
||||
#define IDC_GROUP_GAIN 2030
|
||||
#define IDC_CHECK_GAIN 2031
|
||||
#define IDC_LABEL_GAIN 2032
|
||||
#define IDC_EDIT_GAIN 2033
|
||||
#define IDC_LABEL_GAIN_HINT 2034
|
||||
|
||||
#define IDC_GROUP_MISC 2040
|
||||
#define IDC_LABEL_FFMPEG 2041
|
||||
#define IDC_EDIT_FFMPEG 2042
|
||||
#define IDC_BROWSE_FFMPEG 2043
|
||||
#define IDC_LABEL_STATUS 2044
|
||||
@@ -0,0 +1,206 @@
|
||||
// The umbrella must come first: cfg_var.h pulls in cfg_var_legacy.h, whose whole
|
||||
// body is behind FOOBAR2000_HAVE_CFG_VAR_LEGACY, a macro foobar2000-winver.h sets.
|
||||
#include <SDK/foobar2000-lite.h>
|
||||
|
||||
#include <SDK/cfg_var.h>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
#include "log.h"
|
||||
#include "settings.h"
|
||||
|
||||
namespace {
|
||||
|
||||
// One GUID per stored value. These are the on-disk identity of a setting and
|
||||
// must never be reused for a different meaning.
|
||||
constexpr GUID kGuidOutput = {0x2f1c4a90, 0x6d3e, 0x4b51, {0x9a, 0x77, 0x0c, 0x31, 0x8d, 0x54, 0x2e, 0x10}};
|
||||
constexpr GUID kGuidLayout = {0x7b62e5d1, 0x14a8, 0x4c07, {0x83, 0x2f, 0x6b, 0x90, 0x5a, 0x1d, 0xc4, 0x72}};
|
||||
// Reused from the previous build, where it stored the .jochrtf path: it still
|
||||
// holds "the HRTF file", so the meaning is unchanged for anyone upgrading.
|
||||
constexpr GUID kGuidHrtfFile = {0x4e8a1f36, 0x2c95, 0x47d0, {0xb1, 0x68, 0x3e, 0xa2, 0x77, 0x09, 0xf5, 0x8c}};
|
||||
constexpr GUID kGuidHrtfSource = {0xa3d51b72, 0x6f48, 0x4c19, {0x9e, 0x04, 0x2b, 0x87, 0xd1, 0x53, 0x6a, 0x20}};
|
||||
constexpr GUID kGuidBinauralMode = {0x1a57c9e4, 0x83b6, 0x42f1, {0x9c, 0x50, 0x24, 0xfd, 0x6a, 0x18, 0x73, 0x0e}};
|
||||
constexpr GUID kGuidGainEnabled = {0x74b1e5c8, 0x2a39, 0x4d16, {0x8f, 0x62, 0x0d, 0x51, 0xb7, 0x3c, 0x9a, 0x28}};
|
||||
constexpr GUID kGuidGain = {0x6c04d8b2, 0xe75f, 0x4a39, {0x8d, 0x26, 0x51, 0xb3, 0xc8, 0x40, 0x9e, 0x27}};
|
||||
constexpr GUID kGuidTail = {0x38f1b6a7, 0x0d24, 0x4e88, {0xb7, 0x95, 0x2c, 0x61, 0x4f, 0xd2, 0x08, 0xa3}};
|
||||
constexpr GUID kGuidObjectDelay = {0x5b2e9c18, 0xa640, 0x4d7b, {0x92, 0x0e, 0x37, 0xc5, 0x81, 0x6b, 0x2a, 0x54}};
|
||||
constexpr GUID kGuidThreads = {0xcb7a4015, 0x3f98, 0x4c62, {0xa0, 0x8d, 0x1b, 0x74, 0x29, 0xe6, 0x53, 0x0f}};
|
||||
constexpr GUID kGuidFfmpeg = {0x0e9d37c2, 0x5a18, 0x4b40, {0xb6, 0x3f, 0x88, 0x2d, 0x9a, 0x70, 0x1c, 0x65}};
|
||||
|
||||
cfg_uint g_output(kGuidOutput, 0);
|
||||
cfg_string g_layout(kGuidLayout, "7.1");
|
||||
// 0 = SOFA, 1 = Rosella model, 2 = compiled .jochrtf (advanced)
|
||||
cfg_uint g_hrtf_source(kGuidHrtfSource, 0);
|
||||
cfg_string g_hrtf_file(kGuidHrtfFile, "");
|
||||
cfg_uint g_binaural_mode(kGuidBinauralMode, 3);
|
||||
cfg_bool g_gain_enabled(kGuidGainEnabled, true);
|
||||
// milli-decibels, so the value stays an integer
|
||||
cfg_int g_gain_mdb(kGuidGain, 0);
|
||||
// milli-seconds
|
||||
cfg_uint g_tail_ms(kGuidTail, 5000);
|
||||
cfg_uint g_object_delay(kGuidObjectDelay, 1473);
|
||||
cfg_uint g_threads(kGuidThreads, 0);
|
||||
cfg_string g_ffmpeg(kGuidFfmpeg, "ffmpeg");
|
||||
|
||||
std::string environment(const char* name) {
|
||||
char buffer[4096] = {};
|
||||
const DWORD length = GetEnvironmentVariableA(name, buffer, sizeof(buffer));
|
||||
if (length == 0 || length >= sizeof(buffer)) return {};
|
||||
return std::string(buffer, length);
|
||||
}
|
||||
|
||||
bool env_double(const char* name, double* out) {
|
||||
const std::string text = environment(name);
|
||||
if (text.empty()) return false;
|
||||
*out = std::atof(text.c_str());
|
||||
joc_log::line("settings: %s overrides the stored value (%.3f)", name, *out);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool env_uint(const char* name, unsigned* out) {
|
||||
const std::string text = environment(name);
|
||||
if (text.empty()) return false;
|
||||
*out = static_cast<unsigned>(std::strtoul(text.c_str(), nullptr, 10));
|
||||
joc_log::line("settings: %s overrides the stored value (%u)", name, *out);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool env_string(const char* name, std::string* out) {
|
||||
const std::string text = environment(name);
|
||||
if (text.empty()) return false;
|
||||
*out = text;
|
||||
joc_log::line("settings: %s overrides the stored value (%s)", name, text.c_str());
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace joc_settings {
|
||||
|
||||
const char* hrtf_source_name(HrtfSource source) {
|
||||
return source == HrtfSource::kRosella ? "Rosella 个性化模型" : "SOFA";
|
||||
}
|
||||
|
||||
const char* hrtf_source_extension(HrtfSource source) {
|
||||
return source == HrtfSource::kRosella ? "personalized_headphone" : "sofa";
|
||||
}
|
||||
|
||||
const char* hrtf_default_file_name(HrtfSource source) {
|
||||
return source == HrtfSource::kRosella ? "binaural.personalized_headphone"
|
||||
: "binaural.sofa";
|
||||
}
|
||||
|
||||
Values defaults() { return Values(); }
|
||||
|
||||
Values read() {
|
||||
Values values;
|
||||
values.output = static_cast<int>(static_cast<unsigned>(g_output));
|
||||
values.speaker_layout = g_layout.get_ptr();
|
||||
if (values.speaker_layout.empty()) values.speaker_layout = "7.1";
|
||||
const unsigned source = static_cast<unsigned>(g_hrtf_source);
|
||||
values.hrtf_source = (source == 1u) ? HrtfSource::kRosella : HrtfSource::kSofa;
|
||||
values.hrtf_file = g_hrtf_file.get_ptr();
|
||||
values.binaural_mode = static_cast<unsigned>(g_binaural_mode);
|
||||
values.gain_enabled = static_cast<bool>(g_gain_enabled);
|
||||
values.gain_db = static_cast<double>(static_cast<long long>(g_gain_mdb)) / 1000.0;
|
||||
values.tail_seconds = static_cast<double>(static_cast<unsigned>(g_tail_ms)) / 1000.0;
|
||||
values.object_delay_samples = static_cast<unsigned>(g_object_delay);
|
||||
values.native_threads = static_cast<unsigned>(g_threads);
|
||||
values.ffmpeg_path = g_ffmpeg.get_ptr();
|
||||
if (values.ffmpeg_path.empty()) values.ffmpeg_path = "ffmpeg";
|
||||
return values;
|
||||
}
|
||||
|
||||
void write(const Values& values) {
|
||||
g_output = static_cast<unsigned>(values.output);
|
||||
g_layout = values.speaker_layout.c_str();
|
||||
g_hrtf_source = static_cast<unsigned>(values.hrtf_source);
|
||||
g_hrtf_file = values.hrtf_file.c_str();
|
||||
g_binaural_mode = values.binaural_mode;
|
||||
g_gain_enabled = values.gain_enabled;
|
||||
g_gain_mdb = static_cast<long long>(values.gain_db * 1000.0 + (values.gain_db >= 0 ? 0.5 : -0.5));
|
||||
g_tail_ms = static_cast<unsigned>(values.tail_seconds * 1000.0 + 0.5);
|
||||
g_object_delay = values.object_delay_samples;
|
||||
g_threads = values.native_threads;
|
||||
g_ffmpeg = values.ffmpeg_path.c_str();
|
||||
}
|
||||
|
||||
joc_decode::Settings current() {
|
||||
Values values = read();
|
||||
|
||||
std::string text;
|
||||
if (env_string("JOC_OUTPUT", &text)) values.output = (text == "speaker") ? 1 : 0;
|
||||
(void)env_string("JOC_LAYOUT", &values.speaker_layout);
|
||||
(void)env_string("JOC_HRTF", &values.hrtf_file);
|
||||
if (env_string("JOC_HRTF_SOURCE", &text)) {
|
||||
values.hrtf_source = (text == "rosella") ? HrtfSource::kRosella : HrtfSource::kSofa;
|
||||
}
|
||||
(void)env_string("JOC_FFMPEG", &values.ffmpeg_path);
|
||||
if (env_string("JOC_BINAURAL_MODE", &text)) {
|
||||
values.binaural_mode = (text == "near") ? 1u : (text == "far") ? 2u : 3u;
|
||||
}
|
||||
(void)env_double("JOC_GAIN_DB", &values.gain_db);
|
||||
if (env_string("JOC_GAIN_ENABLED", &text)) values.gain_enabled = (text != "0");
|
||||
(void)env_double("JOC_TAIL_SECONDS", &values.tail_seconds);
|
||||
(void)env_uint("JOC_OBJECT_DELAY", &values.object_delay_samples);
|
||||
(void)env_uint("JOC_THREADS", &values.native_threads);
|
||||
|
||||
joc_decode::Settings settings;
|
||||
settings.output =
|
||||
(values.output == 0) ? joc_decode::Output::kBinaural : joc_decode::Output::kSpeaker;
|
||||
settings.speaker_layout = values.speaker_layout;
|
||||
settings.hrtf_source = static_cast<joc_decode::HrtfSource>(values.hrtf_source);
|
||||
settings.hrtf_file = values.hrtf_file;
|
||||
settings.binaural_mode = values.binaural_mode;
|
||||
// The switch is explicit rather than implied by the value: binaural rendering
|
||||
// can exceed full scale on material that does not clip in the core mix, so
|
||||
// attenuation has to be visible and switchable.
|
||||
settings.gain_db = values.gain_enabled ? values.gain_db : 0.0;
|
||||
settings.tail_seconds = values.tail_seconds;
|
||||
settings.object_delay_samples = values.object_delay_samples;
|
||||
settings.native_threads = values.native_threads;
|
||||
settings.ffmpeg_path = values.ffmpeg_path.empty() ? "ffmpeg" : values.ffmpeg_path;
|
||||
return settings;
|
||||
}
|
||||
|
||||
std::string describe(const joc_decode::Settings& settings) {
|
||||
char text[640] = {};
|
||||
if (settings.output == joc_decode::Output::kBinaural) {
|
||||
std::snprintf(text, sizeof(text),
|
||||
"binaural, hrtf=%s(%s), mode=%u, gain=%.2f dB, tail=%.2f s, delay=%u, "
|
||||
"threads=%u, ffmpeg=%s",
|
||||
hrtf_source_name(static_cast<HrtfSource>(settings.hrtf_source)),
|
||||
settings.hrtf_file.empty() ? "(默认位置)" : settings.hrtf_file.c_str(),
|
||||
settings.binaural_mode, settings.gain_db, settings.tail_seconds,
|
||||
settings.object_delay_samples, settings.native_threads,
|
||||
settings.ffmpeg_path.c_str());
|
||||
} else {
|
||||
// The HRTF fields mean nothing here, and printing them only invites the
|
||||
// reader to wonder why one is missing.
|
||||
std::snprintf(text, sizeof(text),
|
||||
"speaker %s, gain=%.2f dB, delay=%u, threads=%u, ffmpeg=%s",
|
||||
settings.speaker_layout.c_str(), settings.gain_db,
|
||||
settings.object_delay_samples, settings.native_threads,
|
||||
settings.ffmpeg_path.c_str());
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
std::string describe(const Values& values) {
|
||||
char text[640] = {};
|
||||
std::snprintf(text, sizeof(text),
|
||||
"output=%d layout=%s hrtf=%s(%s) mode=%u gain=%s%.2f dB tail=%.2f s delay=%u "
|
||||
"threads=%u ffmpeg=\"%s\"",
|
||||
values.output, values.speaker_layout.c_str(),
|
||||
hrtf_source_name(values.hrtf_source), values.hrtf_file.c_str(),
|
||||
values.binaural_mode, values.gain_enabled ? "" : "(off) ", values.gain_db,
|
||||
values.tail_seconds, values.object_delay_samples, values.native_threads,
|
||||
values.ffmpeg_path.c_str());
|
||||
return text;
|
||||
}
|
||||
|
||||
} // namespace joc_settings
|
||||
@@ -0,0 +1,58 @@
|
||||
// Component settings: what the decoder renders and which HRTF it renders with.
|
||||
//
|
||||
// The HRTF source mirrors joc_task_config v2, which is the interface the
|
||||
// reference CLI uses: a SOFA file, or a Rosella personalized model, are the
|
||||
// user-facing inputs; a compiled .jochrtf is the advanced override; the filter
|
||||
// bank tables are compiled into the library and are not a setting at all.
|
||||
//
|
||||
// Every value also has an environment override (JOC_*), which exists so the
|
||||
// decoder can be exercised headlessly; an override is logged when used, so a test
|
||||
// run cannot silently disagree with what the page shows.
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
#include "joc_decode.h"
|
||||
|
||||
namespace joc_settings {
|
||||
|
||||
// Which HRTF the binaural renderer uses. Only these two inputs exist: everything
|
||||
// else (compiling a SOFA into the renderer's field, caching it) is internal.
|
||||
enum class HrtfSource {
|
||||
kSofa = 0, // SOFA SimpleFreeFieldHRIR, compiled by the renderer
|
||||
kRosella = 1, // Rosella .personalized_headphone model
|
||||
};
|
||||
|
||||
const char* hrtf_source_name(HrtfSource source);
|
||||
const char* hrtf_source_extension(HrtfSource source);
|
||||
// The file looked up in the default folder (next to the component) when no path
|
||||
// is configured; same names the reference CLI uses.
|
||||
const char* hrtf_default_file_name(HrtfSource source);
|
||||
|
||||
// The stored values, exactly as the preferences page shows them.
|
||||
struct Values {
|
||||
int output = 0; // 0 = binaural, 1 = speaker layout
|
||||
std::string speaker_layout = "7.1";
|
||||
HrtfSource hrtf_source = HrtfSource::kSofa;
|
||||
// Empty means "the default file in the default folder".
|
||||
std::string hrtf_file;
|
||||
unsigned binaural_mode = 3; // JOC_BINAURAL_NEAR/FAR/MID = 1/2/3
|
||||
bool gain_enabled = true;
|
||||
double gain_db = 0.0;
|
||||
double tail_seconds = 5.0;
|
||||
unsigned object_delay_samples = 1473;
|
||||
unsigned native_threads = 0;
|
||||
std::string ffmpeg_path = "ffmpeg";
|
||||
};
|
||||
|
||||
Values defaults();
|
||||
Values read();
|
||||
void write(const Values& values);
|
||||
|
||||
// Stored values plus environment overrides, as the decoder should use them.
|
||||
joc_decode::Settings current();
|
||||
|
||||
std::string describe(const joc_decode::Settings& settings);
|
||||
std::string describe(const Values& values);
|
||||
|
||||
} // namespace joc_settings
|
||||
Reference in New Issue
Block a user