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
+38 -1
View File
@@ -1,12 +1,14 @@
name: build
# Builds both architectures and publishes the two installable component packages.
# Builds both architectures, publishes the two installable component packages as
# workflow artifacts, and — when a tag is pushed — turns them into a GitHub release.
# The SDK is fetched from foobar2000.org at build time (tools/setup_sdk.ps1) and the
# renderer sources are part of this repository (kernel/), so nothing else is needed.
on:
push:
branches: [ main, master ]
tags: [ 'v*' ]
pull_request:
workflow_dispatch:
@@ -20,6 +22,16 @@ jobs:
shell: pwsh
run: ./tools/setup_sdk.ps1 -TargetVersion 80
- name: Check the tag against the component version
if: startsWith(github.ref, 'refs/tags/')
shell: pwsh
run: |
$declared = (Select-String -Path src/main.cpp -Pattern '#define JOC_VERSION "([^"]+)"').Matches[0].Groups[1].Value
$tag = '${{ github.ref_name }}'
if ($tag -ne "v$declared") {
throw "the tag $tag does not match JOC_VERSION $declared; the release would be mislabelled"
}
- name: Build both architectures and package them
shell: pwsh
run: ./tools/package.ps1
@@ -41,3 +53,28 @@ jobs:
name: foo_input_joc-x64
path: dist/*-x64.fb2k-component
if-no-files-found: error
release:
# A pushed tag is what makes a release; branch builds only leave artifacts.
if: startsWith(github.ref, 'refs/tags/')
needs: windows
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Collect the packaged components
uses: actions/download-artifact@v4
with:
path: dist
merge-multiple: true
- name: List what will be published
run: ls -l dist
- name: Create the release for this tag
uses: softprops/action-gh-release@v2
with:
name: ${{ github.ref_name }}
files: dist/*.fb2k-component
fail_on_unmatched_files: true
generate_release_notes: true
+37 -8
View File
@@ -9,16 +9,26 @@ install beside `foo_input_joc.dll`.
## What it does
1. Reads the E-AC-3 syncframes and decides from the bitstream whether the file really
carries JOC (an EMDF container holding both the OAMD and the JOC payload).
2. A file without JOC is handed back to foobar2000 with `exception_io_unsupported_format`,
so the built-in E-AC-3 decoder plays it — this component never decodes plain E-AC-3.
3. A JOC file is decoded as: the syncframes go to the renderer as metadata, the 5.1 core
1. Finds the audio: a bare `.eac3` / `.ec3` stream is read as it is, while a container
(`.mp4`, `.m4a`, `.m4b`, `.m4p`, `.m4r`, `.mov`, `.mkv`, `.mka`, `.webm`) is looked into
first — the container's own headers say whether an E-AC-3 track is present and which
audio track it is (MP4 sample entry `ec-3`, Matroska `CodecID A_EAC3`), and ffmpeg then
copies that track out of the file byte for byte. The header walk is bounded and cheap, so
an MP4 holding AAC is declined without starting anything.
2. Decides from the bitstream whether it really carries JOC (an EMDF container holding both
the OAMD and the JOC payload — container metadata only ever says "E-AC-3", and the JOC
flag inside it is frequently missing).
3. A file with no E-AC-3 track, or with one that carries no JOC, is handed back to
foobar2000 with `exception_io_unsupported_format`, so the built-in decoder plays it — this
component never decodes plain AC-3 or E-AC-3.
4. A JOC file is decoded as: the syncframes go to the renderer as metadata, the 5.1 core
PCM comes from ffmpeg, and the renderer pairs them (one syncframe : 1536 bed samples)
and produces the output PCM, which is handed back to foobar2000.
```
.eac3 file
.eac3 / .ec3 file container (.mp4 .mkv .m4a ...)
│ ├─ header walk (src/container_scan.cpp) ── no E-AC-3 ──▶ next decoder
│ └─ E-AC-3 track ── ffmpeg -c:a copy ──▶ syncframes
├─ JOC check (src/eac3_scan.cpp) ─── no JOC ──▶ built-in E-AC-3 decoder
└─ JOC
├─ syncframes ────────────────────▶ renderer metadata
@@ -34,12 +44,13 @@ install beside `foo_input_joc.dll`.
|---|---|
| `kernel/` | Copy of the `joc_core` C++ sources (`include/` + `src/`) and `joc_kernel.vcxproj`, the static library the component links |
| `src/eac3_scan.*` | Syncframe walk and the JOC bitstream test |
| `src/container_scan.*` | Bounded header walk of MP4/MOV and Matroska: is there an E-AC-3 track, which one, and how long is the file |
| `src/joc_decode.*` | Decode engine: starts ffmpeg, drives the renderer, handles the end of stream. No foobar2000 headers, so it also builds into the offline tools |
| `src/input_joc.cpp` | The foobar2000 input: format recognition, yielding, `get_info`, `initialize`, `run` |
| `src/settings.*` | Configuration values and their environment overrides (development only) |
| `src/prefs.cpp`, `src/prefs.rc` | The preferences page |
| `src/log.*` | Diagnostic log written next to the DLL |
| `tests/` | Offline tools: bitstream self-test and cross-check against the renderer, render harness, preferences-page layout check |
| `tests/` | Offline tools: bitstream self-test and cross-check against the renderer, render harness, preferences-page layout check, container-probe check |
| `tools/` | SDK fetch, build, package, deploy, unattended test bed run |
## Build
@@ -70,6 +81,21 @@ Always let foobar2000 exit through `/exit`; a force-killed instance leaves a
`<profile>\running` marker behind and the next start then refuses to load any user
component.
### Containers need one look at the decoder list
foobar2000 tries the decoders in the order shown in Preferences → **Decoding** (the
"list of available decoders", where entries can be moved up and down). The built-in
container readers are in that list too, and when one of them is offered an MP4 or Matroska
file before this component, it takes the file and the JOC objects are lost — the file plays
as plain E-AC-3.
So, to play JOC from a container, move **JOC decoder (E-AC-3 JOC)** above **foobar2000 MP4
Demuxer** and **foobar2000 Matroska/WebM Reader** in that list. Nothing else is needed, and
bare `.eac3` / `.ec3` files are unaffected by the order. This is the same thing every
third-party decoder (the FFmpeg wrapper, for one) asks for, which is why the component does
not try to work around it. If a container still plays as plain E-AC-3, that list is where to
look.
## Settings
Preferences → Tools → **JOC decoder**:
@@ -101,7 +127,10 @@ Development only: they override the stored settings for one run and every use is
## Known limitations
* ADM BWF output is not implemented.
* Containers (`.m4a`, `.mkv`) are not claimed: only bare `.eac3` / `.ec3` streams.
* A container is only claimed when this component is ahead of the built-in container reader
in Preferences → Decoding, as described under [Install](#install); the core does not let a
decoder ask for a file another entry has already taken.
* Transport streams (`.ts`, `.m2ts`) are not claimed.
* The room tail is returned in full; the reference command-line renderer additionally trims
trailing samples below a threshold, so its output can be shorter.
* x86 and x64 do not produce bit-identical binaural output (last-bit differences): the
+19
View File
@@ -113,3 +113,22 @@ disabled, which is what "the option is there but cannot be clicked" otherwise lo
Not verified here: how the page and the `%joc_*%` fields look on screen; that needs a human
in front of the window.
## Container support (mp4 / m4a / mov / mkv / mka / webm)
Verified with the Win32 build, a 5.1 speaker layout, and the component installed in a
portable foobar2000 1.6.19 profile:
- `ffmpeg -i <bare .eac3> -c:a copy` muxed into MP4 and Matroska, then `-map 0:a:0 -c:a copy
-f eac3` extracted again, is byte-identical to a direct `-t 30 -c:a copy` of the source
(same SHA-256) — the renderer therefore sees the stored syncframes, not a re-encode.
- The header probe (`tests/container_scan_test.cpp`, no foobar2000 involved) reports, for the
same material: `joc.mp4 -> mp4 eac3=1 audio#0 codec=ec-3 30.016 s`,
`joc.mkv -> matroska eac3=1 audio#0 codec=A_EAC3 30.016 s`, `plain_eac3.mp4 -> ec-3`,
`ac3.mp4 -> ac-3 (declined)`, `aac.mp4 -> mp4a (declined)`.
- End to end, with the component ordered ahead of the container reader in
Preferences -> Decoding: `open()` is called, the track is found, the JOC verdict is positive,
the file is claimed, and playback reaches `end of stream`.
- Negative case end to end: an MP4 holding E-AC-3 without JOC yields with
`E-AC-3 track 0 carries no JOC`, and the built-in decoder plays it.
- Bare `.eac3` / `.ec3` handling is unchanged.
+2
View File
@@ -95,6 +95,7 @@
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="src\container_scan.cpp" />
<ClCompile Include="src\eac3_scan.cpp" />
<ClCompile Include="src\input_joc.cpp" />
<ClCompile Include="src\joc_decode.cpp" />
@@ -105,6 +106,7 @@
</ItemGroup>
<ItemGroup>
<ClInclude Include="src\container_scan.h" />
<ClInclude Include="src\eac3_scan.h" />
<ClInclude Include="src\joc_decode.h" />
<ClInclude Include="src\joc_pch.h" />
+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
+31
View File
@@ -0,0 +1,31 @@
// Exercises the container probe directly, without going through foobar2000.
//
// container_scan_test <file> [more files...]
//
// It exists because container dispatch is decided by the core's decoder priority
// table: when a built-in demuxer is offered the file first, this component never
// sees it, so the probe would otherwise never run during a test. Running it here
// shows what the probe decides for each file on its own.
#include <cstdio>
#include <string>
#include "../src/container_scan.h"
int main(int argc, char** argv) {
if (argc < 2) {
std::fprintf(stderr, "usage: container_scan_test <file> [more files...]\n");
return 2;
}
int failures = 0;
for (int i = 1; i < argc; ++i) {
const std::string path = argv[i];
const joc_container::Result result = joc_container::scan(path);
std::printf("%-28s kind=%-9s eac3=%d audio#%u codec=%-6s duration=%8.3f s %s\n",
path.c_str(), joc_container::kind_name(result.kind), result.eac3 ? 1 : 0,
result.audio_index, result.codec.empty() ? "-" : result.codec.c_str(),
result.duration_seconds, result.detail.c_str());
if (result.kind == joc_container::Kind::kNone) ++failures;
}
return failures == 0 ? 0 : 1;
}
+2 -1
View File
@@ -39,10 +39,11 @@ $lines = @(
"call `"$vcvars`" >nul",
"cd /d `"$projectRoot`"",
# log.cpp comes along because the engine writes its diagnostics through it.
"cl $common /Fe:`"$outDir\container_scan_test.exe`" /Fo:`"$outDir\\`" tests\container_scan_test.cpp src\container_scan.cpp src\log.cpp",
"cl $common /Fe:`"$outDir\scan_selftest.exe`" /Fo:`"$outDir\\`" tests\scan_selftest.cpp src\eac3_scan.cpp",
"cl $common /Fe:`"$outDir\prefs_layout_check.exe`" /Fo:`"$outDir\\`" tests\prefs_layout_check.cpp user32.lib gdi32.lib",
"cl $common /Fe:`"$outDir\scan_crosscheck.exe`" /Fo:`"$outDir\\`" tests\scan_crosscheck.cpp src\eac3_scan.cpp `"$coreLib`" shell32.lib",
"cl $common /Fe:`"$outDir\render_harness.exe`" /Fo:`"$outDir\\`" tests\render_harness.cpp src\joc_decode.cpp src\eac3_scan.cpp src\log.cpp `"$coreLib`" shell32.lib",
"cl $common /Fe:`"$outDir\render_harness.exe`" /Fo:`"$outDir\\`" tests\render_harness.cpp src\joc_decode.cpp src\eac3_scan.cpp src\container_scan.cpp src\log.cpp `"$coreLib`" shell32.lib",
'if errorlevel 1 exit /b 1',
'exit /b 0'
)