diff --git a/CLAUDE.md b/CLAUDE.md index 6fd6411..1bd47f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,8 +78,14 @@ Install our driver: `scripts\install-driver.ps1 -EnableTestSigning` (elevated) `--list` (endpoints) · `--probe` (AC3 support per output) · `--mon` (capture throughput diag) · `--loopback` · `--duration N` · `--in/--out` (name substr) · `--in-id/--out-id` · `--out-spdif` · `--bitrate ` · `--safe ` · `--config ` · `--hidden` (hide console) · -`--log `. Config precedence: defaults < config file (`virtual-ac3-encoder.conf` next to the -exe; keys `in/out/in_id/out_id/bitrate/safe/loopback/out_spdif`) < CLI. +`--log ` · `--upmix off|surround` (stereo->5.1 via FFmpeg `surround` filter). Config +precedence: defaults < config file (`virtual-ac3-encoder.conf` next to the exe; keys +`in/out/in_id/out_id/bitrate/safe/loopback/out_spdif/upmix`) < CLI. + +**Surround upmix:** `SpdifEncoder` runs an FFmpeg `surround` libavfilter graph (abuffer → surround → +aformat → abuffersink) for <=2ch input when `upmix=surround`, accumulating output in an `AVAudioFifo` +and priming with silence (FFT latency) so the realtime consumer doesn't starve. Needs the `avfilter` +lib (linked in CMake). `log` is a VBScript reserved word — unrelated, but note prior gotchas list. ## Autostart ("set and forget") `scripts/setup-autostart.ps1` stages the engine to `%LOCALAPPDATA%\virtual-ac3-encoder` (+ FFmpeg & diff --git a/README.md b/README.md index d7cfb2d..4f8eadc 100644 --- a/README.md +++ b/README.md @@ -80,9 +80,11 @@ the same engine Kodi uses internally. See `third_party/reference/` for the clone - `--bitrate ` (default 640000) / `--safe ` (drift target, default 1536) - `--config ` (defaults to `virtual-ac3-encoder.conf` next to the exe) · `--hidden` (hide console) · `--log ` (log to file) · `--duration ` (auto-stop) +- `--upmix off|surround` — for stereo input, upmix to 5.1 via FFmpeg's `surround` filter + (a free DTS Neo:PC / Pro Logic II-style matrix upmix). Multichannel input is downmixed regardless. Config precedence: built-in defaults < config file (`key=value`: `in`, `out`, `in_id`, `out_id`, -`bitrate`, `safe`, `loopback`, `out_spdif`) < command-line flags. +`bitrate`, `safe`, `loopback`, `out_spdif`, `upmix`) < command-line flags. ## Driver (Phase 3) diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index 0067694..35b2d5c 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -22,7 +22,7 @@ endif() add_library(ffmpeg INTERFACE) target_include_directories(ffmpeg INTERFACE "${FFMPEG_INC}") -foreach(_l avcodec avformat avutil swresample) +foreach(_l avcodec avformat avfilter avutil swresample) find_library(FF_${_l} NAMES ${_l} PATHS "${FFMPEG_LIB}" NO_DEFAULT_PATH REQUIRED) target_link_libraries(ffmpeg INTERFACE "${FF_${_l}}") endforeach() diff --git a/engine/src/Config.h b/engine/src/Config.h index 0dd22c1..e5d63ef 100644 --- a/engine/src/Config.h +++ b/engine/src/Config.h @@ -24,4 +24,8 @@ struct Config int64_t bitRate = 640000; uint32_t safeFrames = 1536; + + // Stereo->5.1 upmix mode for <=2ch input: "off" (swr default) or "surround" + // (FFmpeg `surround` FFT upmix). Multichannel input is always downmixed to 5.1. + std::string upmix = "off"; }; diff --git a/engine/src/SpdifEncoder.cpp b/engine/src/SpdifEncoder.cpp index 8e9d19e..97a4e2a 100644 --- a/engine/src/SpdifEncoder.cpp +++ b/engine/src/SpdifEncoder.cpp @@ -4,6 +4,7 @@ #include #include +#include extern "C" { #include @@ -32,6 +33,7 @@ bool SpdifEncoder::Init(const Params& p) av_channel_layout_copy(&inLayout_, &p.inLayout); inSampleFmt_ = p.inSampleFmt; + sampleRate_ = p.sampleRate; nextPts_ = 0; const AVCodec* codec = avcodec_find_encoder(AV_CODEC_ID_AC3); @@ -68,27 +70,35 @@ bool SpdifEncoder::Init(const Params& p) framesPerPacket_ = codecCtx_->frame_size; // 1536 for AC3 - // swr: interleaved input (any layout) -> planar-float 5.1 (the encoder layout). - // Same sample rate in/out, so swr only converts sample format + channel layout - // (a downmix when input has > 5.1 channels). - rc = swr_alloc_set_opts2(&swr_, - &codecCtx_->ch_layout, AV_SAMPLE_FMT_FLTP, p.sampleRate, - &inLayout_, inSampleFmt_, p.sampleRate, - 0, nullptr); - if (rc < 0 || !swr_) { LogAv("swr_alloc_set_opts2", rc); return false; } - rc = swr_init(swr_); - if (rc < 0) { LogAv("swr_init", rc); return false; } - - // Scratch frame holding the converted planar samples handed to the encoder. + // Scratch frame holding the planar-float 5.1 samples handed to the encoder. Both the swr + // path and the surround-filter path fill this. frame_ = av_frame_alloc(); if (!frame_) return false; frame_->format = AV_SAMPLE_FMT_FLTP; av_channel_layout_copy(&frame_->ch_layout, &codecCtx_->ch_layout); - frame_->sample_rate = p.sampleRate; + frame_->sample_rate = sampleRate_; frame_->nb_samples = framesPerPacket_; rc = av_frame_get_buffer(frame_, 0); if (rc < 0) { LogAv("av_frame_get_buffer", rc); return false; } + // Input conditioning: either the `surround` upmix filter (stereo->5.1) or plain swr + // convert/downmix. Surround only applies to <= 2ch input; multichannel always downmixes. + useFilter_ = (p.upmix == Upmix::Surround && inLayout_.nb_channels <= 2); + if (useFilter_ && !BuildFilterGraph()) + { + std::fprintf(stderr, "[SpdifEncoder] surround upmix unavailable; using plain upmix/downmix\n"); + useFilter_ = false; + } + if (!useFilter_) + { + // swr: interleaved input -> planar-float 5.1 (encoder layout), same sample rate. + rc = swr_alloc_set_opts2(&swr_, &codecCtx_->ch_layout, AV_SAMPLE_FMT_FLTP, sampleRate_, + &inLayout_, inSampleFmt_, sampleRate_, 0, nullptr); + if (rc < 0 || !swr_) { LogAv("swr_alloc_set_opts2", rc); return false; } + rc = swr_init(swr_); + if (rc < 0) { LogAv("swr_init", rc); return false; } + } + pkt_ = av_packet_alloc(); if (!pkt_) return false; @@ -131,6 +141,11 @@ void SpdifEncoder::Close() avformat_free_context(muxer_); muxer_ = nullptr; } + if (graph_) avfilter_graph_free(&graph_); // also frees fsrc_ / fsink_ + fsrc_ = fsink_ = nullptr; + if (fifo_) { av_audio_fifo_free(fifo_); fifo_ = nullptr; } + if (filtFrame_) av_frame_free(&filtFrame_); + useFilter_ = false; if (swr_) swr_free(&swr_); if (frame_) av_frame_free(&frame_); if (pkt_) av_packet_free(&pkt_); @@ -140,6 +155,104 @@ void SpdifEncoder::Close() nextPts_ = 0; } +bool SpdifEncoder::BuildFilterGraph() +{ + graph_ = avfilter_graph_alloc(); + if (!graph_) return false; + + char inDesc[64] = {0}, outDesc[64] = {0}; + av_channel_layout_describe(&inLayout_, inDesc, sizeof inDesc); + av_channel_layout_describe(&codecCtx_->ch_layout, outDesc, sizeof outDesc); + + const AVFilter* abuffer = avfilter_get_by_name("abuffer"); + const AVFilter* surround = avfilter_get_by_name("surround"); + const AVFilter* aformat = avfilter_get_by_name("aformat"); + const AVFilter* abuffersink = avfilter_get_by_name("abuffersink"); + if (!abuffer || !surround || !aformat || !abuffersink) + { + std::fprintf(stderr, "[SpdifEncoder] a required avfilter is missing (surround/aformat/abuffer)\n"); + return false; + } + + char srcArgs[256]; + std::snprintf(srcArgs, sizeof srcArgs, + "sample_rate=%d:sample_fmt=%s:channel_layout=%s:time_base=1/%d", + sampleRate_, av_get_sample_fmt_name(inSampleFmt_), inDesc, sampleRate_); + char surArgs[128]; + std::snprintf(surArgs, sizeof surArgs, "chl_in=%s:chl_out=%s", inDesc, outDesc); + char fmtArgs[256]; + std::snprintf(fmtArgs, sizeof fmtArgs, "sample_fmts=fltp:sample_rates=%d:channel_layouts=%s", + sampleRate_, outDesc); + + AVFilterContext* surCtx = nullptr; + AVFilterContext* fmtCtx = nullptr; + int rc; + if ((rc = avfilter_graph_create_filter(&fsrc_, abuffer, "in", srcArgs, nullptr, graph_)) < 0) + { LogAv("create abuffer", rc); return false; } + if ((rc = avfilter_graph_create_filter(&surCtx, surround, "surround", surArgs, nullptr, graph_)) < 0) + { LogAv("create surround", rc); return false; } + if ((rc = avfilter_graph_create_filter(&fmtCtx, aformat, "aformat", fmtArgs, nullptr, graph_)) < 0) + { LogAv("create aformat", rc); return false; } + if ((rc = avfilter_graph_create_filter(&fsink_, abuffersink, "out", nullptr, nullptr, graph_)) < 0) + { LogAv("create abuffersink", rc); return false; } + + if ((rc = avfilter_link(fsrc_, 0, surCtx, 0)) < 0 || + (rc = avfilter_link(surCtx, 0, fmtCtx, 0)) < 0 || + (rc = avfilter_link(fmtCtx, 0, fsink_, 0)) < 0) + { LogAv("avfilter_link", rc); return false; } + + if ((rc = avfilter_graph_config(graph_, nullptr)) < 0) + { LogAv("avfilter_graph_config", rc); return false; } + + fifo_ = av_audio_fifo_alloc(AV_SAMPLE_FMT_FLTP, codecCtx_->ch_layout.nb_channels, sampleRate_); + filtFrame_ = av_frame_alloc(); + if (!fifo_ || !filtFrame_) return false; + + // Prime with silence to warm the FFT and build a >= 2-packet cushion, so the real-time + // consumer doesn't starve mid-stream (the filter has inherent FFT latency). + const int inBytes = + framesPerPacket_ * inLayout_.nb_channels * av_get_bytes_per_sample(inSampleFmt_); + std::vector silence(static_cast(inBytes), 0); + for (int guard = 0; guard < 64 && av_audio_fifo_size(fifo_) < 2 * framesPerPacket_; ++guard) + if (!FeedFilter(silence.data())) + break; + + std::printf("[SpdifEncoder] surround upmix enabled (%s -> %s)\n", inDesc, outDesc); + return true; +} + +bool SpdifEncoder::FeedFilter(const uint8_t* in) +{ + const int inBytes = + framesPerPacket_ * inLayout_.nb_channels * av_get_bytes_per_sample(inSampleFmt_); + + AVFrame* f = av_frame_alloc(); + if (!f) return false; + f->format = inSampleFmt_; + av_channel_layout_copy(&f->ch_layout, &inLayout_); + f->sample_rate = sampleRate_; + f->nb_samples = framesPerPacket_; + if (av_frame_get_buffer(f, 0) < 0) { av_frame_free(&f); return false; } + std::memcpy(f->data[0], in, static_cast(inBytes)); + + int rc = av_buffersrc_add_frame(fsrc_, f); // takes ownership of f's buffers, resets f + av_frame_free(&f); + if (rc < 0) { LogAv("av_buffersrc_add_frame", rc); return false; } + + for (;;) + { + rc = av_buffersink_get_frame(fsink_, filtFrame_); + if (rc == AVERROR(EAGAIN) || rc == AVERROR_EOF) + break; + if (rc < 0) { LogAv("av_buffersink_get_frame", rc); return false; } + int w = av_audio_fifo_write(fifo_, reinterpret_cast(filtFrame_->data), + filtFrame_->nb_samples); + av_frame_unref(filtFrame_); + if (w < 0) { LogAv("av_audio_fifo_write", w); return false; } + } + return true; +} + int SpdifEncoder::EncodePacket(const uint8_t* in, uint8_t* outBuf, int outSize) { if (!codecCtx_) @@ -147,18 +260,39 @@ int SpdifEncoder::EncodePacket(const uint8_t* in, uint8_t* outBuf, int outSize) if (outSize < kMaxBytesPerPacket) return -1; - int rc = av_frame_make_writable(frame_); - if (rc < 0) { LogAv("av_frame_make_writable", rc); return -1; } - - // Convert/downmix the interleaved input into the planar-float encoder frame. - const uint8_t* inPlanes[1] = { in }; - int got = swr_convert(swr_, frame_->data, frame_->nb_samples, inPlanes, framesPerPacket_); - if (got < 0) { LogAv("swr_convert", got); return -1; } + if (useFilter_) + { + // Push this input packet through the surround graph, then take one packet of upmixed + // 5.1 from the FIFO. During startup (FFT latency) the FIFO may not yet hold a full + // packet -> return 0 so the caller emits silence until it fills. + if (!FeedFilter(in)) + return -1; + if (av_audio_fifo_size(fifo_) < framesPerPacket_) + return 0; + if (av_frame_make_writable(frame_) < 0) + return -1; + if (av_audio_fifo_read(fifo_, reinterpret_cast(frame_->data), framesPerPacket_) < + framesPerPacket_) + return 0; + } + else + { + if (av_frame_make_writable(frame_) < 0) + return -1; + // Convert/downmix the interleaved input into the planar-float encoder frame. + const uint8_t* inPlanes[1] = { in }; + int got = swr_convert(swr_, frame_->data, frame_->nb_samples, inPlanes, framesPerPacket_); + if (got < 0) { LogAv("swr_convert", got); return -1; } + } frame_->pts = nextPts_; nextPts_ += framesPerPacket_; + return EncodeFrameToBurst(outBuf, outSize); +} - rc = avcodec_send_frame(codecCtx_, frame_); +int SpdifEncoder::EncodeFrameToBurst(uint8_t* outBuf, int outSize) +{ + int rc = avcodec_send_frame(codecCtx_, frame_); if (rc < 0) { LogAv("avcodec_send_frame", rc); return -1; } rc = avcodec_receive_packet(codecCtx_, pkt_); @@ -167,7 +301,6 @@ int SpdifEncoder::EncodePacket(const uint8_t* in, uint8_t* outBuf, int outSize) if (rc < 0) { LogAv("avcodec_receive_packet", rc); return -1; } pkt_->stream_index = 0; - writeDst_ = outBuf; writeCap_ = outSize; writeLen_ = 0; diff --git a/engine/src/SpdifEncoder.h b/engine/src/SpdifEncoder.h index a7ddb90..bb3d43b 100644 --- a/engine/src/SpdifEncoder.h +++ b/engine/src/SpdifEncoder.h @@ -19,6 +19,10 @@ extern "C" { #include #include +#include +#include +#include +#include #include #include } @@ -31,6 +35,14 @@ class SpdifEncoder SpdifEncoder(const SpdifEncoder&) = delete; SpdifEncoder& operator=(const SpdifEncoder&) = delete; + // Stereo->5.1 upmix mode. Only applies when the input has <= 2 channels; multichannel + // input is always downmixed to 5.1 (by swr) regardless. + enum class Upmix + { + Off, // swr default rematrix (front channels only-ish) + Surround, // FFmpeg `surround` libavfilter (FFT-based steered upmix) + }; + struct Params { int sampleRate = 48000; // AC3: 48000 / 44100 / 32000 @@ -38,6 +50,7 @@ class SpdifEncoder AVSampleFormat inSampleFmt = AV_SAMPLE_FMT_FLT; // interleaved input from WASAPI/WAV AVChannelLayout inLayout{}; // caller-owned; copied in Init(). // Any layout; downmixed to 5.1 by swr. + Upmix upmix = Upmix::Off; // stereo->5.1 upmix mode }; // Open the encoder. Returns false (and logs to stderr) on failure. @@ -65,14 +78,27 @@ class SpdifEncoder static int WritePacketThunk(void* opaque, const uint8_t* buf, int buf_size); int OnWritePacket(const uint8_t* buf, int buf_size); + bool BuildFilterGraph(); // surround-upmix path + bool FeedFilter(const uint8_t* in); // push one input packet, drain output into fifo_ + int EncodeFrameToBurst(uint8_t* outBuf, int outSize); // frame_ -> AC3 -> IEC61937 + AVCodecContext* codecCtx_ = nullptr; AVFormatContext* muxer_ = nullptr; SwrContext* swr_ = nullptr; AVFrame* frame_ = nullptr; // planar-float scratch fed to the encoder AVPacket* pkt_ = nullptr; + // surround-upmix pipeline (used when upmix == Surround and input is <= 2ch) + bool useFilter_ = false; + AVFilterGraph* graph_ = nullptr; + AVFilterContext* fsrc_ = nullptr; // abuffer + AVFilterContext* fsink_ = nullptr; // abuffersink + AVAudioFifo* fifo_ = nullptr; // accumulates filtered 5.1 fltp samples + AVFrame* filtFrame_ = nullptr; // scratch for draining the sink + AVChannelLayout inLayout_{}; AVSampleFormat inSampleFmt_ = AV_SAMPLE_FMT_FLT; + int sampleRate_ = 48000; int framesPerPacket_ = 0; int64_t nextPts_ = 0; diff --git a/engine/src/WasapiPassthrough.cpp b/engine/src/WasapiPassthrough.cpp index 82fb15f..a31480a 100644 --- a/engine/src/WasapiPassthrough.cpp +++ b/engine/src/WasapiPassthrough.cpp @@ -101,6 +101,7 @@ bool WasapiPassthrough::Init(IMMDevice* dev, RingBuffer* ring, const CaptureForm ep.sampleRate = rate; ep.bitRate = params_.bitRate; ep.inSampleFmt = inFmt; + ep.upmix = params_.upmixSurround ? SpdifEncoder::Upmix::Surround : SpdifEncoder::Upmix::Off; if (capFmt.channelMask) av_channel_layout_from_mask(&ep.inLayout, capFmt.channelMask); else diff --git a/engine/src/WasapiPassthrough.h b/engine/src/WasapiPassthrough.h index 5bfc353..bc34d3e 100644 --- a/engine/src/WasapiPassthrough.h +++ b/engine/src/WasapiPassthrough.h @@ -26,7 +26,8 @@ class WasapiPassthrough struct Params { int64_t bitRate = 640000; - uint32_t safeFrames = 1536; // target excess frames kept buffered (latency vs. safety) + uint32_t safeFrames = 1536; // target excess frames kept buffered (latency vs. safety) + bool upmixSurround = false; // stereo->5.1 via the `surround` filter (else swr default) }; WasapiPassthrough() = default; diff --git a/engine/src/main.cpp b/engine/src/main.cpp index fc01d7b..a9c33d5 100644 --- a/engine/src/main.cpp +++ b/engine/src/main.cpp @@ -80,6 +80,7 @@ static void LoadConfigFile(const std::string& path, Config& c) else if (k == "safe") c.safeFrames = (uint32_t)std::strtoul(v.c_str(), nullptr, 10); else if (k == "loopback") c.loopback = truthy(v); else if (k == "out_spdif") c.outAutoSpdif = truthy(v); + else if (k == "upmix") c.upmix = v; } std::printf("Loaded config: %s\n", path.c_str()); } @@ -106,6 +107,7 @@ static void ParseArgs(int argc, char** argv, Config& c) else if (a == "--out-spdif") c.outAutoSpdif = true; else if (a == "--bitrate" && i + 1 < argc) c.bitRate = std::strtoll(argv[++i], nullptr, 10); else if (a == "--safe" && i + 1 < argc) c.safeFrames = (uint32_t)std::strtoul(argv[++i], nullptr, 10); + else if (a == "--upmix" && i + 1 < argc) c.upmix = argv[++i]; else std::fprintf(stderr, "ignoring unknown arg: %s\n", a.c_str()); } } @@ -278,6 +280,7 @@ int main(int argc, char** argv) WasapiPassthrough::Params pp; pp.bitRate = cfg.bitRate; pp.safeFrames = cfg.safeFrames; + pp.upmixSurround = (cfg.upmix == "surround"); WasapiPassthrough out; if (!out.Init(outDev.Get(), &ring, cf, pp)) return 1; diff --git a/engine/test/test_encoder.cpp b/engine/test/test_encoder.cpp index 0524888..eff8bed 100644 --- a/engine/test/test_encoder.cpp +++ b/engine/test/test_encoder.cpp @@ -112,3 +112,34 @@ TEST_CASE("init: AC3-invalid sample rate fails cleanly") SpdifEncoder enc; CHECK_FALSE(InitFor(enc, 6, AV_SAMPLE_FMT_FLT, 96000)); // AC3 supports 48/44.1/32k only } + +TEST_CASE("upmix: stereo -> 5.1 via surround filter yields AC3 bursts") +{ + SpdifEncoder::Params p; + p.sampleRate = 48000; + p.inSampleFmt = AV_SAMPLE_FMT_FLT; + p.upmix = SpdifEncoder::Upmix::Surround; + av_channel_layout_default(&p.inLayout, 2); + SpdifEncoder enc; + bool ok = enc.Init(p); + av_channel_layout_uninit(&p.inLayout); + REQUIRE(ok); + CHECK(enc.InChannels() == 2); + + const int fpp = enc.FramesPerPacket(); + std::vector in(static_cast(fpp) * 2, 0.1f); // stereo, non-silent + std::vector out(SpdifEncoder::kMaxBytesPerPacket); + int bursts = 0; + for (int i = 0; i < 30; ++i) + { + int n = enc.EncodePacket(reinterpret_cast(in.data()), out.data(), + static_cast(out.size())); + REQUIRE(n >= 0); + if (n == 6144) + { + if (bursts == 0) CHECK(HasIec61937Sync(out.data())); + ++bursts; + } + } + CHECK(bursts >= 25); // primed FIFO -> ~1 burst per call in steady state +} diff --git a/installer/default.conf b/installer/default.conf index cf153b2..ec113d5 100644 --- a/installer/default.conf +++ b/installer/default.conf @@ -10,3 +10,6 @@ out_spdif=1 bitrate=640000 loopback=0 + +# Upmix stereo input to 5.1 (free DTS Neo:PC-style matrix). off (default) or surround: +#upmix=surround