Fix missing tags, broken seeking, and the x64 playback crash
build / windows (push) Has been cancelled
build / release (push) Has been cancelled

This commit is contained in:
2026-09-26 03:14:02 +08:00
parent 259688ef1b
commit 65f56990aa
10 changed files with 1107 additions and 398 deletions
+289 -29
View File
@@ -11,9 +11,11 @@
#include <SDK/audio_chunk.h>
#include <SDK/exception_io.h>
#include <SDK/file_info.h>
#include <SDK/file_info_impl.h>
#include <SDK/input.h>
#include <SDK/input_file_type.h>
#include <SDK/input_impl.h>
#include <SDK/tag_processor.h>
#include <cstring>
#include <string>
@@ -31,6 +33,18 @@ constexpr std::size_t kSniffBytes = 256u * 1024u;
constexpr std::size_t kRunFrames = 4096u;
constexpr unsigned kSampleRate = 48000;
// Largest magnitude in a block, for the delivery check in decode_run().
template <typename Sample>
double peak_of(const Sample* values, std::size_t count) {
double peak = 0.0;
for (std::size_t i = 0; i < count; ++i) {
const double value =
values[i] < Sample(0) ? -static_cast<double>(values[i]) : static_cast<double>(values[i]);
if (value > peak) peak = value;
}
return peak;
}
// Identity in the decoder priority table.
const GUID g_decoder_guid = {0x9c3f1d58, 0x27ab, 0x4e64, {0xb0, 0x93, 0x5e, 0x1c, 0xd7, 0x48, 0x2f, 0xa6}};
@@ -52,13 +66,131 @@ std::string file_name_of(const std::string& path) {
return slash == std::string::npos ? path : path.substr(slash + 1);
}
// ---------------------------------------------------------------------------
// Tags of a file this component has taken over.
//
// An MP4/M4A keeps its tags in its own metadata box, and the component that knows
// how to read and write them is the container reader the core already ships.
// Claiming a file for decoding must not take it away from that reader, and the SDK
// has no "decode with me, ask someone else for tags" arrangement -- whichever
// entry answers open() answers for everything. So the information read and write
// paths are forwarded to whichever other entry claims the file, and only the tags
// of its answer are merged into ours: the technical information stays this
// component's own, which is what tells a user the file is JOC rather than plain
// E-AC-3.
// ---------------------------------------------------------------------------
// Entries other than this one that claim the path, in the user's own decoding
// order. Ourselves is never in the list: an open forwarded back here would enter
// open() again, for ever.
void forwarding_candidates(const char* url, pfc::list_t<input_entry::ptr>& out) {
out.remove_all();
input_manager_v3::ptr manager;
if (input_manager_v3::tryGet(manager)) {
manager->get_enabled_inputs(out);
} else {
input_entry::g_find_inputs_by_path_ex(out, url,
[](input_entry::ptr) { return true; });
}
const char* dot = std::strrchr(url, '.');
const char* extension = (dot != nullptr) ? dot + 1 : "";
const GUID self = g_decoder_guid;
for (t_size index = out.get_count(); index-- > 0;) {
input_entry::ptr entry = out[index];
if (entry->get_guid_() == self || !entry->is_our_path(url, extension)) {
out.remove_by_idx(index);
}
}
}
// Opens the file again through another entry, for information reading or writing.
// The file is left unopened on our side, so the other entry can have it to itself.
template <typename t_interface>
bool open_forwarded(service_ptr_t<t_interface>& out, const GUID& what_for, const char* url,
abort_callback& abort, pfc::string8* name) {
out.release();
pfc::list_t<input_entry::ptr> candidates;
forwarding_candidates(url, candidates);
if (candidates.get_count() == 0) return false;
try {
GUID used = pfc::guid_null;
service_ptr opened = input_entry::g_open_from_list(candidates, what_for, nullptr, url,
nullptr, abort, &used);
if (!opened.is_valid() || !opened->service_query_t(out)) return false;
if (name != nullptr) {
input_entry::ptr entry = input_entry::g_find_by_guid(used);
*name = entry.is_valid() ? entry->get_name_() : "another component";
}
return true;
} catch (const pfc::exception& error) {
joc_log::line("decoder: no other component answers for this file's tags: %s",
error.what());
return false;
}
}
// Bytes in front of the E-AC-3 stream, which is where a tagging tool puts an
// ID3v2 tag. The renderer refuses a stream that does not begin on a syncword and
// never resynchronises, so the walk and the feed both have to start after it.
t_filesize leading_tag_bytes(file::ptr const& source, abort_callback& abort) {
if (!source.is_valid()) return 0;
try {
if (source->get_position(abort) != 0) source->seek(0, abort);
return tag_processor::skip_id3v2(source, abort);
} catch (const pfc::exception& error) {
joc_log::line("decoder: cannot inspect the area in front of the stream: %s",
error.what());
return 0;
}
}
// Tags read straight from the file, for a bare stream that no other component
// claims: an ID3v2 tag in front of the syncframes, or an APEv2/ID3v1 tag behind
// them. Neither is part of E-AC-3, so a tag that is there was written by a
// tagging tool and is worth showing.
void read_local_tags(file::ptr const& source, file_info& info, abort_callback& abort) {
if (!source.is_valid()) return;
bool found = false;
try {
source->seek(0, abort);
tag_processor::read_id3v2(source, info, abort);
found = true;
} catch (const pfc::exception&) {
// No leading tag; the trailing one is still worth a look.
}
try {
tag_processor::read_trailing(source, info, abort);
found = true;
} catch (const pfc::exception&) {
}
if (found) {
joc_log::line("decoder: %u tag field(s) read from the file itself",
static_cast<unsigned>(info.meta_get_count()));
}
}
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 : "";
if (reason == input_open_info_write) {
// Writing tags belongs to whoever owns the file's format, and that is
// not this component: its inputs are two ffmpeg children and the JOC
// renderer, none of which writes anything. The file is deliberately
// left unopened here, because a write-mode handle of ours would make
// the writer that replaces it fail on a sharing violation.
m_write_only = true;
if (!open_forwarded(m_forward_writer, input_info_writer::class_guid, m_path.c_str(),
abort, &m_forward_name)) {
throw exception_tagging_unsupported();
}
joc_log::line("decoder: open \"%s\" reason=2 tags: written by %s", m_path.c_str(),
m_forward_name.c_str());
return;
}
service_ptr_t<file> source = hint;
input_open_file_helper(source, path, reason, abort);
m_file = source;
@@ -78,7 +210,31 @@ public:
if (joc_container::is_container_extension(extension)) {
open_container(extension);
return;
} else {
open_bare(abort);
}
// Whatever else happens, the tags of this file are read by the component
// that owns its format; failing to find one is not fatal, the technical
// information below is still worth showing.
if (!open_forwarded(m_forward_reader, input_info_reader::class_guid, m_path.c_str(), abort,
&m_forward_name)) {
joc_log::line("decoder: no other component reads this file's tags");
} else {
joc_log::line("decoder: tags for \"%s\" are read by %s", m_path.c_str(),
m_forward_name.c_str());
}
}
// A bare stream is either read from the file or handed back. One thing has to
// happen first: a tag area in front of the syncframes is not part of the
// stream, and treating it as one would hand the file to the built-in decoder,
// which plays it without the Atmos objects.
void open_bare(abort_callback& abort) {
m_stream_start = leading_tag_bytes(m_file, abort);
if (m_stream_start != 0) {
joc_log::line("decoder: %llu byte(s) of tags in front of the stream are skipped",
static_cast<unsigned long long>(m_stream_start));
}
pfc::array_t<t_uint8> buffer;
@@ -86,9 +242,8 @@ public:
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),
joc_log::line("decoder: open \"%s\" reason=1 bytes=%llu frames=%llu with_joc=%llu",
m_path.c_str(), static_cast<unsigned long long>(got),
static_cast<unsigned long long>(scan.frames_examined),
static_cast<unsigned long long>(scan.frames_with_joc));
@@ -137,8 +292,18 @@ public:
}
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());
if (m_write_only) {
// An instance opened to write tags is the writer's reader: what it
// reports is exactly what the caller has just written.
if (m_forward_writer.is_valid()) {
m_forward_writer->get_info(0, info, abort);
return;
}
throw exception_tagging_unsupported();
}
const joc_decode::FileProbe probe =
joc_decode::probe_file(m_native_path.get_ptr(), 0, m_stream_start);
const joc_decode::Settings settings = joc_settings::current();
// A container knows its own duration even though the E-AC-3 syncframes are
@@ -171,6 +336,10 @@ public:
info.info_set_int("bitspersample", 32);
info.info_set("bitspersample_extra", "floating-point");
info.set_length(duration);
m_length = duration;
// The renderer keeps its room tail, but the stream this component hands over
// ends where the file ends: the tail is rendering, not playback time.
m_engine.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) {
@@ -194,6 +363,32 @@ public:
m_container.audio_index);
info.info_set("joc_container", text);
}
// The file's own reader supplies the tags; nothing above this line is one.
// Only the metadata is taken over -- its technical information (E-AC-3,
// 6 channels, the stream's own bitrate) would replace this component's,
// which is the part that says whether the file is JOC.
bool have_tags = false;
if (m_forward_reader.is_valid()) {
try {
file_info_impl tags;
m_forward_reader->get_info(0, tags, abort);
info.copy_meta(tags);
have_tags = tags.meta_get_count() != 0;
joc_log::line("decoder: %u tag field(s) from %s",
static_cast<unsigned>(tags.meta_get_count()),
m_forward_name.c_str());
} catch (const pfc::exception& error) {
joc_log::line("decoder: reading this file's own tags failed: %s", error.what());
}
}
// A reader that answers for the format but has nothing to say about a bare
// stream is common -- ffmpeg's AC-3 decoder reads no tags at all -- while
// the file may still carry an ID3v2 or APEv2 tag a tagging tool wrote.
if (!have_tags && m_input_kind == joc_decode::InputKind::kBare) {
read_local_tags(m_file, info, abort);
}
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)" : "");
@@ -201,6 +396,7 @@ public:
t_filestats2 get_stats2(uint32_t flags, abort_callback& abort) {
if (m_file.is_valid()) return m_file->get_stats2_(flags, abort);
if (m_forward_writer.is_valid()) return m_forward_writer->get_stats2_(nullptr, flags, abort);
throw exception_io_unsupported_format();
}
@@ -209,6 +405,8 @@ public:
m_settings = joc_settings::current();
m_settings.input_kind = m_input_kind;
m_settings.audio_index = m_audio_index;
m_settings.stream_start_bytes = m_stream_start;
m_settings.length_seconds = m_length;
joc_log::line("decoder: initialize flags=0x%X settings: %s", flags,
joc_settings::describe(m_settings).c_str());
@@ -229,6 +427,7 @@ public:
m_buffer.resize(kRunFrames * m_channels);
m_frames_delivered = 0;
m_reported = false;
m_delivery_mismatches = 0;
joc_log::line("decoder: engine ready, %u output channel(s), %u frames per read",
m_channels, static_cast<unsigned>(kRunFrames));
}
@@ -242,50 +441,98 @@ public:
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));
joc_log::line("decoder: end of stream after %llu frames%s",
static_cast<unsigned long long>(m_frames_delivered),
m_delivery_mismatches == 0 ? ""
: " (the delivery changed samples)");
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));
// The renderer produces float32 and a chunk holds audio_sample, which is float on
// 32-bit builds and double on 64-bit ones (SDK audio_math.h): the samples are
// converted, not copied. set_data_32() is the SDK's conversion for a float32
// source, and it sets the channel count, the sample rate and the sample count.
chunk.set_data_32(m_buffer.data(), frames, m_channels, kSampleRate);
m_frames_delivered += frames;
// A delivery that mangled the samples would be heard as noise rather than reported
// as a failure, so every chunk is checked: the conversion is exact, and the peak of
// what the chunk holds has to equal the peak of what the renderer produced.
const double produced = peak_of(m_buffer.data(), frames * m_channels);
const double delivered =
peak_of(chunk.get_data(), chunk.get_sample_count() * chunk.get_channels());
if (delivered > produced + 1e-6 + produced * 1e-6 ||
delivered < produced - 1e-6 - produced * 1e-6) {
++m_delivery_mismatches;
if (m_delivery_mismatches == 1) {
joc_log::line("decoder: delivery changed the samples: peak %.9f produced, "
"%.9f delivered",
produced, delivered);
}
}
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));
joc_log::line("decoder: first %llu frames delivered (%u ch), peak %.6f, "
"delivered peak %.6f",
static_cast<unsigned long long>(frames), m_channels, produced,
delivered);
}
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();
void decode_seek(double seconds, abort_callback& abort) {
// Walking the syncframe index of a long file is the only part of a seek
// that can take a while, and it polls this.
m_engine.set_abort_check([&abort] { return !abort.is_aborting(); });
std::string error;
const bool ok = m_engine.seek(seconds, m_length, &error);
m_engine.set_abort_check(nullptr);
if (!ok) {
// An aborted seek reports itself as an abort, not as a decode failure.
abort.check();
joc_log::line("decoder: seek to %.6f s failed: %s", seconds, error.c_str());
throw exception_io_data(error.c_str());
}
// The position reporting and the first-read statistics belong to the run
// that starts here, not to the one that was interrupted.
m_frames_delivered = 0;
m_reported = false;
m_delivery_mismatches = 0;
joc_log::line("decoder: seek to %.6f s, the next read starts at the target", seconds);
}
bool decode_can_seek() { return false; }
bool decode_can_seek() { return true; }
size_t extended_param(const GUID& type, size_t arg1, void* arg2, size_t arg2size) {
(void)arg1;
(void)arg2;
(void)arg2size;
// A seek restarts both ffmpeg children and replays the renderer's warm-up,
// so it is worth avoiding the ones the core would only make speculatively.
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(); }
void retag(const file_info& info, abort_callback& abort) {
if (!m_forward_writer.is_valid()) throw exception_tagging_unsupported();
// A single-track input has no commit() of its own -- the SDK wrapper
// implements it as a no-op -- so the writer's commit has to happen here or
// nothing reaches the file.
m_forward_writer->set_info(0, info, abort);
m_forward_writer->commit(abort);
joc_log::line("decoder: %u tag field(s) written through %s",
static_cast<unsigned>(info.meta_get_count()), m_forward_name.c_str());
}
void remove_tags(abort_callback& abort) {
if (!m_forward_writer.is_valid()) throw exception_tagging_unsupported();
input_info_writer_v2::ptr v2;
if (m_forward_writer->service_query_t(v2)) {
v2->remove_tags(abort);
return;
}
m_forward_writer->remove_tags_fallback(abort);
}
static bool g_is_our_path(const char* path, const char* extension) {
(void)path;
@@ -334,6 +581,19 @@ private:
unsigned m_channels = 2;
std::uint64_t m_frames_delivered = 0;
bool m_reported = false;
// Chunks whose delivered samples did not match what the renderer produced.
std::uint64_t m_delivery_mismatches = 0;
// Duration get_info() last reported; a seek needs it to tell "past the end"
// from "inside the file" without decoding anything.
double m_length = 0.0;
// Bytes in front of a bare stream, which is where an ID3v2 tag sits.
std::uint64_t m_stream_start = 0;
// The other component that answers for this file's tags, and the one that
// writes them. Only the writer exists on an instance opened to retag.
service_ptr_t<input_info_reader> m_forward_reader;
service_ptr_t<input_info_writer> m_forward_writer;
pfc::string8 m_forward_name;
bool m_write_only = false;
};
static input_singletrack_factory_t<input_joc> g_input_joc_factory;
+389 -44
View File
@@ -2,8 +2,10 @@
#include <windows.h>
#include <cmath>
#include <cstdio>
#include <cstring>
#include <cwchar>
#include <vector>
// The kernel copy that is compiled into this component; see kernel/.
@@ -42,6 +44,7 @@ struct CoreApi {
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* reset)(joc_stream*) = joc_stream_reset;
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;
@@ -199,6 +202,7 @@ public:
FILE_ATTRIBUTE_NORMAL | FILE_FLAG_SEQUENTIAL_SCAN, nullptr);
return handle_ != INVALID_HANDLE_VALUE;
}
bool is_open() const { 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;
@@ -207,6 +211,12 @@ public:
}
return got;
}
bool seek(std::uint64_t offset) {
if (handle_ == INVALID_HANDLE_VALUE) return false;
LARGE_INTEGER value{};
value.QuadPart = static_cast<LONGLONG>(offset);
return SetFilePointerEx(handle_, value, nullptr, FILE_BEGIN) != FALSE;
}
std::uint64_t size() const {
LARGE_INTEGER value{};
if (handle_ == INVALID_HANDLE_VALUE || GetFileSizeEx(handle_, &value) == FALSE) return 0;
@@ -228,6 +238,41 @@ const char* const kLayouts[] = {"2.0", "3.0", "3.1", "4.0", "5.0", "5.
"9.1.6", "22.2"};
const unsigned kLayoutChannels[] = {2, 3, 4, 4, 5, 6, 8, 10, 7, 7, 8, 10, 12, 14, 16, 24};
// Every ffmpeg child writes its diagnostics next to the component rather than into
// whatever working directory the host process happens to have; separate files so
// no child can truncate another's.
std::wstring 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(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(name)
: text.substr(0, slash) + L"\\" + name;
}
// The time ffmpeg's -ss takes for a given source sample. A seek always starts on
// a syncframe boundary, so the sample is exactly representable in microseconds.
std::wstring seek_time(std::uint64_t source_sample) {
if (source_sample == 0) return {};
wchar_t text[64] = {};
std::swprintf(text, 64, L"%.6f", static_cast<double>(source_sample) / 48000.0);
return text;
}
// Source sample a time offset names, rounded the way the core rounds a position
// (pfc::rint64, i.e. llrint: to nearest, ties to even).
std::int64_t sample_of_seconds(double seconds) {
if (!(seconds > 0.0)) return 0; // also catches NaN
const double value = seconds * 48000.0;
if (value >= 9.0e18) return 9223372036854775807LL;
return static_cast<std::int64_t>(std::llrint(value));
}
} // namespace
const char* const* speaker_layouts(std::size_t* count) {
@@ -277,7 +322,8 @@ std::string resolve_hrtf_file(const Settings& settings) {
return directory + "\\HRTF\\" + name;
}
FileProbe probe_file(const std::string& path, std::size_t max_scan_bytes) {
FileProbe probe_file(const std::string& path, std::size_t max_scan_bytes,
std::uint64_t start_offset) {
FileProbe probe;
InputFile file;
if (!file.open(path)) {
@@ -285,6 +331,15 @@ FileProbe probe_file(const std::string& path, std::size_t max_scan_bytes) {
return probe;
}
const std::uint64_t size = file.size();
if (start_offset >= size) {
probe.detail = "file too small to be E-AC-3";
return probe;
}
if (start_offset != 0 && !file.seek(start_offset)) {
probe.detail = "cannot skip the leading tag area";
return probe;
}
const std::uint64_t stream_bytes = size - start_offset;
// 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,
@@ -293,7 +348,7 @@ FileProbe probe_file(const std::string& path, std::size_t max_scan_bytes) {
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));
(stream_bytes < kFullWalkLimit && stream_bytes > 0) ? stream_bytes : 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";
@@ -310,7 +365,7 @@ FileProbe probe_file(const std::string& path, std::size_t max_scan_bytes) {
}
std::uint64_t frames = 0;
if (buffer.size() == size) {
if (buffer.size() == stream_bytes) {
std::size_t offset = 0;
while (true) {
const std::size_t bytes = joc_eac3::frame_bytes_at(buffer.data(), got, offset);
@@ -320,7 +375,7 @@ FileProbe probe_file(const std::string& path, std::size_t max_scan_bytes) {
}
probe.detail = "frame count walked over the whole file";
} else if (scan.all_frames_same_size) {
frames = size / scan.first_frame_bytes;
frames = stream_bytes / 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.
@@ -333,7 +388,8 @@ FileProbe probe_file(const std::string& path, std::size_t max_scan_bytes) {
++seen;
}
frames = (offset != 0) ? static_cast<std::uint64_t>(
(static_cast<double>(size) / static_cast<double>(offset)) *
(static_cast<double>(stream_bytes) /
static_cast<double>(offset)) *
static_cast<double>(seen))
: 0;
probe.detail = "frame count estimated from a variable frame size";
@@ -443,6 +499,7 @@ struct Engine::Impl {
bool eac3_from_pipe = false;
FfmpegPipe bed;
Settings settings;
std::string input_path;
// The metadata stream comes either from the file itself or from ffmpeg.
std::size_t read_eac3(void* destination, std::size_t bytes) {
@@ -464,8 +521,192 @@ struct Engine::Impl {
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;
// Seek state. seek_skip counts output frames still to be discarded: a seek
// restarts the inputs on the syncframe before the target, and the samples the
// renderer produces for the part already behind the target are dropped here.
std::uint64_t seek_skip = 0;
// Delivered-stream accounting: where the stream currently being delivered starts
// on the source timeline, how much of it has been handed over, and where it has
// to stop. end_sample is the file's own end, so a binaural room tail cannot
// turn into playback time the file does not have.
std::uint64_t start_sample = 0;
std::uint64_t delivered = 0;
std::uint64_t end_sample = 0; // 0 = no limit
// Syncframes of a container's metadata stream still to be read and thrown away.
// ffmpeg's input seek on a copy stream lands on the frame whose timestamp is
// at or after the target, which is not reliably the frame the bed restarts on,
// so a container seek re-reads the stream and drops the frames here instead.
std::uint64_t metadata_skip = 0;
bool at_end = false; // seek landed at or past the end
std::function<bool()> abort_check;
// Bare-stream syncframe index: offset of every syncframe within the stream
// (so index_base has to be added to get a file offset). Filled by walking the
// file, and only as far as a seek asks for.
std::vector<std::uint64_t> frame_offsets;
std::uint64_t index_base = 0; // file offset the stream starts at
std::uint64_t index_file_offset = 0; // file offset the walk continues from
std::uint64_t index_stream_offset = 0; // stream offset the walk continues from
bool index_complete = false;
void reset_feed_state() {
frames_queued = 0;
bed_frames_pushed = 0;
eac3_eof = false;
bed_eof = false;
flushed = false;
trace_count = 0;
read_calls = 0;
eac3_carry = 0;
bed_bytes_read = 0;
bed_staged_bytes = 0;
seek_skip = 0;
metadata_skip = 0;
start_sample = 0;
delivered = 0;
at_end = false;
}
void stop_inputs() {
bed.stop();
eac3_pipe.stop();
eac3.close();
}
void reset_index() {
frame_offsets.clear();
index_base = settings.stream_start_bytes;
index_file_offset = index_base;
index_stream_offset = 0;
index_complete = false;
}
// Grows the syncframe index until it holds `wanted` entries or the stream ends.
bool grow_frame_index(std::uint64_t wanted, std::string* error);
// Points the file at the syncframe `frame`, or reports at_end when the stream
// has fewer frames than that.
bool position_bare(std::uint64_t frame, std::string* error);
bool start_bed(std::uint64_t source_sample, std::string* error);
bool start_metadata(std::string* error);
};
bool Engine::Impl::grow_frame_index(std::uint64_t wanted, std::string* error) {
constexpr std::size_t kWindow = 256u * 1024u;
std::vector<std::uint8_t> buffer(kWindow);
while (frame_offsets.size() < wanted && !index_complete) {
if (abort_check != nullptr && !abort_check()) {
if (error != nullptr) *error = "the seek was aborted";
return false;
}
if (!eac3.is_open() && !eac3.open(input_path)) {
if (error != nullptr) *error = "cannot reopen the input file";
return false;
}
const std::uint64_t size = eac3.size();
if (index_file_offset + 4u > size) {
index_complete = true;
break;
}
if (!eac3.seek(index_file_offset)) {
if (error != nullptr) *error = "cannot position the input file";
return false;
}
const std::uint64_t remaining = size - index_file_offset;
const std::size_t want = static_cast<std::size_t>(
remaining < kWindow ? remaining : kWindow);
const std::size_t got = eac3.read(buffer.data(), want);
if (got < 4u) {
index_complete = true;
break;
}
std::size_t consumed = 0;
while (frame_offsets.size() < wanted) {
const std::size_t bytes = joc_eac3::frame_bytes_at(buffer.data(), got, consumed);
if (bytes == 0 || consumed + bytes > got) break;
frame_offsets.push_back(index_stream_offset + consumed);
consumed += bytes;
}
if (consumed == 0) {
// Not even one whole syncframe in a full window: the stream stops here.
index_complete = true;
break;
}
index_file_offset += consumed;
index_stream_offset += consumed;
}
return true;
}
bool Engine::Impl::position_bare(std::uint64_t frame, std::string* error) {
if (!grow_frame_index(frame + 1u, error)) return false;
if (frame >= frame_offsets.size()) {
// Past the last syncframe: the decoder contract asks for a successful seek
// that the next read() answers with end of stream.
at_end = true;
stop_inputs();
return true;
}
// Position check first: frame_offsets.size() is what bounds the index.
const std::uint64_t file_offset =
index_base + frame_offsets[static_cast<std::size_t>(frame)];
if (!eac3.is_open() && !eac3.open(input_path)) {
if (error != nullptr) *error = "cannot reopen the input file";
return false;
}
// The renderer rejects a stream that does not begin on a syncword and never
// resynchronises, so a mispositioned start has to fail loudly here rather than
// turn into silence at the end of the file.
std::uint8_t header[8] = {};
if (!eac3.seek(file_offset) || eac3.read(header, sizeof(header)) < 4u ||
joc_eac3::frame_bytes_at(header, sizeof(header), 0) == 0) {
if (error != nullptr) {
*error = "the syncframe index does not point at a syncframe (offset " +
std::to_string(file_offset) + ")";
}
return false;
}
if (!eac3.seek(file_offset)) {
if (error != nullptr) *error = "cannot position the input file";
return false;
}
return true;
}
bool Engine::Impl::start_bed(std::uint64_t source_sample, std::string* error) {
// 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).
// -drc_scale 0 -target_level 0: the bed is taken as stored, without the
// stream's dynrng or target-level metadata being applied by the decoder.
//
// -ss is an input option: the demuxer is positioned on the frame boundary and
// decoding starts there, so a seek costs the same wherever it lands. The price is
// that the bed is carried by a decoder that started at the seek point rather than
// at the start of the file, which is a low-level, noise-like difference from a
// play-through rather than a misalignment: the position stays exact.
std::wstring input_arguments = L"-drc_scale 0 -target_level 0";
const std::wstring offset = seek_time(source_sample);
if (!offset.empty()) input_arguments += L" -ss " + offset;
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 -";
return bed.start(settings.ffmpeg_path, input_path, input_arguments, bed_arguments, "bed",
stderr_path_for(L"joc_ffmpeg_bed.log"), error);
}
bool Engine::Impl::start_metadata(std::string* error) {
// Stream copy from the start of the track: the syncframes arrive byte for byte
// as they are stored, which is what the JOC metadata needs, and a seek then
// discards whole syncframes from the front (see metadata_skip) rather than
// asking ffmpeg to position the stream.
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 -";
return eac3_pipe.start(settings.ffmpeg_path, input_path, std::wstring(), stream_arguments,
"metadata", stderr_path_for(L"joc_ffmpeg_stream.log"), error,
1u << 20);
}
Engine::Engine() : impl_(new Impl()) {}
Engine::~Engine() {
@@ -475,21 +716,30 @@ Engine::~Engine() {
unsigned Engine::channels() const { return impl_->channels; }
void Engine::set_abort_check(std::function<bool()> check) { impl_->abort_check = std::move(check); }
void Engine::set_length(double seconds) { impl_->end_sample = sample_of_seconds(seconds); }
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_pipe.stop();
impl.eac3.close();
impl.eac3_from_pipe = false;
impl.stop_inputs();
impl.reset_feed_state();
}
bool Engine::start(const std::string& input_path, const Settings& settings, std::string* error) {
Impl& impl = *impl_;
// initialize() may be called more than once on the same instance, and each call
// has the renderer and its inputs start from scratch.
stop();
impl.settings = settings;
impl.input_path = input_path;
impl.reset_index();
impl.reset_feed_state();
impl.end_sample = sample_of_seconds(settings.length_seconds);
impl.eac3_buffer.resize(kEac3Chunk);
impl.bed_buffer.resize(kBedFramesChunk * kBedChannels);
@@ -513,6 +763,12 @@ bool Engine::start(const std::string& input_path, const Settings& settings, std:
if (error != nullptr) *error = "cannot open the input file";
return false;
}
// A tag area in front of the stream is skipped here rather than by the
// renderer, which rejects a stream that does not begin on a syncword.
if (impl.index_base != 0 && !impl.eac3.seek(impl.index_base)) {
if (error != nullptr) *error = "cannot skip the leading tag area";
return false;
}
}
joc_stream_config config{};
@@ -590,51 +846,89 @@ bool Engine::start(const std::string& input_path, const Settings& settings, std:
// 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(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(name)
: text.substr(0, slash) + L"\\" + name;
};
if (!impl.start_bed(0, error)) return false;
if (impl.eac3_from_pipe && !impl.start_metadata(error)) return false;
return true;
}
// 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).
// -drc_scale 0 -target_level 0: the bed is taken as stored, without the stream's
// dynrng or target-level metadata being applied by the decoder.
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, L"-drc_scale 0 -target_level 0", bed_arguments, "bed",
stderr_path_for(L"joc_ffmpeg_bed.log"), error)) {
bool Engine::seek(double seconds, double total_seconds, std::string* error) {
Impl& impl = *impl_;
if (impl.stream == nullptr || impl.api.reset == nullptr) {
if (error != nullptr) *error = "the renderer is not running";
return false;
}
// Where the stream that is about to be delivered starts:
//
// * the frame the target sits in, minus a pre-roll, so the renderer's own state
// -- object timeline, matrix interpolation, room tail -- has converged by the
// time the target itself is delivered. Both inputs restart there, and the bed
// is positioned with an input seek, which is what keeps the cost of a seek
// independent of where it lands;
// * the samples in front of the target are then dropped from the output, which is
// what makes the delivery start exactly on the requested sample.
const std::int64_t target = sample_of_seconds(seconds);
std::int64_t wanted = target - static_cast<std::int64_t>(impl.settings.pipeline_delay_samples);
if (wanted < 0) wanted = 0;
const std::uint64_t target_frame = static_cast<std::uint64_t>(wanted) / kFrameSamples;
const std::uint64_t preroll = impl.settings.seek_preroll_frames;
const std::uint64_t frame = (target_frame > preroll) ? (target_frame - preroll) : 0;
const std::uint64_t first_sample = frame * kFrameSamples;
const std::uint64_t skip = static_cast<std::uint64_t>(wanted) - first_sample;
impl.stop_inputs();
impl.reset_feed_state();
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, L"", stream_arguments, "metadata",
stderr_path_for(L"joc_ffmpeg_stream.log"), error,
1u << 20)) {
return false;
// A container's frame count is not known before it is decoded, so the
// caller's duration is what says whether this lands past the end.
if (total_seconds > 0.0 && seconds >= total_seconds) {
impl.at_end = true;
joc_log::line("engine: seek %.6f s is at or past the end (%.6f s)", seconds,
total_seconds);
}
} else if (!impl.position_bare(frame, error)) {
return false;
}
if (!impl.at_end) {
if (!impl.start_bed(first_sample, error)) return false;
if (impl.eac3_from_pipe) {
if (!impl.start_metadata(error)) return false;
impl.metadata_skip = frame;
}
impl.seek_skip = skip;
impl.start_sample = static_cast<std::uint64_t>(wanted);
impl.delivered = 0;
}
// joc_stream_reset keeps the renderer and its HRTF field: it drops the whole
// timeline, gain ramps, room tail and filter-bank history, which is exactly
// what a restart at another position needs.
const joc_error reset = impl.api.reset(impl.stream);
if (reset != JOC_OK) {
if (error != nullptr) {
*error = std::string("cannot reset the render stream: ") +
error_text(impl.api, reset);
}
return false;
}
joc_log::line("engine: seek %.6f s -> source sample %lld, frames %llu.. from sample %llu, "
"drop %llu sample(s)%s",
seconds, static_cast<long long>(target),
static_cast<unsigned long long>(frame),
static_cast<unsigned long long>(first_sample),
static_cast<unsigned long long>(impl.seek_skip),
impl.at_end ? " (at end)" : "");
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;
// A seek at or past the end of the file succeeds and leaves the next read to
// report end of stream.
if (impl.at_end) return 0;
const bool trace = impl.trace_count < 6;
++impl.read_calls;
if ((impl.read_calls % 50u) == 0u) {
@@ -672,10 +966,36 @@ std::size_t Engine::read(float* destination, std::size_t frames, std::string* er
// frame of the file unrendered.
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;
std::size_t total = impl.eac3_carry + got;
if (total == 0) {
impl.eac3_eof = true;
} else {
// Syncframes in front of a seek target are thrown away before
// anything is interpreted. They are dropped a whole window at a
// time, so only as much of the stream is read as the skip needs.
if (impl.metadata_skip != 0) {
std::size_t dropped = 0;
while (impl.metadata_skip != 0) {
const std::size_t bytes =
joc_eac3::frame_bytes_at(impl.eac3_buffer.data(), total, dropped);
if (bytes == 0 || dropped + bytes > total) break;
dropped += bytes;
--impl.metadata_skip;
}
if (dropped != 0) {
total -= dropped;
std::memmove(impl.eac3_buffer.data(),
impl.eac3_buffer.data() + dropped, total);
}
if (impl.metadata_skip != 0) {
// The window ended inside the part to be skipped: keep what
// is left and come back with the next read.
impl.eac3_carry = total;
if (got == 0) impl.eac3_eof = true;
continue;
}
}
std::size_t offset = 0;
std::uint64_t complete = 0;
while (true) {
@@ -804,12 +1124,37 @@ std::size_t Engine::read(float* destination, std::size_t frames, std::string* er
return 0;
}
if (produced != 0u) {
std::size_t count = produced;
if (impl.seek_skip != 0) {
// The renderer had to be fed from before the seek target, so the
// samples it produces for that part are dropped before anything
// reaches the caller: the first sample handed over is the target.
const std::size_t drop = (impl.seek_skip < count)
? static_cast<std::size_t>(impl.seek_skip)
: count;
impl.seek_skip -= drop;
count -= drop;
if (count != 0) {
std::memmove(destination, destination + drop * impl.channels,
count * impl.channels * sizeof(float));
}
if (count == 0) continue; // the whole pull was pre-roll
}
// The renderer's binaural room tail is not part of the file: the stream
// ends where the file ends, not later.
if (impl.end_sample != 0) {
const std::uint64_t from = impl.start_sample + impl.delivered;
if (from >= impl.end_sample) return 0;
const std::uint64_t left = impl.end_sample - from;
if (left < count) count = static_cast<std::size_t>(left);
}
impl.delivered += count;
if (trace) {
++impl.trace_count;
joc_log::line("engine: pulled %u frame(s) on the %u%s attempt", produced,
joc_log::line("engine: pulled %u frame(s) on the %u%s attempt", count,
impl.trace_count, impl.trace_count == 1 ? "st" : "th");
}
return produced;
return count;
}
// Nothing more can arrive once the metadata stream is drained and the bed
+51 -2
View File
@@ -19,10 +19,29 @@
#include <cstddef>
#include <cstdint>
#include <functional>
#include <string>
namespace joc_decode {
// Delay of the rendering pipeline itself, in output samples: in a run that began
// at source sample 0, output sample k carries the rendering of source sample
// k - this value. A seek starts the inputs this much earlier and drops the
// samples before the target. Measured as 0: the renderer's own filter-bank
// latency and its metadata delay are inside the renderer, not delays of the
// delivered stream. It stays a named, overridable constant so the measurement can
// be repeated.
constexpr std::uint32_t kJocSeekPipelineDelaySamples = 0;
// Syncframes fed before the frame the target sits in. The renderer's state is
// rebuilt from the frames it is given, so a restart needs a moment to converge and
// the delivered part has to be past that. Two things converge at different rates:
// the metadata state (object positions, matrix interpolation, gain ramps), which
// the measured 3008-sample window covers, and the binaural room tail, which is
// recursive and can only be approached -- two syncframes are enough for the speaker
// path, and the tail keeps improving with more, which is why this is 64.
constexpr std::uint32_t kJocSeekPrerollFrames = 64;
enum class Output {
kBinaural = 0, // 2 channels, HRTF rendering
kSpeaker = 1, // N channels, named layout
@@ -57,9 +76,22 @@ struct Settings {
std::uint32_t binaural_mode = 3; // JOC_BINAURAL_MID
double gain_db = 0.0;
double tail_seconds = 5.0;
// Duration of the file, in seconds. The renderer ends a binaural stream with a
// room tail, which would be played as time the file does not have; the delivered
// stream is cut at exactly this much audio instead. Zero means "no limit".
double length_seconds = 0.0;
std::uint32_t object_delay_samples = 1473;
std::uint32_t native_threads = 0;
std::string ffmpeg_path = "ffmpeg";
// Bytes in front of the first syncframe -- a leading ID3v2 tag. The renderer
// rejects a stream that does not begin on a syncword, so both the walk and the
// feed start here.
std::uint64_t stream_start_bytes = 0;
// Samples of pipeline delay a seek compensates for, and syncframes it pre-rolls
// before the target: the calibrated constants above, unless the comparison
// harness overrides them to measure those constants.
std::uint32_t pipeline_delay_samples = kJocSeekPipelineDelaySamples;
std::uint32_t seek_preroll_frames = kJocSeekPrerollFrames;
// 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.
@@ -94,8 +126,10 @@ struct FileProbe {
};
// 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);
// does pointer arithmetic over the stream, no decoding, no HRTF work. The walk
// starts at `start_offset`, which is where the stream begins behind a tag area.
FileProbe probe_file(const std::string& path, std::size_t max_scan_bytes = 0,
std::uint64_t start_offset = 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,
@@ -118,6 +152,21 @@ public:
unsigned channels() const;
void stop();
// Duration of the file, in seconds, for a caller that learns it after start()
// (reported duration of the track); zero means "no limit".
void set_length(double seconds);
// Repositions so that the next sample read() returns is the rendering of source
// sample round(seconds * 48000): the inputs restart at the syncframe holding
// that sample and the output before it is dropped. Seeking at or past
// `total_seconds` (0 when the duration is unknown) succeeds and makes the next
// read() return 0, as the decoder contract requires. start() must have run.
bool seek(double seconds, double total_seconds, std::string* error);
// Polled while the syncframe index is being walked, so a long seek stays
// interruptible; returning false aborts the seek.
void set_abort_check(std::function<bool()> check);
private:
struct Impl;
Impl* impl_;
+1 -1
View File
@@ -12,7 +12,7 @@
// Kept in one place: the string reported to foobar2000 and written to the log
// must not drift apart.
#define JOC_VERSION "0.1.0"
#define JOC_VERSION "0.2.0"
DECLARE_COMPONENT_VERSION("JOC decoder (E-AC-3 JOC)", JOC_VERSION,
"Plays E-AC-3 JOC (Dolby Atmos) files: the JOC objects are "