Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <bps>` · `--safe <frames>` · `--config <path>` · `--hidden` (hide console) ·
`--log <path>`. 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 <path>` · `--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 &
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,11 @@ the same engine Kodi uses internally. See `third_party/reference/` for the clone
- `--bitrate <bps>` (default 640000) / `--safe <frames>` (drift target, default 1536)
- `--config <path>` (defaults to `virtual-ac3-encoder.conf` next to the exe) ·
`--hidden` (hide console) · `--log <path>` (log to file) · `--duration <s>` (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)

Expand Down
2 changes: 1 addition & 1 deletion engine/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions engine/src/Config.h
Original file line number Diff line number Diff line change
Expand Up @@ -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";
};
177 changes: 155 additions & 22 deletions engine/src/SpdifEncoder.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

#include <cstdio>
#include <cstring>
#include <vector>

extern "C" {
#include <libavutil/error.h>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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_);
Expand All @@ -140,25 +155,144 @@ 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<uint8_t> silence(static_cast<size_t>(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<size_t>(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<void**>(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_)
return -1;
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<void**>(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_);
Expand All @@ -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;
Expand Down
26 changes: 26 additions & 0 deletions engine/src/SpdifEncoder.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libavfilter/avfilter.h>
#include <libavfilter/buffersink.h>
#include <libavfilter/buffersrc.h>
#include <libavutil/audio_fifo.h>
#include <libavutil/channel_layout.h>
#include <libswresample/swresample.h>
}
Expand All @@ -31,13 +35,22 @@ 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
int64_t bitRate = 640000; // AC3-over-optical maximum
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.
Expand Down Expand Up @@ -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;

Expand Down
1 change: 1 addition & 0 deletions engine/src/WasapiPassthrough.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion engine/src/WasapiPassthrough.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions engine/src/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
Expand All @@ -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());
}
}
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading