diff --git a/src/filter/LoadChain.cxx b/src/filter/LoadChain.cxx
index 67c94e85c..519eb461b 100644
--- a/src/filter/LoadChain.cxx
+++ b/src/filter/LoadChain.cxx
@@ -21,27 +21,29 @@
 #include "Factory.hxx"
 #include "Prepared.hxx"
 #include "plugins/AutoConvertFilterPlugin.hxx"
-#include "plugins/ChainFilterPlugin.hxx"
+#include "plugins/TwoFilters.hxx"
 #include "util/IterableSplitString.hxx"
 
 #include <string>
 
 static void
-filter_chain_append_new(PreparedFilter &chain, FilterFactory &factory,
+filter_chain_append_new(std::unique_ptr<PreparedFilter> &chain,
+			FilterFactory &factory,
 			std::string_view template_name)
 {
 	/* using the AutoConvert filter just in case the specified
 	   filter plugin does not support the exact input format */
 
-	filter_chain_append(chain, template_name,
-			    /* unfortunately, MakeFilter() wants a
-			       null-terminated string, so we need to
-			       copy it here */
-			    autoconvert_filter_new(factory.MakeFilter(std::string(template_name).c_str())));
+	chain = ChainFilters(std::move(chain),
+			     /* unfortunately, MakeFilter() wants a
+				null-terminated string, so we need to
+				copy it here */
+			     autoconvert_filter_new(factory.MakeFilter(std::string(template_name).c_str())),
+			     template_name);
 }
 
 void
-filter_chain_parse(PreparedFilter &chain,
+filter_chain_parse(std::unique_ptr<PreparedFilter> &chain,
 		   FilterFactory &factory,
 		   const char *spec)
 {
diff --git a/src/filter/LoadChain.hxx b/src/filter/LoadChain.hxx
index c0196e22d..c36c63f4c 100644
--- a/src/filter/LoadChain.hxx
+++ b/src/filter/LoadChain.hxx
@@ -20,6 +20,8 @@
 #ifndef MPD_FILTER_LOAD_CHAIN_HXX
 #define MPD_FILTER_LOAD_CHAIN_HXX
 
+#include <memory>
+
 class FilterFactory;
 class PreparedFilter;
 
@@ -35,7 +37,7 @@ class PreparedFilter;
  * @param spec the filter chain specification
  */
 void
-filter_chain_parse(PreparedFilter &chain,
+filter_chain_parse(std::unique_ptr<PreparedFilter> &chain,
 		   FilterFactory &factory,
 		   const char *spec);
 
diff --git a/src/filter/plugins/ChainFilterPlugin.cxx b/src/filter/plugins/ChainFilterPlugin.cxx
deleted file mode 100644
index 488e43896..000000000
--- a/src/filter/plugins/ChainFilterPlugin.cxx
+++ /dev/null
@@ -1,184 +0,0 @@
-/*
- * Copyright 2003-2021 The Music Player Daemon Project
- * http://www.musicpd.org
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
- */
-
-#include "ChainFilterPlugin.hxx"
-#include "filter/Filter.hxx"
-#include "filter/Prepared.hxx"
-#include "pcm/AudioFormat.hxx"
-#include "util/ConstBuffer.hxx"
-#include "util/StringBuffer.hxx"
-#include "util/RuntimeError.hxx"
-
-#include <cassert>
-#include <list>
-#include <memory>
-#include <string>
-
-class ChainFilter final : public Filter {
-	struct Child {
-		std::unique_ptr<Filter> filter;
-
-		explicit Child(std::unique_ptr<Filter> &&_filter) noexcept
-			:filter(std::move(_filter)) {}
-	};
-
-	std::list<Child> children;
-
-	/**
-	 * The child which will be flushed in the next Flush() call.
-	 */
-	std::list<Child>::iterator flushing = children.end();
-
-public:
-	explicit ChainFilter(AudioFormat _audio_format)
-		:Filter(_audio_format) {}
-
-	void Append(std::unique_ptr<Filter> filter) noexcept {
-		assert(out_audio_format.IsValid());
-		out_audio_format = filter->GetOutAudioFormat();
-		assert(out_audio_format.IsValid());
-
-		children.emplace_back(std::move(filter));
-
-		RewindFlush();
-	}
-
-	/* virtual methods from class Filter */
-	void Reset() noexcept override;
-	ConstBuffer<void> FilterPCM(ConstBuffer<void> src) override;
-	ConstBuffer<void> Flush() override;
-
-private:
-	void RewindFlush() {
-		flushing = children.begin();
-	}
-
-};
-
-class PreparedChainFilter final : public PreparedFilter {
-	struct Child {
-		const std::string name;
-		std::unique_ptr<PreparedFilter> filter;
-
-		Child(std::string_view _name,
-		      std::unique_ptr<PreparedFilter> _filter)
-			:name(_name), filter(std::move(_filter)) {}
-
-		Child(const Child &) = delete;
-		Child &operator=(const Child &) = delete;
-
-		std::unique_ptr<Filter> Open(const AudioFormat &prev_audio_format);
-	};
-
-	std::list<Child> children;
-
-public:
-	void Append(std::string_view name,
-		    std::unique_ptr<PreparedFilter> filter) noexcept {
-		children.emplace_back(name, std::move(filter));
-	}
-
-	/* virtual methods from class PreparedFilter */
-	std::unique_ptr<Filter> Open(AudioFormat &af) override;
-};
-
-std::unique_ptr<Filter>
-PreparedChainFilter::Child::Open(const AudioFormat &prev_audio_format)
-{
-	AudioFormat conv_audio_format = prev_audio_format;
-	auto new_filter = filter->Open(conv_audio_format);
-
-	if (conv_audio_format != prev_audio_format)
-		throw FormatRuntimeError("Audio format not supported by filter '%s': %s",
-					 name.c_str(),
-					 ToString(prev_audio_format).c_str());
-
-	return new_filter;
-}
-
-std::unique_ptr<Filter>
-PreparedChainFilter::Open(AudioFormat &in_audio_format)
-{
-	auto chain = std::make_unique<ChainFilter>(in_audio_format);
-
-	for (auto &child : children) {
-		AudioFormat audio_format = chain->GetOutAudioFormat();
-		chain->Append(child.Open(audio_format));
-	}
-
-	return chain;
-}
-
-void
-ChainFilter::Reset() noexcept
-{
-	RewindFlush();
-
-	for (auto &child : children)
-		child.filter->Reset();
-}
-
-template<typename I>
-static ConstBuffer<void>
-ApplyFilterChain(I begin, I end, ConstBuffer<void> src)
-{
-	for (auto i = begin; i != end; ++i)
-		/* feed the output of the previous filter as input
-		   into the current one */
-		src = i->filter->FilterPCM(src);
-
-	return src;
-}
-
-ConstBuffer<void>
-ChainFilter::FilterPCM(ConstBuffer<void> src)
-{
-	RewindFlush();
-
-	/* return the output of the last filter */
-	return ApplyFilterChain(children.begin(), children.end(), src);
-}
-
-ConstBuffer<void>
-ChainFilter::Flush()
-{
-	for (auto end = children.end(); flushing != end; ++flushing) {
-		auto data = flushing->filter->Flush();
-		if (!data.IsNull())
-			return ApplyFilterChain(std::next(flushing), end,
-						data);
-	}
-
-	return nullptr;
-}
-
-std::unique_ptr<PreparedFilter>
-filter_chain_new() noexcept
-{
-	return std::make_unique<PreparedChainFilter>();
-}
-
-void
-filter_chain_append(PreparedFilter &_chain, std::string_view name,
-		    std::unique_ptr<PreparedFilter> filter) noexcept
-{
-	auto &chain = (PreparedChainFilter &)_chain;
-
-	chain.Append(name, std::move(filter));
-}
diff --git a/src/filter/plugins/ChainFilterPlugin.hxx b/src/filter/plugins/ChainFilterPlugin.hxx
deleted file mode 100644
index 75ff342de..000000000
--- a/src/filter/plugins/ChainFilterPlugin.hxx
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- * Copyright 2003-2021 The Music Player Daemon Project
- * http://www.musicpd.org
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 2 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along
- * with this program; if not, write to the Free Software Foundation, Inc.,
- * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
- */
-
-/** \file
- *
- * A filter chain is a container for several filters.  They are
- * chained together, i.e. called in a row, one filter passing its
- * output to the next one.
- */
-
-#ifndef MPD_FILTER_CHAIN_HXX
-#define MPD_FILTER_CHAIN_HXX
-
-#include <memory>
-#include <string_view>
-
-class PreparedFilter;
-
-/**
- * Creates a new filter chain.
- */
-std::unique_ptr<PreparedFilter>
-filter_chain_new() noexcept;
-
-/**
- * Appends a new filter at the end of the filter chain.  You must call
- * this function before the first filter_open() call.
- *
- * @param chain the filter chain created with filter_chain_new()
- * @param filter the filter to be appended to #chain
- */
-void
-filter_chain_append(PreparedFilter &chain, std::string_view name,
-		    std::unique_ptr<PreparedFilter> filter) noexcept;
-
-#endif
diff --git a/src/filter/plugins/meson.build b/src/filter/plugins/meson.build
index 187dcfcc2..aa5dd8038 100644
--- a/src/filter/plugins/meson.build
+++ b/src/filter/plugins/meson.build
@@ -15,7 +15,6 @@ filter_plugins = static_library(
   '../../AudioCompress/compress.c',
   'NullFilterPlugin.cxx',
   'TwoFilters.cxx',
-  'ChainFilterPlugin.cxx',
   'AutoConvertFilterPlugin.cxx',
   'ConvertFilterPlugin.cxx',
   'RouteFilterPlugin.cxx',
diff --git a/src/output/Filtered.hxx b/src/output/Filtered.hxx
index 29e40f006..4482fddb0 100644
--- a/src/output/Filtered.hxx
+++ b/src/output/Filtered.hxx
@@ -90,8 +90,8 @@ public:
 	AudioFormat out_audio_format;
 
 	/**
-	 * The filter object of this audio output.  This is an
-	 * instance of chain_filter_plugin.
+	 * The filter object of this audio output.  This is a chain of
+	 * #PreparedTwoFilter instances.
 	 */
 	std::unique_ptr<PreparedFilter> prepared_filter;
 
diff --git a/src/output/Init.cxx b/src/output/Init.cxx
index c07e9d6e9..c561c5705 100644
--- a/src/output/Init.cxx
+++ b/src/output/Init.cxx
@@ -32,7 +32,7 @@
 #include "filter/plugins/AutoConvertFilterPlugin.hxx"
 #include "filter/plugins/ConvertFilterPlugin.hxx"
 #include "filter/plugins/ReplayGainFilterPlugin.hxx"
-#include "filter/plugins/ChainFilterPlugin.hxx"
+#include "filter/plugins/TwoFilters.hxx"
 #include "filter/plugins/VolumeFilterPlugin.hxx"
 #include "filter/plugins/NormalizeFilterPlugin.hxx"
 #include "util/RuntimeError.hxx"
@@ -109,7 +109,7 @@ audio_output_load_mixer(EventLoop &event_loop, FilteredAudioOutput &ao,
 			const ConfigBlock &block,
 			const MixerType mixer_type,
 			const MixerPlugin *plugin,
-			PreparedFilter &filter_chain,
+			std::unique_ptr<PreparedFilter> &filter_chain,
 			MixerListener &listener)
 {
 	Mixer *mixer;
@@ -137,8 +137,9 @@ audio_output_load_mixer(EventLoop &event_loop, FilteredAudioOutput &ao,
 				  ConfigBlock());
 		assert(mixer != nullptr);
 
-		filter_chain_append(filter_chain, "software_mixer",
-				    ao.volume_filter.Set(volume_filter_prepare()));
+		filter_chain = ChainFilters(std::move(filter_chain),
+					    ao.volume_filter.Set(volume_filter_prepare()),
+					    "software_mixer");
 		return mixer;
 	}
 
@@ -169,21 +170,17 @@ FilteredAudioOutput::Configure(const ConfigBlock &block,
 
 	log_name = StringFormat<256>("\"%s\" (%s)", name, plugin_name);
 
-	/* set up the filter chain */
-
-	prepared_filter = filter_chain_new();
-	assert(prepared_filter != nullptr);
-
 	/* create the normalization filter (if configured) */
 
 	if (defaults.normalize) {
-		filter_chain_append(*prepared_filter, "normalize",
-				    autoconvert_filter_new(normalize_filter_prepare()));
+		prepared_filter = ChainFilters(std::move(prepared_filter),
+					       autoconvert_filter_new(normalize_filter_prepare()),
+					       "normalize");
 	}
 
 	try {
 		if (filter_factory != nullptr)
-			filter_chain_parse(*prepared_filter, *filter_factory,
+			filter_chain_parse(prepared_filter, *filter_factory,
 					   block.GetBlockValue(AUDIO_FILTERS, ""));
 	} catch (...) {
 		/* It's not really fatal - Part of the filter chain
@@ -236,7 +233,7 @@ FilteredAudioOutput::Setup(EventLoop &event_loop,
 		mixer = audio_output_load_mixer(event_loop, *this, block,
 						mixer_type,
 						mixer_plugin,
-						*prepared_filter,
+						prepared_filter,
 						mixer_listener);
 	} catch (...) {
 		FmtError(output_domain,
@@ -260,8 +257,9 @@ FilteredAudioOutput::Setup(EventLoop &event_loop,
 
 	/* the "convert" filter must be the last one in the chain */
 
-	filter_chain_append(*prepared_filter, "convert",
-			    convert_filter.Set(convert_filter_prepare()));
+	prepared_filter = ChainFilters(std::move(prepared_filter),
+				       convert_filter.Set(convert_filter_prepare()),
+				       "convert");
 }
 
 std::unique_ptr<FilteredAudioOutput>