Fix JOC detection inside mp4/mkv containers
build / windows (push) Has been cancelled
build / release (push) Has been cancelled

This commit is contained in:
2026-09-25 20:10:08 +08:00
parent ad62a914e6
commit 188cb9ebfb
12 changed files with 980 additions and 58 deletions
+528
View File
@@ -0,0 +1,528 @@
#include "container_scan.h"
#include <windows.h>
#include <cstdarg>
#include <cstdio>
#include <cstring>
#include <vector>
#include "log.h"
namespace joc_container {
namespace {
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;
}
// A bounded, read-only view of the file. Every accessor returns false instead of
// throwing, so a truncated or hostile file simply fails the probe.
class Window {
public:
bool open(const std::string& path) {
const std::wstring wide = utf8_to_wide(path);
handle_ = CreateFileW(wide.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE,
nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
if (handle_ == INVALID_HANDLE_VALUE) {
handle_ = nullptr;
return false;
}
LARGE_INTEGER size{};
if (GetFileSizeEx(handle_, &size) == FALSE) return false;
size_ = static_cast<std::uint64_t>(size.QuadPart);
return true;
}
~Window() {
if (handle_ != nullptr) CloseHandle(handle_);
}
std::uint64_t size() const { return size_; }
// Reads at an absolute offset; false when the range is not fully available.
bool read(std::uint64_t offset, void* destination, std::size_t bytes) const {
if (offset + bytes > size_) return false;
LARGE_INTEGER position{};
position.QuadPart = static_cast<LONGLONG>(offset);
if (SetFilePointerEx(handle_, position, nullptr, FILE_BEGIN) == FALSE) return false;
std::size_t done = 0;
auto* target = static_cast<std::uint8_t*>(destination);
while (done < bytes) {
const DWORD chunk = static_cast<DWORD>(
(bytes - done) > 0x10000u ? 0x10000u : (bytes - done));
DWORD got = 0;
if (ReadFile(handle_, target + done, chunk, &got, nullptr) == FALSE || got == 0) {
return false;
}
done += got;
}
return true;
}
bool load(std::uint64_t offset, std::size_t bytes, std::vector<std::uint8_t>* out) const {
out->assign(bytes, 0);
return bytes == 0 || read(offset, out->data(), bytes);
}
private:
HANDLE handle_ = nullptr;
std::uint64_t size_ = 0;
};
// Set JOC_SCAN_TRACE=1 to have the walk printed: the practical way to see where a
// container's structure stops matching what the probe expects.
bool trace_enabled() {
static const bool enabled = [] {
return GetEnvironmentVariableA("JOC_SCAN_TRACE", nullptr, 0) != 0;
}();
return enabled;
}
void trace(const char* format, ...) {
if (!trace_enabled()) return;
va_list arguments;
va_start(arguments, format);
std::vfprintf(stdout, format, arguments);
va_end(arguments);
std::fflush(stdout);
}
std::uint32_t be32(const std::uint8_t* p) {
return (static_cast<std::uint32_t>(p[0]) << 24) | (static_cast<std::uint32_t>(p[1]) << 16) |
(static_cast<std::uint32_t>(p[2]) << 8) | static_cast<std::uint32_t>(p[3]);
}
std::string fourcc(const std::uint8_t* p) { return std::string(reinterpret_cast<const char*>(p), 4); }
bool is_audio_sample_entry(const std::string& format) {
// E-AC-3 is "ec-3"; "ac-3" is plain Dolby Digital and is *not* ours.
return format == "ec-3" || format == "ec3 " || format == "EAC3";
}
bool is_ac3_sample_entry(const std::string& format) { return format == "ac-3" || format == "ac3 "; }
// --- MP4 / ISO base media -------------------------------------------------
// Walks the boxes inside [begin, end) and hands each to the visitor.
template <typename Fn>
bool walk_boxes(const Window& file, std::uint64_t begin, std::uint64_t end,
std::size_t depth, Fn visitor) {
std::uint64_t offset = begin;
std::uint8_t header[8];
while (offset + 8 <= end) {
if (!file.read(offset, header, sizeof(header))) return false;
std::uint64_t size = be32(header);
const std::string type = fourcc(header + 4);
std::uint64_t payload = offset + 8;
if (size == 1) {
std::uint8_t extended[8];
if (!file.read(offset + 8, extended, sizeof(extended))) return false;
size = (static_cast<std::uint64_t>(be32(extended)) << 32) | be32(extended + 4);
payload = offset + 16;
} else if (size == 0) {
size = end - offset; // "to the end of the file"
}
trace("%*sbox %s size=%llu\n", static_cast<int>(depth) * 2, "", type.c_str(), static_cast<unsigned long long>(size));
if (size < 8 || offset + size > end) return false;
if (!visitor(type, payload, offset + size, depth)) return false;
offset += size;
}
return true;
}
// One trak: is it an audio track, and which sample entry does it use?
struct TrackInfo {
bool audio = false;
std::string format;
};
bool scan_stbl(const Window& file, std::uint64_t begin, std::uint64_t end, TrackInfo* info) {
return walk_boxes(file, begin, end, 0, [&](const std::string& type, std::uint64_t payload,
std::uint64_t box_end, std::size_t) {
(void)box_end;
if (type == "stsd") {
std::uint8_t head[16];
if (file.read(payload, head, sizeof(head))) {
// version+flags(4) entry_count(4), then the first sample entry:
// size(4) format(4).
info->format = fourcc(head + 12);
trace("%*sstsd format=%s\n", 8, "", info->format.c_str());
}
return false; // one sample entry is all the probe needs
}
return true;
});
}
// minf -> stbl -> stsd
bool scan_minf(const Window& file, std::uint64_t begin, std::uint64_t end, TrackInfo* info) {
return walk_boxes(file, begin, end, 0, [&](const std::string& type, std::uint64_t payload,
std::uint64_t box_end, std::size_t) {
if (type == "stbl") scan_stbl(file, payload, box_end, info);
return true;
});
}
// mdia: the track handler says whether this is audio, minf leads to the sample entry.
bool scan_mdia(const Window& file, std::uint64_t begin, std::uint64_t end, TrackInfo* info) {
return walk_boxes(file, begin, end, 0, [&](const std::string& type, std::uint64_t payload,
std::uint64_t box_end, std::size_t) {
if (type == "hdlr") {
std::uint8_t head[12];
if (file.read(payload, head, sizeof(head))) {
info->audio = fourcc(head + 8) == "soun";
trace("%*shdlr handler=%s audio=%d\n", 6, "", fourcc(head + 8).c_str(),
info->audio ? 1 : 0);
}
} else if (type == "minf") {
scan_minf(file, payload, box_end, info);
}
return true;
});
}
// trak -> mdia
bool scan_trak(const Window& file, std::uint64_t begin, std::uint64_t end, TrackInfo* info) {
return walk_boxes(file, begin, end, 0, [&](const std::string& type, std::uint64_t payload,
std::uint64_t box_end, std::size_t) {
if (type == "mdia") scan_mdia(file, payload, box_end, info);
return true;
});
}
Result scan_mp4(const Window& file, std::size_t max_bytes) {
Result result;
result.kind = Kind::kMp4;
// moov is usually at the start for streamed files and at the end otherwise.
struct Range {
std::uint64_t begin;
std::uint64_t end;
};
std::vector<Range> ranges{{0, (std::min<std::uint64_t>)(file.size(), max_bytes)}};
if (file.size() > max_bytes) {
ranges.push_back({file.size() - max_bytes, file.size()});
}
unsigned audio_seen = 0;
bool found_any_audio = false;
std::string first_audio_format;
for (const Range& range : ranges) {
walk_boxes(file, range.begin, range.end, 0,
[&](const std::string& type, std::uint64_t payload, std::uint64_t box_end,
std::size_t) {
if (type != "moov") return true;
walk_boxes(file, payload, box_end, 1,
[&](const std::string& inner, std::uint64_t inner_payload,
std::uint64_t inner_end, std::size_t) {
if (inner == "mvhd") {
// version(1) flags(3) then either
// creation/modification/timescale/duration (32-bit)
// or the 64-bit variant.
std::uint8_t header[32];
if (file.read(inner_payload, header, sizeof(header))) {
const bool wide = header[0] == 1;
const std::uint32_t timescale =
wide ? be32(header + 20) : be32(header + 12);
const std::uint64_t duration =
wide ? ((static_cast<std::uint64_t>(
be32(header + 24))
<< 32) |
be32(header + 28))
: be32(header + 16);
if (timescale != 0) {
result.duration_seconds =
static_cast<double>(duration) /
static_cast<double>(timescale);
}
}
return true;
}
if (inner != "trak") return true;
TrackInfo info;
scan_trak(file, inner_payload, inner_end, &info);
if (!info.audio) return true;
if (!found_any_audio) {
found_any_audio = true;
first_audio_format = info.format;
}
if (is_audio_sample_entry(info.format)) {
result.eac3 = true;
result.audio_index = audio_seen;
result.codec = info.format;
} else if (is_ac3_sample_entry(info.format)) {
result.codec = info.format;
}
++audio_seen;
return !result.eac3; // stop once found
});
return !result.eac3;
});
if (result.eac3) break;
}
if (result.eac3) {
result.detail = "mp4: E-AC-3 audio track " + std::to_string(result.audio_index);
} else if (found_any_audio) {
result.detail = "mp4: first audio track is " +
(first_audio_format.empty() ? std::string("unknown")
: first_audio_format);
} else {
result.detail = "mp4: no audio track found in the scanned window";
}
return result;
}
// --- Matroska / WebM ------------------------------------------------------
struct EbmlReader {
const std::uint8_t* data = nullptr;
std::size_t size = 0;
std::size_t position = 0;
bool at_end() const { return position >= size; }
// Element IDs keep their leading bits; sizes drop the marker bit.
bool read_id(std::uint64_t* id) {
if (at_end()) return false;
const std::uint8_t first = data[position];
int length = 1;
for (int bit = 0x80; bit != 0; bit >>= 1, ++length) {
if ((first & bit) != 0) break;
}
if (length > 4 || position + static_cast<std::size_t>(length) > size) return false;
std::uint64_t value = 0;
for (int i = 0; i < length; ++i) value = (value << 8) | data[position + i];
position += static_cast<std::size_t>(length);
*id = value;
return true;
}
bool read_size(std::uint64_t* value, bool* unknown) {
*unknown = false;
if (at_end()) return false;
const std::uint8_t first = data[position];
int length = 1;
for (int bit = 0x80; bit != 0; bit >>= 1, ++length) {
if ((first & bit) != 0) break;
}
if (length > 8 || position + static_cast<std::size_t>(length) > size) return false;
std::uint64_t result = first & (0xFFu >> length);
bool all_ones = (first & (0xFFu >> length)) == (0xFFu >> length);
for (int i = 1; i < length; ++i) {
const std::uint8_t byte = data[position + i];
all_ones = all_ones && byte == 0xFFu;
result = (result << 8) | byte;
}
position += static_cast<std::size_t>(length);
*unknown = all_ones;
*value = result;
return true;
}
};
Result scan_matroska(const Window& file, std::size_t max_bytes) {
Result result;
result.kind = Kind::kMatroska;
std::vector<std::uint8_t> data;
if (!file.load(0, static_cast<std::size_t>(
(std::min<std::uint64_t>)(file.size(), max_bytes)),
&data)) {
result.detail = "matroska: cannot read the header";
return result;
}
unsigned audio_seen = 0;
std::string first_audio_codec;
bool saw_tracks = false;
bool found = false;
double timestamp_scale_ns = 1000000.0; // Matroska default: 1 ms per tick
double duration_ticks = 0.0;
// Only the two levels that matter are walked: Tracks, then TrackEntry.
auto walk = [&](std::size_t begin, std::size_t end, int depth,
auto&& self) -> void {
EbmlReader reader{data.data(), end, begin};
while (!reader.at_end() && !found) {
const std::size_t element_start = reader.position;
std::uint64_t id = 0;
std::uint64_t size = 0;
bool unknown = false;
if (!reader.read_id(&id) || !reader.read_size(&size, &unknown)) break;
std::size_t payload = reader.position;
std::size_t payload_end =
unknown ? end : (std::min<std::size_t>)(end, payload + static_cast<std::size_t>(size));
if (payload_end < payload) break;
switch (id) {
case 0x1654AE6Bu: // Tracks
saw_tracks = true;
self(payload, payload_end, depth + 1, self);
break;
case 0x1549A966u: { // Info: carries the segment duration
EbmlReader inner{data.data(), payload_end, payload};
while (!inner.at_end()) {
std::uint64_t child_id = 0;
std::uint64_t child_size = 0;
bool child_unknown = false;
if (!inner.read_id(&child_id) ||
!inner.read_size(&child_size, &child_unknown)) {
break;
}
const std::size_t child_payload = inner.position;
const std::size_t child_end =
child_unknown ? payload_end
: (std::min<std::size_t>)(
payload_end,
child_payload + static_cast<std::size_t>(child_size));
// TimestampScale (ns per tick, default 1 ms) and Duration (ticks).
if (child_id == 0x2AD7B1u && child_size >= 1 && child_size <= 8) {
std::uint64_t scale = 0;
for (std::size_t i = 0; i < child_size; ++i) {
scale = (scale << 8) | data[child_payload + i];
}
if (scale != 0) timestamp_scale_ns = scale;
} else if (child_id == 0x4489u && (child_size == 4 || child_size == 8)) {
// Duration is a big-endian float: 4 or 8 bytes, both occur.
const std::uint8_t* field = data.data() + child_payload;
if (child_size == 4) {
const std::uint32_t bits = be32(field);
float value = 0.0f;
std::memcpy(&value, &bits, sizeof(value));
duration_ticks = static_cast<double>(value);
} else {
const std::uint64_t bits =
(static_cast<std::uint64_t>(be32(field)) << 32) | be32(field + 4);
double value = 0.0;
std::memcpy(&value, &bits, sizeof(value));
duration_ticks = value;
}
}
inner.position = child_end;
}
break;
}
case 0xAEu: { // TrackEntry
// Read this track's type and codec.
std::uint64_t track_type = 0;
std::string codec;
EbmlReader inner{data.data(), payload_end, payload};
while (!inner.at_end()) {
std::uint64_t child_id = 0;
std::uint64_t child_size = 0;
bool child_unknown = false;
if (!inner.read_id(&child_id) ||
!inner.read_size(&child_size, &child_unknown)) {
break;
}
const std::size_t child_payload = inner.position;
const std::size_t child_end =
child_unknown ? payload_end
: (std::min<std::size_t>)(
payload_end,
child_payload + static_cast<std::size_t>(child_size));
if (child_id == 0x83u && child_size >= 1) { // TrackType
track_type = data[child_payload];
} else if (child_id == 0x86u) { // CodecID
codec.assign(reinterpret_cast<const char*>(data.data() + child_payload),
child_end - child_payload);
}
inner.position = child_end;
}
if (track_type == 2u) { // audio
if (first_audio_codec.empty()) first_audio_codec = codec;
if (codec == "A_EAC3") {
result.eac3 = true;
result.audio_index = audio_seen;
result.codec = codec;
found = true;
}
++audio_seen;
}
break;
}
case 0x18538067u: // Segment: descend, the size may be unknown
self(payload, payload_end, depth + 1, self);
break;
default:
break;
}
if (found) break;
reader.position = payload_end;
if (payload_end == element_start) break; // no progress: stop
}
};
walk(0, data.size(), 0, walk);
if (duration_ticks > 0.0 && timestamp_scale_ns > 0.0) {
result.duration_seconds = duration_ticks * timestamp_scale_ns / 1.0e9;
}
if (result.eac3) {
result.detail = "matroska: E-AC-3 audio track " + std::to_string(result.audio_index);
} else if (saw_tracks) {
result.detail = "matroska: first audio track is " +
(first_audio_codec.empty() ? std::string("unknown") : first_audio_codec);
} else {
result.detail = "matroska: no track list in the scanned window";
}
return result;
}
} // namespace
const char* kind_name(Kind kind) {
switch (kind) {
case Kind::kMp4: return "mp4";
case Kind::kMatroska: return "matroska";
default: return "none";
}
}
bool is_container_extension(const char* extension) {
if (extension == nullptr) return false;
static const char* const kExtensions[] = {"mp4", "m4a", "m4b", "m4p", "m4r",
"mov", "mkv", "mka", "webm"};
for (const char* candidate : kExtensions) {
if (_stricmp(extension, candidate) == 0) return true;
}
return false;
}
Result scan(const std::string& path, std::size_t max_bytes) {
Result result;
Window file;
if (!file.open(path)) {
result.detail = "cannot open the file";
return result;
}
if (file.size() < 16) {
result.detail = "file too small to be a container";
return result;
}
std::uint8_t head[16];
if (!file.read(0, head, sizeof(head))) {
result.detail = "cannot read the file header";
return result;
}
// Matroska starts with the EBML header element (0x1A45DFA3); ISO base media
// starts with a box whose type is at offset 4 ("ftyp" for a normal file).
if (head[0] == 0x1A && head[1] == 0x45 && head[2] == 0xDF && head[3] == 0xA3) {
result = scan_matroska(file, max_bytes);
} else {
result = scan_mp4(file, max_bytes);
}
joc_log::line("container: %s -> %s (eac3=%d audio#%u codec=%s)", path.c_str(),
result.detail.c_str(), result.eac3 ? 1 : 0, result.audio_index,
result.codec.empty() ? "-" : result.codec.c_str());
return result;
}
} // namespace joc_container
+47
View File
@@ -0,0 +1,47 @@
// Cheap structural probe of container files, to answer one question: does this
// file hold an E-AC-3 audio track, and if so which one?
//
// It exists so that a Media Library scan does not pay for an ffmpeg launch on
// every MP4 in the collection. The probe reads a bounded window of the file and
// walks only the headers it needs:
//
// MP4 / MOV top-level boxes -> moov -> trak -> hdlr(mdia) + stsd sample entry.
// moov may sit at the end (no faststart), so the tail is checked too.
// Matroska EBML header -> Segment -> Tracks -> TrackEntry -> CodecID.
//
// The E-AC-3 bitstream itself is never demuxed here: ffmpeg extracts it when the
// file is actually decoded, which keeps this file small and lets every container
// ffmpeg understands work without a demuxer of our own.
#pragma once
#include <cstdint>
#include <string>
namespace joc_container {
enum class Kind {
kNone,
kMp4,
kMatroska,
};
const char* kind_name(Kind kind);
struct Result {
Kind kind = Kind::kNone;
bool eac3 = false; // an E-AC-3 audio track is present
unsigned audio_index = 0; // which audio track it is, 0-based
double duration_seconds = 0.0; // from the container header, 0 when not stated
std::string codec; // codec identifier found, for the log
std::string detail;
};
// True when the extension belongs to a container this component looks inside.
bool is_container_extension(const char* extension);
// Reads at most max_bytes of header (plus the same amount at the end for MP4) and
// reports what it found. Missing files, empty files and unknown containers come
// back as Kind::kNone with a detail string.
Result scan(const std::string& path, std::size_t max_bytes = 4u * 1024u * 1024u);
} // namespace joc_container
+104 -24
View File
@@ -18,6 +18,7 @@
#include <cstring>
#include <string>
#include "container_scan.h"
#include "eac3_scan.h"
#include "joc_decode.h"
#include "log.h"
@@ -62,6 +63,24 @@ public:
input_open_file_helper(source, path, reason, abort);
m_file = source;
// 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();
}
const char* extension = std::strrchr(m_native_path.get_ptr(), '.');
extension = (extension != nullptr) ? extension + 1 : "";
if (joc_container::is_container_extension(extension)) {
open_container(extension);
return;
}
pfc::array_t<t_uint8> buffer;
buffer.set_size(kSniffBytes);
const std::size_t got = m_file->read(buffer.get_ptr(), kSniffBytes, abort);
@@ -78,18 +97,43 @@ public:
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");
joc_log::line("decoder: claiming this file as E-AC-3 JOC (bare stream)");
m_input_kind = joc_decode::InputKind::kBare;
}
// 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());
// Two questions, in this order: is there an E-AC-3 track inside (answered from
// the container's own headers, so a library scan pays nothing for the MP4s that
// hold AAC), and does that track carry JOC (answered from a copied prefix of
// the track). Either "no" hands the file to the next decoder in the table.
void open_container(const char* extension) {
m_container = joc_container::scan(m_native_path.get_ptr());
if (!m_container.eac3) {
joc_log::line("decoder: yielding to the built-in decoder (%s)",
m_container.detail.c_str());
throw exception_io_unsupported_format();
}
joc_log::line("decoder: native path \"%s\"", m_native_path.get_ptr());
const joc_decode::Settings settings = joc_settings::current();
bool joc = false;
std::string detail;
if (!joc_decode::probe_container_joc(settings.ffmpeg_path, m_native_path.get_ptr(),
m_container.audio_index, &joc, &detail)) {
joc_log::line("decoder: cannot examine the E-AC-3 track (%s); yielding",
detail.c_str());
throw exception_io_unsupported_format();
}
if (!joc) {
joc_log::line("decoder: yielding to the built-in decoder (E-AC-3 track %u carries "
"no JOC: %s)",
m_container.audio_index, detail.c_str());
throw exception_io_unsupported_format();
}
joc_log::line("decoder: claiming this file as E-AC-3 JOC (%s, audio track %u, .%s)",
joc_container::kind_name(m_container.kind), m_container.audio_index,
extension);
m_input_kind = joc_decode::InputKind::kContainer;
m_audio_index = m_container.audio_index;
}
void get_info(file_info& info, abort_callback& abort) {
@@ -97,6 +141,17 @@ public:
const joc_decode::FileProbe probe = joc_decode::probe_file(m_native_path.get_ptr());
const joc_decode::Settings settings = joc_settings::current();
// A container knows its own duration even though the E-AC-3 syncframes are
// not directly addressable in the file.
const bool container = (m_input_kind == joc_decode::InputKind::kContainer);
const double duration = (container && m_container.duration_seconds > 0.0)
? m_container.duration_seconds
: probe.duration_seconds;
const std::uint64_t frames =
probe.frames != 0
? probe.frames
: ((duration > 0.0) ? static_cast<std::uint64_t>(duration * 48000.0 / 1536.0) : 0);
unsigned channels = 2;
std::string render;
if (settings.output == joc_decode::Output::kBinaural) {
@@ -115,12 +170,12 @@ public:
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) {
info.set_length(duration);
if (duration > 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));
static_cast<double>(bytes) * 8.0 / duration / 1000.0));
}
}
// Custom fields: visible in Properties and usable as %joc_*% in title
@@ -129,11 +184,19 @@ public:
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());
info.info_set_int("joc_frames", static_cast<t_int64>(frames));
info.info_set("joc_scan", container ? m_container.detail.c_str() : probe.detail.c_str());
if (container) {
char text[128] = {};
std::snprintf(text, sizeof(text), "%s (%s, audio track %u)",
joc_container::kind_name(m_container.kind),
m_container.codec.empty() ? "E-AC-3" : m_container.codec.c_str(),
m_container.audio_index);
info.info_set("joc_container", text);
}
joc_log::line("decoder: get_info duration=%.3f s frames=%llu channels=%u render=%s%s",
duration, static_cast<unsigned long long>(frames), channels, render.c_str(),
container ? " (container)" : "");
}
t_filestats2 get_stats2(uint32_t flags, abort_callback& abort) {
@@ -144,6 +207,8 @@ public:
void decode_initialize(unsigned flags, abort_callback& abort) {
(void)abort;
m_settings = joc_settings::current();
m_settings.input_kind = m_input_kind;
m_settings.audio_index = m_audio_index;
joc_log::line("decoder: initialize flags=0x%X settings: %s", flags,
joc_settings::describe(m_settings).c_str());
@@ -224,18 +289,28 @@ public:
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.
// Claim by extension, then decide from the contents in open(): a file whose
// audio turns out not to be E-AC-3 JOC is handed back with
// exception_io_unsupported_format, and the core moves on to the next decoder
// in its priority table. Containers are included because an E-AC-3 JOC
// track is commonly wrapped in MP4 or Matroska; the container walk in
// open() is what keeps the other files cheap to decline.
if (joc_container::is_container_extension(extension)) return true;
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.
if (content_type == nullptr) return false;
// E-AC-3 content types, and only the E-AC-3 ones: the generic Dolby aliases
// are deliberately left alone, because a track the core can already decode
// must not end up claimed by an entry that will not decode it.
static const char* const kTypes[] = {"audio/eac3", "audio/eac3joc", "audio/ec3",
"audio/x-eac3", "E-AC-3", "eac3",
"ec3"};
for (const char* candidate : kTypes) {
if (stricmp_utf8(content_type, candidate) == 0) return true;
}
return false;
}
@@ -250,6 +325,11 @@ private:
pfc::string8 m_native_path;
joc_decode::Settings m_settings;
joc_decode::Engine m_engine;
// Set by open(): a bare stream is read directly, an E-AC-3 track inside a
// container is extracted by ffmpeg before it reaches the renderer.
joc_decode::InputKind m_input_kind = joc_decode::InputKind::kBare;
unsigned m_audio_index = 0;
joc_container::Result m_container;
std::vector<float> m_buffer;
unsigned m_channels = 2;
std::uint64_t m_frames_delivered = 0;
+154 -23
View File
@@ -88,19 +88,24 @@ const char* error_text(const CoreApi& api, joc_error code) {
// ---------------------------------------------------------------------------
// ffmpeg child process producing the 5.1 core PCM on a pipe.
// ---------------------------------------------------------------------------
class BedProcess {
class FfmpegPipe {
public:
~BedProcess() { stop(); }
~FfmpegPipe() { stop(); }
// ffmpeg_path, the input file and whatever should follow "-i <input>" are
// separate: the 5.1 bed and the E-AC-3 metadata stream of a container file are
// both ffmpeg output, they only differ in those arguments.
bool start(const std::string& ffmpeg_path, const std::string& input_path,
const std::wstring& stderr_path, std::string* error) {
const std::wstring& output_arguments, const char* label,
const std::wstring& stderr_path, std::string* error,
std::size_t pipe_bytes = kBedPipeBytes) {
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";
if (CreatePipe(&read_end, &write_end, &attributes, static_cast<DWORD>(pipe_bytes)) == FALSE) {
if (error != nullptr) *error = std::string("cannot create the ") + label + " pipe";
return false;
}
// Only the child's end is inheritable.
@@ -115,10 +120,8 @@ public:
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 -";
command += L"\" ";
command += output_arguments;
STARTUPINFOW startup{};
startup.cb = sizeof(startup);
@@ -138,13 +141,13 @@ public:
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";
if (error != nullptr) *error = std::string("cannot start ffmpeg for the ") + label;
return false;
}
CloseHandle(process.hThread);
pipe_ = read_end;
process_ = process.hProcess;
joc_log::line("core bed: ffmpeg started (pid %lu)", process.dwProcessId);
joc_log::line("core %s: ffmpeg started (pid %lu)", label, process.dwProcessId);
return true;
}
@@ -341,15 +344,109 @@ FileProbe probe_file(const std::string& path, std::size_t max_scan_bytes) {
return probe;
}
// ---------------------------------------------------------------------------
// JOC verdict for a container file, without rendering anything.
// ---------------------------------------------------------------------------
bool probe_container_joc(const std::string& ffmpeg_path, const std::string& path,
unsigned audio_index, bool* joc, std::string* detail) {
struct Entry {
std::string path;
unsigned audio_index = 0;
std::uint64_t size = 0;
std::uint64_t modified = 0;
bool joc = false;
std::string detail;
};
static std::vector<Entry> cache;
std::uint64_t size = 0;
std::uint64_t modified = 0;
{
WIN32_FILE_ATTRIBUTE_DATA data{};
if (GetFileAttributesExW(utf8_to_wide(path).c_str(), GetFileExInfoStandard, &data) !=
FALSE) {
size = (static_cast<std::uint64_t>(data.nFileSizeHigh) << 32) | data.nFileSizeLow;
modified = (static_cast<std::uint64_t>(data.ftLastWriteTime.dwHighDateTime) << 32) |
data.ftLastWriteTime.dwLowDateTime;
}
}
for (const Entry& entry : cache) {
if (entry.path == path && entry.audio_index == audio_index && entry.size == size &&
entry.modified == modified) {
if (joc != nullptr) *joc = entry.joc;
if (detail != nullptr) *detail = entry.detail;
return true;
}
}
if (ffmpeg_path.empty()) {
if (detail != nullptr) *detail = "no ffmpeg configured";
return false;
}
FfmpegPipe pipe;
std::string error;
std::wstring arguments = L"-map 0:a:";
arguments += std::to_wstring(audio_index);
arguments += L" -vn -t 3 -c:a copy -f eac3 -";
const std::wstring stderr_path = [] {
wchar_t temp[MAX_PATH + 1] = {};
const DWORD length = GetTempPathW(MAX_PATH, temp);
return length == 0 ? std::wstring(L"NUL")
: std::wstring(temp) + L"joc_container_probe.log";
}();
if (!pipe.start(ffmpeg_path, path, arguments, "probe", stderr_path, &error, 1u << 20)) {
if (detail != nullptr) *detail = error;
return false;
}
std::vector<std::uint8_t> prefix(512u * 1024u);
std::size_t filled = 0;
while (filled < prefix.size()) {
const std::size_t got = pipe.read(prefix.data() + filled, prefix.size() - filled);
if (got == 0) break;
filled += got;
}
pipe.stop();
const joc_eac3::ScanResult scan = joc_eac3::scan(prefix.data(), filled, 8);
const bool carries_joc = (scan.joc == joc_eac3::JocState::kYes);
Entry entry;
entry.path = path;
entry.audio_index = audio_index;
entry.size = size;
entry.modified = modified;
entry.joc = carries_joc;
entry.detail = scan.detail;
if (cache.size() >= 8) cache.erase(cache.begin());
cache.push_back(entry);
joc_log::line("container: %s track %u -> %s (%s)", path.c_str(), audio_index,
carries_joc ? "JOC" : "not JOC", scan.detail);
if (joc != nullptr) *joc = carries_joc;
if (detail != nullptr) *detail = scan.detail;
return true;
}
// ---------------------------------------------------------------------------
// Engine
// ---------------------------------------------------------------------------
struct Engine::Impl {
CoreApi api;
joc_stream* stream = nullptr;
InputFile eac3;
BedProcess bed;
InputFile eac3; // bare E-AC-3 input: read straight from the file
FfmpegPipe eac3_pipe; // E-AC-3 inside a container: ffmpeg extracts the stream
bool eac3_from_pipe = false;
FfmpegPipe bed;
Settings settings;
// The metadata stream comes either from the file itself or from ffmpeg.
std::size_t read_eac3(void* destination, std::size_t bytes) {
return eac3_from_pipe ? eac3_pipe.read(destination, bytes)
: eac3.read(destination, bytes);
}
unsigned channels = 0;
std::uint64_t frames_queued = 0; // E-AC-3 frames handed to the core
std::uint64_t bed_frames_pushed = 0;
@@ -382,7 +479,9 @@ void Engine::stop() {
impl.stream = nullptr;
}
impl.bed.stop();
impl.eac3_pipe.stop();
impl.eac3.close();
impl.eac3_from_pipe = false;
}
bool Engine::start(const std::string& input_path, const Settings& settings, std::string* error) {
@@ -402,9 +501,15 @@ bool Engine::start(const std::string& input_path, const Settings& settings, std:
return false;
}
if (!impl.eac3.open(input_path)) {
if (error != nullptr) *error = "cannot open the input file";
return false;
// Metadata stream: a bare E-AC-3 file is read directly, while the E-AC-3 track
// of a container is extracted by ffmpeg (stream copy, so the syncframes reach
// the renderer exactly as stored).
impl.eac3_from_pipe = (settings.input_kind == InputKind::kContainer);
if (!impl.eac3_from_pipe) {
if (!impl.eac3.open(input_path)) {
if (error != nullptr) *error = "cannot open the input file";
return false;
}
}
joc_stream_config config{};
@@ -479,20 +584,46 @@ bool Engine::start(const std::string& input_path, const Settings& settings, std:
settings.speaker_layout.c_str(),
hrtf_in_use.empty() ? "(none)" : hrtf_in_use.c_str());
const std::wstring stderr_path = [] {
// ffmpeg's stderr lands next to the component rather than in whatever working
// directory the host process happens to have. The two children write separate
// files so neither can truncate the other's diagnostics.
const auto stderr_path_for = [](const wchar_t* name) {
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");
if (length == 0) return std::wstring(name);
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 slash == std::wstring::npos ? std::wstring(name)
: text.substr(0, slash) + L"\\" + name;
};
// The 5.1 core PCM, exactly as the reference renderer's own core decode does it:
// 5.1 interleaved float32 at 48 kHz, the layout the renderer expects
// (L R C LFE Ls Rs).
std::wstring bed_arguments = L"-map 0:a:";
bed_arguments += std::to_wstring(settings.audio_index);
bed_arguments += L" -vn -ac 6 -ar 48000 -c:a pcm_f32le -f f32le -";
if (!impl.bed.start(settings.ffmpeg_path, input_path, bed_arguments, "bed",
stderr_path_for(L"joc_ffmpeg_bed.log"), error)) {
return false;
}
if (impl.eac3_from_pipe) {
// Stream copy: the syncframes arrive byte for byte as they are stored, which
// is what the JOC metadata needs.
std::wstring stream_arguments = L"-map 0:a:";
stream_arguments += std::to_wstring(settings.audio_index);
stream_arguments += L" -vn -c:a copy -f eac3 -";
if (!impl.eac3_pipe.start(settings.ffmpeg_path, input_path, stream_arguments, "metadata",
stderr_path_for(L"joc_ffmpeg_stream.log"), error,
1u << 20)) {
return false;
}
}
return true;
}
@@ -534,7 +665,7 @@ std::size_t Engine::read(float* destination, std::size_t frames, std::string* er
// 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,
const std::size_t got = impl.read_eac3(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) {
+18
View File
@@ -35,8 +35,18 @@ enum class HrtfSource {
kRosella = 1,
};
// Where the E-AC-3 syncframes come from. A bare stream is read straight from the
// file; inside a container the track has to be extracted first (ffmpeg, stream copy).
enum class InputKind {
kBare = 0,
kContainer = 1,
};
struct Settings {
Output output = Output::kBinaural;
InputKind input_kind = InputKind::kBare;
// Which of the container's audio tracks holds the E-AC-3 stream, 0-based.
unsigned audio_index = 0;
std::string speaker_layout = "7.1";
HrtfSource hrtf_source = HrtfSource::kSofa;
// Empty means the default file in the default folder, see resolve_hrtf_file().
@@ -87,6 +97,14 @@ struct FileProbe {
// 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);
// Decides whether the E-AC-3 track of a container file carries JOC, without
// rendering anything: ffmpeg copies a short prefix of that track out (stream copy,
// so the syncframes are the stored ones) and the same bitstream test is applied.
// The verdict is cached per file, because the information and the decode pass would
// otherwise each launch ffmpeg for it.
bool probe_container_joc(const std::string& ffmpeg_path, const std::string& path,
unsigned audio_index, bool* joc, std::string* detail);
class Engine {
public:
Engine();
-1
View File
@@ -8,7 +8,6 @@
#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