diff --git a/grc/CMakeLists.txt b/grc/CMakeLists.txt
index 7bd50626ef9ac1668c7b5c76b8132e59618f230f..43e25723a8be312b24349ae63d471357a71f9f6e 100644
--- a/grc/CMakeLists.txt
+++ b/grc/CMakeLists.txt
@@ -13,5 +13,6 @@ install(FILES
     elen90089_header_format_cdc.block.yml
     elen90089_packet_phy_tx.block.yml
     elen90089_packet_mac_tx.block.yml
+    elen90089_constellation_decoder_cf.block.yml
     DESTINATION share/gnuradio/grc/blocks
 )
diff --git a/grc/elen90089_constellation_decoder_cf.block.yml b/grc/elen90089_constellation_decoder_cf.block.yml
new file mode 100644
index 0000000000000000000000000000000000000000..b510360a5ce4e3fa85766047a0c0c4e1545c9fb6
--- /dev/null
+++ b/grc/elen90089_constellation_decoder_cf.block.yml
@@ -0,0 +1,31 @@
+id: elen90089_constellation_decoder_cf
+label: CDC Constellation Decoder
+category: '[elen90089]'
+
+templates:
+  imports: import elen90089
+  make: elen90089.constellation_decoder_cf(${bps}, ${soft_decisions}, ${length_tag_name})
+
+parameters:
+- id: bps
+  label: Bits per sym
+  dtype: int
+- id: soft_decisions
+  label: Soft Decisions
+  default: True
+  dtype: bool
+- id: length_tag_name
+  label: Length Tag Name
+  default: ''
+  dtype: string
+
+inputs:
+- label: in
+  domain: stream
+  dtype: complex
+outputs:
+- label: out
+  domain: stream
+  dtype: float
+
+file_format: 1
diff --git a/include/elen90089/CMakeLists.txt b/include/elen90089/CMakeLists.txt
index 01411424550ddb7307cb581024c5b8d59e98eb84..a43d267181d68f69e89d71b393f515f5d2026269 100644
--- a/include/elen90089/CMakeLists.txt
+++ b/include/elen90089/CMakeLists.txt
@@ -15,5 +15,6 @@ install(FILES
     moe_symbol_sync_cc.h
     symbol_mapper_c.h
     header_format_cdc.h
+    constellation_decoder_cf.h
     DESTINATION include/elen90089
 )
diff --git a/include/elen90089/constellation_decoder_cf.h b/include/elen90089/constellation_decoder_cf.h
new file mode 100644
index 0000000000000000000000000000000000000000..779e6ae624a2c99815969dc8bbfed2100fc81b7c
--- /dev/null
+++ b/include/elen90089/constellation_decoder_cf.h
@@ -0,0 +1,83 @@
+/* -*- c++ -*- */
+/*
+ * Copyright 2022 University of Melbourne.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#ifndef INCLUDED_ELEN90089_CONSTELLATION_DECODER_CF_H
+#define INCLUDED_ELEN90089_CONSTELLATION_DECODER_CF_H
+
+#include <elen90089/api.h>
+#include <gnuradio/sync_interpolator.h>
+
+namespace gr {
+namespace elen90089 {
+
+/*!
+ * \brief CDC soft or hard decision constellation decoder
+ * \ingroup elen90089
+ *
+ * \details
+ * Decode a constellation's points from a complex space to either hard bits or
+ * soft bits from soft decision LUT. The default constellation is set by
+ * the bps parameter in the constructor. The constellation used is updated
+ * from input stream tags with key 'bps' and value of the bits per symbol of
+ * the desired constellation. The following constellations are supported:
+ *
+ * \li BPSK     bps = 1
+ * \li QPSK     bps = 2
+ * \li 8PSK     bps = 3
+ * \li 16QAM    bps = 4
+ *
+ * Both hard and soft decision making is supported. Tags will be propagated
+ * on the correct sample based on the interpolation factor of the current
+ * constellation, e.g., the bps. If a length tag name is provided, the value
+ * of this tag will be scaled by the current bps.
+ */
+class ELEN90089_API constellation_decoder_cf : virtual public gr::sync_interpolator
+{
+public:
+    typedef std::shared_ptr<constellation_decoder_cf> sptr;
+
+    /*!
+     * \brief Make a CDC constellation decoder block.
+     *
+     * \param bps               (int) Bits per symbol of constellation
+     * \param soft_decisions    (bool) Use soft decision making
+     * \length_tag_name         (string) Tag key identifying length of symbol
+     *                          blocks
+     */
+    static sptr make(int bps,
+                     bool soft_decisions = true,
+                     const std::string& length_tag_name = "");
+
+    /*!
+     * \brief Returns current bits per symbol of constellation.
+     */
+    virtual int bps() const = 0;
+
+    /*!
+     * \brief Set bits per symbol of constellation.
+     *
+     * \param bps   (int) Bits per symbol of constellation
+     */
+    virtual void set_bps(int bps) = 0;
+
+    /*!
+     * \brief Returns whether soft decision making is used.
+     */
+    virtual bool soft_decisions() const = 0;
+
+    /*!
+     * \brief Set whether soft decision making is used.
+     *
+     * \param soft_decisions    (bool) Use soft decision making.
+     */
+    virtual void set_soft_decisions(bool soft_decisions) = 0;
+};
+
+} // namespace elen90089
+} // namespace gr
+
+#endif /* INCLUDED_ELEN90089_CONSTELLATION_DECODER_CF_H */
diff --git a/lib/CMakeLists.txt b/lib/CMakeLists.txt
index 505cbddc305651cced6842d7a1cbb6d1eb7b500a..e6353a612f8d78d98a47f9b7e6cf2cb9a3015eee 100644
--- a/lib/CMakeLists.txt
+++ b/lib/CMakeLists.txt
@@ -16,6 +16,7 @@ list(APPEND elen90089_sources
     moe_symbol_sync_cc_impl.cc
     symbol_mapper_c_impl.cc
     header_format_cdc.cc
+    constellation_decoder_cf_impl.cc
 )
 
 set(elen90089_sources "${elen90089_sources}" PARENT_SCOPE)
diff --git a/lib/constellation_decoder_cf_impl.cc b/lib/constellation_decoder_cf_impl.cc
new file mode 100644
index 0000000000000000000000000000000000000000..dc22feea4eb440888965189110706cc196313754
--- /dev/null
+++ b/lib/constellation_decoder_cf_impl.cc
@@ -0,0 +1,146 @@
+/* -*- c++ -*- */
+/*
+ * Copyright 2022 University of Melbourne.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#include <gnuradio/io_signature.h>
+#include "constellation_decoder_cf_impl.h"
+
+
+namespace gr {
+namespace elen90089 {
+
+constellation_decoder_cf::sptr constellation_decoder_cf::make(int bps,
+                                                              bool soft_decisions,
+                                                              const std::string& length_tag_name)
+{
+    return gnuradio::make_block_sptr<constellation_decoder_cf_impl>(
+        bps, soft_decisions, length_tag_name);
+}
+
+constellation_decoder_cf_impl::constellation_decoder_cf_impl(int bps,
+                                                             bool soft_decisions,
+                                                             const std::string& length_tag_name)
+    : gr::sync_interpolator("constellation_decoder_cf",
+                            gr::io_signature::make(1, 1, sizeof(gr_complex)),
+                            gr::io_signature::make(1, 1, sizeof(float)),
+                            bps),
+      d_soft_decisions(soft_decisions),
+      d_length_tag_key(pmt::PMT_NIL),
+      d_bps_new(-1)
+{
+    if(!length_tag_name.empty()) {
+        d_length_tag_key = pmt::intern(length_tag_name);
+    }
+
+    // manually propagate tags
+    set_tag_propagation_policy(TPP_DONT);
+
+    // create constellation maps
+    d_constel[1] = digital::constellation_bpsk::make();
+    d_constel[2] = digital::constellation_calcdist::make( // normalized QPSK
+        {gr_complex(-1, -1), gr_complex( 1, -1),
+         gr_complex(-1,  1), gr_complex( 1,  1)},
+        {0x0, 0x1, 0x2, 0x3}, 4, 1,
+        digital::constellation::POWER_NORMALIZATION);
+    d_constel[3] = digital::constellation_8psk::make();
+    d_constel[4] = digital::constellation_16qam::make();
+}
+
+constellation_decoder_cf_impl::~constellation_decoder_cf_impl() { }
+
+int constellation_decoder_cf_impl::bps() const 
+{
+    return interpolation();
+}
+
+void constellation_decoder_cf_impl::set_bps(int bps)
+{
+    set_interpolation(bps);
+}
+
+bool constellation_decoder_cf_impl::soft_decisions() const 
+{
+    return d_soft_decisions;
+}
+
+void constellation_decoder_cf_impl::set_soft_decisions(bool soft_decisions)
+{
+    d_soft_decisions = soft_decisions;
+}
+
+int constellation_decoder_cf_impl::work(int noutput_items,
+                                        gr_vector_const_void_star &input_items,
+                                        gr_vector_void_star &output_items)
+{
+    auto in = static_cast<const gr_complex*>(input_items[0]);
+    auto out = static_cast<float*>(output_items[0]);
+    
+    // update bps from last time as needed
+    if (d_bps_new > 0) {
+        set_interpolation(d_bps_new);
+        d_bps_new = -1;
+    }
+    int bps = interpolation();
+    int nin = noutput_items / bps;
+ 
+    // get bps tags in window
+    std::vector<gr::tag_t> tags;
+    get_tags_in_window(tags, 0, 0, nin, pmt::mp("bps"));
+
+    // look for change in bps in this window
+    int nread = nitems_read(0);
+    for(gr::tag_t tag : tags) {
+        int value = pmt::to_long(tag.value);
+        assert(value > 0 && value < 5);
+        if(value != bps) {
+            d_bps_new = value;
+            nin = tag.offset - nread;
+            break;
+        }
+    }
+    tags.clear();
+   
+    // decoding with current constellation
+    digital::constellation_sptr constel = d_constel[bps];
+    if (d_soft_decisions) {
+        std::vector<float> bits;
+        for(int i = 0; i < nin; i++) {
+            bits = constel->soft_decision_maker(in[i]);
+            for (size_t j = 0; j < bits.size(); j++) {
+                out[bps*i + j] = bits[j];
+            }
+        }
+    } else { // hard decisions
+        for(int i = 0; i < nin; i++) {
+            int bits = constel->decision_maker(&in[i]);
+            for (size_t j = 0; j < bps; j++) {
+                int bit = 0x1 & (bits >> (bps - j - 1));
+                out[bps*i + j] = (float)bit;
+            }
+        }
+    }
+
+    // manually propagate tags in window to safely handle changes in 
+    // interpolation rate (bps)
+    get_tags_in_window(tags, 0, 0, nin);
+    int nwritten = nitems_written(0);
+    for(gr::tag_t tag : tags) {
+        int offset = bps*(tag.offset - nread) + nwritten;
+
+        pmt::pmt_t value = tag.value;
+        if(tag.key == d_length_tag_key) {
+            int length = pmt::to_long(value);
+            value = pmt::from_long(bps*length);
+        }
+
+        add_item_tag(0, offset, tag.key, value, tag.srcid);
+    }
+
+    return bps*nin;
+}
+
+} /* namespace elen90089 */
+} /* namespace gr */
diff --git a/lib/constellation_decoder_cf_impl.h b/lib/constellation_decoder_cf_impl.h
new file mode 100644
index 0000000000000000000000000000000000000000..7c8d93aaa63dad2ae3109406c473cd4b73b9b327
--- /dev/null
+++ b/lib/constellation_decoder_cf_impl.h
@@ -0,0 +1,50 @@
+/* -*- c++ -*- */
+/*
+ * Copyright 2022 University of Melbourne.
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#ifndef INCLUDED_ELEN90089_CONSTELLATION_DECODER_CF_IMPL_H
+#define INCLUDED_ELEN90089_CONSTELLATION_DECODER_CF_IMPL_H
+
+#include <elen90089/constellation_decoder_cf.h>
+#include <gnuradio/digital/constellation.h>
+#include <pmt/pmt.h>
+
+namespace gr {
+namespace elen90089 {
+
+class constellation_decoder_cf_impl : public constellation_decoder_cf
+{
+private:
+    bool d_soft_decisions;
+    pmt::pmt_t d_length_tag_key;
+    
+    int d_bps_new;
+    std::map<int, digital::constellation_sptr> d_constel;
+
+public:
+    constellation_decoder_cf_impl(int bps,
+                                  bool soft_decisions = true,
+                                  const std::string& length_tag_name = "");
+
+    ~constellation_decoder_cf_impl();
+
+    int bps() const override;
+
+    void set_bps(int bps) override;
+
+    bool soft_decisions() const override;
+
+    void set_soft_decisions(bool soft_decisions) override;
+
+    int work(int noutput_items,
+             gr_vector_const_void_star &input_items,
+             gr_vector_void_star &output_items);
+};
+
+} // namespace elen90089
+} // namespace gr
+
+#endif /* INCLUDED_ELEN90089_CONSTELLATION_DECODER_CF_IMPL_H */
diff --git a/python/CMakeLists.txt b/python/CMakeLists.txt
index eb5106e643b31c808e9bdee3704037c6cb311451..a429794c9327c34a43dfb52092ab68d6673cf1a9 100644
--- a/python/CMakeLists.txt
+++ b/python/CMakeLists.txt
@@ -44,3 +44,4 @@ add_custom_target(
 GR_ADD_TEST(qa_corr_est_cc ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/qa_corr_est_cc.py)
 GR_ADD_TEST(qa_moe_symbol_sync_cc ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/qa_moe_symbol_sync_cc.py)
 GR_ADD_TEST(qa_symbol_mapper_c ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/qa_symbol_mapper_c.py)
+GR_ADD_TEST(qa_constellation_decoder_cf ${PYTHON_EXECUTABLE} ${CMAKE_CURRENT_SOURCE_DIR}/qa_constellation_decoder_cf.py)
diff --git a/python/bindings/CMakeLists.txt b/python/bindings/CMakeLists.txt
index 95bcf56bf41e4fec2d69113d490e0eee00815ebd..b5b89a83c55c18fb648b72d65b05b5e5d31c773c 100644
--- a/python/bindings/CMakeLists.txt
+++ b/python/bindings/CMakeLists.txt
@@ -33,6 +33,7 @@ list(APPEND elen90089_python_files
     moe_symbol_sync_cc_python.cc
     symbol_mapper_c_python.cc
     header_format_cdc_python.cc
+    constellation_decoder_cf_python.cc
     python_bindings.cc)
 
 GR_PYBIND_MAKE_OOT(elen90089
diff --git a/python/bindings/constellation_decoder_cf_python.cc b/python/bindings/constellation_decoder_cf_python.cc
new file mode 100644
index 0000000000000000000000000000000000000000..77684fd5dcecdd71682e5a4e8d58789aae9de293
--- /dev/null
+++ b/python/bindings/constellation_decoder_cf_python.cc
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2022 Free Software Foundation, Inc.
+ *
+ * This file is part of GNU Radio
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ */
+
+/***********************************************************************************/
+/* This file is automatically generated using bindtool and can be manually edited  */
+/* The following lines can be configured to regenerate this file during cmake      */
+/* If manual edits are made, the following tags should be modified accordingly.    */
+/* BINDTOOL_GEN_AUTOMATIC(0)                                                       */
+/* BINDTOOL_USE_PYGCCXML(0)                                                        */
+/* BINDTOOL_HEADER_FILE(constellation_decoder_cf.h)                                */
+/* BINDTOOL_HEADER_FILE_HASH(6676402190669fcc53daed62a6ba41cc)                     */
+/***********************************************************************************/
+
+#include <pybind11/complex.h>
+#include <pybind11/pybind11.h>
+#include <pybind11/stl.h>
+
+namespace py = pybind11;
+
+#include <elen90089/constellation_decoder_cf.h>
+// pydoc.h is automatically generated in the build directory
+#include <constellation_decoder_cf_pydoc.h>
+
+void bind_constellation_decoder_cf(py::module& m)
+{
+    using constellation_decoder_cf = ::gr::elen90089::constellation_decoder_cf;
+
+    py::class_<constellation_decoder_cf,
+               gr::sync_interpolator,
+               std::shared_ptr<constellation_decoder_cf>>(m, "constellation_decoder_cf", D(constellation_decoder_cf))
+
+        .def(py::init(&constellation_decoder_cf::make),
+             py::arg("bps"),
+             py::arg("soft_decisions") = true,
+             py::arg("length_tag_name") = "",
+             D(constellation_decoder_cf,make))
+
+        .def("bps",
+             &constellation_decoder_cf::bps,
+             D(constellation_decoder_cf, bps))
+
+        .def("set_bps",
+             &constellation_decoder_cf::set_bps,
+             py::arg("bps"),
+             D(constellation_decoder_cf, set_bps))
+
+        .def("soft_decisions",
+             &constellation_decoder_cf::soft_decisions,
+             D(constellation_decoder_cf, soft_decisions))
+
+        .def("set_soft_decisions",
+             &constellation_decoder_cf::set_soft_decisions,
+             py::arg("soft_decisions"),
+             D(constellation_decoder_cf, set_soft_decisions));
+}
diff --git a/python/bindings/docstrings/constellation_decoder_cf_pydoc_template.h b/python/bindings/docstrings/constellation_decoder_cf_pydoc_template.h
new file mode 100644
index 0000000000000000000000000000000000000000..8e2509b324bed38074185a0429798b46d3f8c6f3
--- /dev/null
+++ b/python/bindings/docstrings/constellation_decoder_cf_pydoc_template.h
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2022 Free Software Foundation, Inc.
+ *
+ * This file is part of GNU Radio
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ *
+ */
+#include "pydoc_macros.h"
+#define D(...) DOC(gr,elen90089, __VA_ARGS__ )
+/*
+  This file contains placeholders for docstrings for the Python bindings.
+  Do not edit! These were automatically extracted during the binding process
+  and will be overwritten during the build process
+ */
+
+
+ 
+ static const char *__doc_gr_elen90089_constellation_decoder_cf = R"doc()doc";
+
+
+ static const char *__doc_gr_elen90089_constellation_decoder_cf_constellation_decoder_cf_0 = R"doc()doc";
+
+
+ static const char *__doc_gr_elen90089_constellation_decoder_cf_constellation_decoder_cf_1 = R"doc()doc";
+
+
+ static const char *__doc_gr_elen90089_constellation_decoder_cf_make = R"doc()doc";
+
+
+ static const char *__doc_gr_elen90089_constellation_decoder_cf_bps = R"doc()doc";
+
+
+ static const char *__doc_gr_elen90089_constellation_decoder_cf_set_bps = R"doc()doc";
+
+
+ static const char *__doc_gr_elen90089_constellation_decoder_cf_soft_decisions = R"doc()doc";
+
+
+ static const char *__doc_gr_elen90089_constellation_decoder_cf_set_soft_decisions = R"doc()doc";
+
+  
diff --git a/python/bindings/python_bindings.cc b/python/bindings/python_bindings.cc
index d46dcdfb7afb3b71c81cfa25552e3060e5807ed1..05ab0ceb79ab46242dbb907dc22920580d4342ee 100644
--- a/python/bindings/python_bindings.cc
+++ b/python/bindings/python_bindings.cc
@@ -25,6 +25,7 @@ namespace py = pybind11;
     void bind_moe_symbol_sync_cc(py::module& m);
     void bind_symbol_mapper_c(py::module& m);
     void bind_header_format_cdc(py::module& m);
+    void bind_constellation_decoder_cf(py::module& m);
 // ) END BINDING_FUNCTION_PROTOTYPES
 
 
@@ -58,5 +59,6 @@ PYBIND11_MODULE(elen90089_python, m)
     bind_moe_symbol_sync_cc(m);
     bind_symbol_mapper_c(m);
     bind_header_format_cdc(m);
+    bind_constellation_decoder_cf(m);
     // ) END BINDING_FUNCTION_CALLS
-}
+}
\ No newline at end of file
diff --git a/python/qa_constellation_decoder_cf.py b/python/qa_constellation_decoder_cf.py
new file mode 100755
index 0000000000000000000000000000000000000000..768fde1a857c781c3c4eb93b4558f4c3b022adaf
--- /dev/null
+++ b/python/qa_constellation_decoder_cf.py
@@ -0,0 +1,107 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# Copyright 2022 University of Melbourne.
+#
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+
+from gnuradio import gr, gr_unittest
+from gnuradio import blocks, digital
+import pmt
+import numpy as np
+import time
+try:
+    from elen90089 import constellation_decoder_cf
+except ImportError:
+    import os
+    import sys
+    dirname, filename = os.path.split(os.path.abspath(__file__))
+    sys.path.append(os.path.join(dirname, "bindings"))
+    from elen90089 import constellation_decoder_cf
+
+
+def modulate_u8vector(cnst, vec):
+    bits = np.unpackbits(vec)
+    bps = cnst.bits_per_symbol()
+    
+    symb = []
+    for ii in range(0, len(bits), bps):
+        x = np.flip(bits[ii:ii+bps])
+        s = np.packbits(x, bitorder='little')[0]
+        symb.append(cnst.points()[s])
+
+    return np.array(symb, dtype=np.complex)
+
+class qa_constellation_decoder_cf(gr_unittest.TestCase):
+
+    def setUp(self):
+        self.tb = gr.top_block()
+
+    def tearDown(self):
+        self.tb = None
+
+    def test_instance(self):
+        instance = constellation_decoder_cf(1, True, "payload symbols")
+
+    def test_001_constel_change(self):
+        # test parameters
+        default_bps = 3
+        soft_decisions = True
+        length_tag = "payload symbols"
+        nbytes = 16
+        constel = [digital.constellation_calcdist( # normalized qpsk
+                       [-1-1j, 1-1j, -1+1j, 1+1j],
+                       [0, 1, 2, 3], 4, 1,
+                       digital.constellation.normalization.POWER_NORMALIZATION),
+                   digital.constellation_bpsk(),
+                   digital.constellation_16qam()]
+
+        # set up flowgraph
+        src = blocks.pdu_to_tagged_stream(blocks.complex_t, length_tag)
+        dec = constellation_decoder_cf(default_bps, soft_decisions, length_tag)
+        dst = blocks.vector_sink_f()
+        self.tb.connect(src, dec, dst)
+        dbg = blocks.tag_debug(gr.sizeof_float, "", length_tag)
+        dbg.set_save_all(True)
+        dbg.set_display(False)
+        self.tb.connect(dec, dbg)
+
+        # create test pdus
+        data = np.array([], dtype=np.uint8)
+        for c in constel:
+            # create modulated vector
+            d = np.random.randint(256, size=nbytes, dtype=np.uint8)
+            x = modulate_u8vector(c, d)
+            # convert to pdu
+            bps = pmt.from_long(c.bits_per_symbol())
+            meta = pmt.make_dict()
+            meta = pmt.dict_add(meta, pmt.intern('bps'), bps)
+            pdu = pmt.cons(meta, pmt.init_c32vector(len(x), x))
+            # pass to flowgraph
+            src.to_basic_block()._post(pmt.intern('pdus'), pdu)
+            data = np.concatenate((data, d))
+        
+        # run fg
+        self.tb.start()
+        time.sleep(1)
+        self.tb.stop()
+        
+        # check soft bits
+        results = np.array(dst.data())
+        vec = np.packbits(results > 0)
+        self.assertTupleEqual(tuple(data), tuple(vec))
+        
+        # check length tag is updated
+        tags = dbg.current_tags()
+        self.assertEqual(len(tags), len(constel))
+        offset = 0
+        for tag in tags:
+            value = pmt.to_python(tag.value)
+            self.assertEqual(offset, tag.offset)
+            self.assertEqual(8*nbytes, value)
+            offset += value
+
+
+if __name__ == '__main__':
+    gr_unittest.run(qa_constellation_decoder_cf)