diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 91b1e20..9692496 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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 diff --git a/README.md b/README.md index 3852527..6fe48b5 100644 --- a/README.md +++ b/README.md @@ -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 `\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 diff --git a/VERIFICATION.md b/VERIFICATION.md index 8a69a48..7ad3d33 100644 --- a/VERIFICATION.md +++ b/VERIFICATION.md @@ -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 -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. diff --git a/foo_input_joc.vcxproj b/foo_input_joc.vcxproj index 7fce79c..740169b 100644 --- a/foo_input_joc.vcxproj +++ b/foo_input_joc.vcxproj @@ -95,6 +95,7 @@ + @@ -105,6 +106,7 @@ + diff --git a/src/container_scan.cpp b/src/container_scan.cpp new file mode 100644 index 0000000..b6b5602 --- /dev/null +++ b/src/container_scan.cpp @@ -0,0 +1,528 @@ +#include "container_scan.h" + +#include + +#include +#include +#include +#include + +#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(text.size()), nullptr, 0); + std::wstring out(static_cast(needed), L'\0'); + MultiByteToWideChar(CP_UTF8, 0, text.c_str(), static_cast(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(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(offset); + if (SetFilePointerEx(handle_, position, nullptr, FILE_BEGIN) == FALSE) return false; + std::size_t done = 0; + auto* target = static_cast(destination); + while (done < bytes) { + const DWORD chunk = static_cast( + (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* 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(p[0]) << 24) | (static_cast(p[1]) << 16) | + (static_cast(p[2]) << 8) | static_cast(p[3]); +} + +std::string fourcc(const std::uint8_t* p) { return std::string(reinterpret_cast(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 +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(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(depth) * 2, "", type.c_str(), static_cast(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 ranges{{0, (std::min)(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( + be32(header + 24)) + << 32) | + be32(header + 28)) + : be32(header + 16); + if (timescale != 0) { + result.duration_seconds = + static_cast(duration) / + static_cast(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(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(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(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(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 data; + if (!file.load(0, static_cast( + (std::min)(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)(end, payload + static_cast(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)( + payload_end, + child_payload + static_cast(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(value); + } else { + const std::uint64_t bits = + (static_cast(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)( + payload_end, + child_payload + static_cast(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(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 \ No newline at end of file diff --git a/src/container_scan.h b/src/container_scan.h new file mode 100644 index 0000000..df73dfd --- /dev/null +++ b/src/container_scan.h @@ -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 +#include + +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 diff --git a/src/input_joc.cpp b/src/input_joc.cpp index accbd19..2e52023 100644 --- a/src/input_joc.cpp +++ b/src/input_joc.cpp @@ -18,6 +18,7 @@ #include #include +#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 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(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( - static_cast(bytes) * 8.0 / probe.duration_seconds / 1000.0)); + static_cast(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(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(probe.frames), - channels, render.c_str()); + info.info_set_int("joc_frames", static_cast(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(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 m_buffer; unsigned m_channels = 2; std::uint64_t m_frames_delivered = 0; diff --git a/src/joc_decode.cpp b/src/joc_decode.cpp index 3cb987a..03a4cb2 100644 --- a/src/joc_decode.cpp +++ b/src/joc_decode.cpp @@ -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 " 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(kBedPipeBytes)) == FALSE) { - if (error != nullptr) *error = "cannot create the core PCM pipe"; + if (CreatePipe(&read_end, &write_end, &attributes, static_cast(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 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(data.nFileSizeHigh) << 32) | data.nFileSizeLow; + modified = (static_cast(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 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(&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) { diff --git a/src/joc_decode.h b/src/joc_decode.h index 0e14d14..53b7fad 100644 --- a/src/joc_decode.h +++ b/src/joc_decode.h @@ -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(); diff --git a/src/main.cpp b/src/main.cpp index 0cca3dc..8fae479 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -8,7 +8,6 @@ #include #include #include - #include "log.h" // Kept in one place: the string reported to foobar2000 and written to the log diff --git a/tests/container_scan_test.cpp b/tests/container_scan_test.cpp new file mode 100644 index 0000000..1462dac --- /dev/null +++ b/tests/container_scan_test.cpp @@ -0,0 +1,31 @@ +// Exercises the container probe directly, without going through foobar2000. +// +// container_scan_test [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 +#include + +#include "../src/container_scan.h" + +int main(int argc, char** argv) { + if (argc < 2) { + std::fprintf(stderr, "usage: container_scan_test [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; +} diff --git a/tools/build_tests.ps1 b/tools/build_tests.ps1 index 1ca3dd2..a3a523d 100644 --- a/tools/build_tests.ps1 +++ b/tools/build_tests.ps1 @@ -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' )