Repository: pion/interceptor
Pull request #71 (2021-11-02 10:11:38)

[feat/gcc] Implement Bandwidth Estimation
#### Description

This pull request tracks the implementation progress for bandwidth estimation using Google Congestion Control. It will introduce interfaces that can hopefully be reused by other bandwidth estimators in the future.

#### Reference issue
Implements the GCC related tasks of #25

#### TODO:
- [x] Implement TWCC Feedback adapter
- [x] Integrate in bwe tests in pion/webrtc
- [x] Sending Engine (pacer)
- [ ] Implement Loss based bandwidth estimation
  - [x] Calculate loss and update target bitrate
  - [ ] `loss_based_bwe.go` unit tests
- [x] Implement Delay based bandwidth estimation
  - [x] Pre-filter acknowledgements and aggregate arrival groups
  - [x] Implement arrival-time filter to estimate inter group delay variation
  - [x] Implement over-use-detector using adaptive threshold
  - [x] Implement Rate Control
  - [x] Add measurement uncertainty updates to kalman filter
  - [x] Add outlier filter for non-zero-mean WGN of measurement uncertainty
  - [x] Add over use time threshold
  - [x] Tune parameters (adaptive threshold, ...)
- [ ] (optional/later) Implement RFC8888
- [ ] (optional/later) Implement RFC8888 Feedback adapter

codecov[bot]: # [Codecov](https://codecov.io/gh/pion/interceptor/pull/71?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion) Report
> Merging [#71](https://codecov.io/gh/pion/interceptor/pull/71?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion) (19e6dfa) into [master](https://codecov.io/gh/pion/interceptor/commit/6cde0e070e940b31a0bf20557301e26142016ffd?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion) (6cde0e0) will **decrease** coverage by `12.85%`.
> The diff coverage is `22.64%`.

[![Impacted file tree graph](https://codecov.io/gh/pion/interceptor/pull/71/graphs/tree.svg?width=650&height=150&src=pr&token=FNH9xUG6AX&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion)](https://codecov.io/gh/pion/interceptor/pull/71?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion)

```diff
@@             Coverage Diff             @@
##           master      #71       +/-   ##
===========================================
- Coverage   79.44%   66.58%   -12.86%     
===========================================
  Files          31       40        +9     
  Lines         973     1260      +287     
===========================================
+ Hits          773      839       +66     
- Misses        134      352      +218     
- Partials       66       69        +3     
```

| Flag | Coverage Δ | |
|---|---|---|
| go | `66.58% <22.64%> (-12.86%)` | :arrow_down: |
| wasm | `?` | |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://codecov.io/gh/pion/interceptor/pull/71?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion) | Coverage Δ | |
|---|---|---|
| [internal/types/datarate.go](https://codecov.io/gh/pion/interceptor/pull/71/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#diff-aW50ZXJuYWwvdHlwZXMvZGF0YXJhdGUuZ28=) | `0.00% <0.00%> (ø)` | |
| [pkg/cc/interceptor.go](https://codecov.io/gh/pion/interceptor/pull/71/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#diff-cGtnL2NjL2ludGVyY2VwdG9yLmdv) | `0.00% <0.00%> (ø)` | |
| [pkg/cc/leaky\_bucket\_pacer.go](https://codecov.io/gh/pion/interceptor/pull/71/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#diff-cGtnL2NjL2xlYWt5X2J1Y2tldF9wYWNlci5nbw==) | `0.00% <0.00%> (ø)` | |
| [pkg/cc/noop\_pacer.go](https://codecov.io/gh/pion/interceptor/pull/71/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#diff-cGtnL2NjL25vb3BfcGFjZXIuZ28=) | `0.00% <0.00%> (ø)` | |
| [pkg/gcc/delay\_based\_bwe.go](https://codecov.io/gh/pion/interceptor/pull/71/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#diff-cGtnL2djYy9kZWxheV9iYXNlZF9id2UuZ28=) | `0.00% <0.00%> (ø)` | |
| [pkg/gcc/loss\_based\_bwe.go](https://codecov.io/gh/pion/interceptor/pull/71/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#diff-cGtnL2djYy9sb3NzX2Jhc2VkX2J3ZS5nbw==) | `0.00% <0.00%> (ø)` | |
| [pkg/gcc/queue.go](https://codecov.io/gh/pion/interceptor/pull/71/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#diff-cGtnL2djYy9xdWV1ZS5nbw==) | `0.00% <0.00%> (ø)` | |
| [pkg/gcc/send\_side\_bwe.go](https://codecov.io/gh/pion/interceptor/pull/71/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#diff-cGtnL2djYy9zZW5kX3NpZGVfYndlLmdv) | `0.00% <0.00%> (ø)` | |
| [pkg/cc/feedback\_adapter.go](https://codecov.io/gh/pion/interceptor/pull/71/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#diff-cGtnL2NjL2ZlZWRiYWNrX2FkYXB0ZXIuZ28=) | `76.47% <76.47%> (ø)` | |
| [attributes.go](https://codecov.io/gh/pion/interceptor/pull/71/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion#diff-YXR0cmlidXRlcy5nbw==) | `85.00% <0.00%> (+5.00%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/pion/interceptor/pull/71?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/pion/interceptor/pull/71?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion). Last update [6cde0e0...19e6dfa](https://codecov.io/gh/pion/interceptor/pull/71?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=pion).


Patch:
diff --git a/internal/cc/acknowledgment.go b/internal/cc/acknowledgment.go
new file mode 100644
index 0000000..580f81f
--- /dev/null
+++ b/internal/cc/acknowledgment.go
@@ -0,0 +1,26 @@
+package cc
+
+import (
+	"fmt"
+	"time"
+)
+
+// Acknowledgment holds information about a packet and if/when it has been
+// sent/received.
+type Acknowledgment struct {
+	TLCC      uint16
+	Size      int
+	Departure time.Time
+	Arrival   time.Time
+	RTT       time.Duration
+}
+
+func (a Acknowledgment) String() string {
+	s := "ACK:\n"
+	s += fmt.Sprintf("\tTLCC:\t%v\n", a.TLCC)
+	s += fmt.Sprintf("\tSIZE:\t%v\n", a.Size)
+	s += fmt.Sprintf("\tDEPARTURE:\t%v\n", int64(float64(a.Departure.UnixNano())/1e+6))
+	s += fmt.Sprintf("\tARRIVAL:\t%v\n", int64(float64(a.Arrival.UnixNano())/1e+6))
+	s += fmt.Sprintf("\tRTT:\t%v\n", a.RTT)
+	return s
+}
diff --git a/internal/cc/cc.go b/internal/cc/cc.go
new file mode 100644
index 0000000..6eae0ce
--- /dev/null
+++ b/internal/cc/cc.go
@@ -0,0 +1,2 @@
+// Package cc implements common constructs used by Congestion Controllers
+package cc
diff --git a/internal/cc/feedback_adapter.go b/internal/cc/feedback_adapter.go
new file mode 100644
index 0000000..14d62c5
--- /dev/null
+++ b/internal/cc/feedback_adapter.go
@@ -0,0 +1,159 @@
+package cc
+
+import (
+	"errors"
+	"sync"
+	"time"
+
+	"github.com/pion/interceptor"
+	"github.com/pion/rtcp"
+	"github.com/pion/rtp"
+)
+
+// TwccExtensionAttributesKey identifies the TWCC value in the attribute collection
+// so we don't need to reparse
+const TwccExtensionAttributesKey = iota
+
+var (
+	errMissingTWCCExtension  = errors.New("missing transport layer cc header extension")
+	errUnknownFeedbackFormat = errors.New("unknown feedback format")
+
+	errInvalidFeedback = errors.New("invalid feedback")
+)
+
+// FeedbackAdapter converts incoming RTCP Packets (TWCC and RFC8888) into Acknowledgments.
+// Acknowledgments are the common format that Congestion Controllers in Pion understand.
+type FeedbackAdapter struct {
+	lock    sync.Mutex
+	history map[uint16]Acknowledgment
+}
+
+// NewFeedbackAdapter returns a new FeedbackAdapter
+func NewFeedbackAdapter() *FeedbackAdapter {
+	return &FeedbackAdapter{
+		history: make(map[uint16]Acknowledgment),
+	}
+}
+
+// OnSent records that and when an outgoing packet was sent for later mapping to
+// acknowledgments
+func (f *FeedbackAdapter) OnSent(ts time.Time, header *rtp.Header, size int, attributes interceptor.Attributes) error {
+	hdrExtensionID := attributes.Get(TwccExtensionAttributesKey)
+	id, ok := hdrExtensionID.(uint8)
+	if !ok || hdrExtensionID == 0 {
+		return errMissingTWCCExtension
+	}
+	sequenceNumber := header.GetExtension(id)
+	var tccExt rtp.TransportCCExtension
+	err := tccExt.Unmarshal(sequenceNumber)
+	if err != nil {
+		return err
+	}
+
+	f.lock.Lock()
+	defer f.lock.Unlock()
+	f.history[tccExt.TransportSequence] = Acknowledgment{
+		TLCC:      tccExt.TransportSequence,
+		Size:      header.MarshalSize() + size,
+		Departure: ts,
+		Arrival:   time.Time{},
+		RTT:       0,
+	}
+	return nil
+}
+
+// OnFeedback converts incoming RTCP packet feedback to Acknowledgments.
+// Currently only TWCC is supported.
+func (f *FeedbackAdapter) OnFeedback(ts time.Time, feedback rtcp.Packet) ([]Acknowledgment, error) {
+	f.lock.Lock()
+	defer f.lock.Unlock()
+
+	switch fb := feedback.(type) {
+	case *rtcp.TransportLayerCC:
+		return f.onIncomingTransportCC(ts, fb)
+	default:
+		return nil, errUnknownFeedbackFormat
+	}
+}
+
+func (f *FeedbackAdapter) unpackRunLengthChunk(ts time.Time, start uint16, refTime time.Time, chunk *rtcp.RunLengthChunk, deltas []*rtcp.RecvDelta) (consumedDeltas int, nextRef time.Time, acks []Acknowledgment, err error) {
+	result := make([]Acknowledgment, chunk.RunLength)
+	deltaIndex := 0
+
+	end := start + chunk.RunLength
+	resultIndex := 0
+	for i := start; i != end; i++ {
+		if ack, ok := f.history[i]; ok {
+			if chunk.PacketStatusSymbol != rtcp.TypeTCCPacketNotReceived {
+				if len(deltas)-1 < deltaIndex {
+					return deltaIndex, refTime, result, errInvalidFeedback
+				}
+				refTime = refTime.Add(time.Duration(deltas[deltaIndex].Delta) * time.Microsecond)
+				ack.Arrival = refTime
+				ack.RTT = ts.Sub(ack.Departure)
+				deltaIndex++
+			}
+			result[resultIndex] = ack
+		}
+		resultIndex++
+	}
+	return deltaIndex, refTime, result, nil
+}
+
+func (f *FeedbackAdapter) unpackStatusVectorChunk(ts time.Time, start uint16, refTime time.Time, chunk *rtcp.StatusVectorChunk, deltas []*rtcp.RecvDelta) (consumedDeltas int, nextRef time.Time, acks []Acknowledgment, err error) {
+	result := make([]Acknowledgment, len(chunk.SymbolList))
+	deltaIndex := 0
+	resultIndex := 0
+	for i, symbol := range chunk.SymbolList {
+		if ack, ok := f.history[start+uint16(i)]; ok {
+			if symbol != rtcp.TypeTCCPacketNotReceived {
+				if len(deltas)-1 < deltaIndex {
+					return deltaIndex, refTime, result, errInvalidFeedback
+				}
+				refTime = refTime.Add(time.Duration(deltas[deltaIndex].Delta) * time.Microsecond)
+				ack.Arrival = refTime
+				ack.RTT = ts.Sub(ack.Departure)
+				deltaIndex++
+			}
+			result[resultIndex] = ack
+		}
+		resultIndex++
+	}
+
+	return deltaIndex, refTime, result, nil
+}
+
+func (f *FeedbackAdapter) onIncomingTransportCC(ts time.Time, feedback *rtcp.TransportLayerCC) ([]Acknowledgment, error) {
+	result := []Acknowledgment{}
+
+	index := feedback.BaseSequenceNumber
+	refTime := time.Time{}.Add(time.Duration(feedback.ReferenceTime) * 64 * time.Millisecond)
+	recvDeltas := feedback.RecvDeltas
+
+	for _, chunk := range feedback.PacketChunks {
+		switch chunk := chunk.(type) {
+		case *rtcp.RunLengthChunk:
+			n, nextRefTime, acks, err := f.unpackRunLengthChunk(ts, index, refTime, chunk, recvDeltas)
+			if err != nil {
+				return nil, err
+			}
+			refTime = nextRefTime
+			result = append(result, acks...)
+			recvDeltas = recvDeltas[n:]
+			index = uint16(int(index) + len(acks))
+		case *rtcp.StatusVectorChunk:
+			n, nextRefTime, acks, err := f.unpackStatusVectorChunk(ts, index, refTime, chunk, recvDeltas)
+			if err != nil {
+				return nil, err
+			}
+			refTime = nextRefTime
+			result = append(result, acks...)
+			recvDeltas = recvDeltas[n:]
+			index = uint16(int(index) + len(acks))
+		default:
+			return nil, errInvalidFeedback
+		}
+	}
+
+	return result, nil
+}
diff --git a/pkg/cc/interceptor.go b/pkg/cc/interceptor.go
new file mode 100644
index 0000000..2576cbb
--- /dev/null
+++ b/pkg/cc/interceptor.go
@@ -0,0 +1,147 @@
+// Package cc implements an interceptor for bandwidth estimation that can be
+// used with different BandwidthEstimators.
+package cc
+
+import (
+	"github.com/pion/interceptor"
+	"github.com/pion/interceptor/pkg/gcc"
+	"github.com/pion/logging"
+	"github.com/pion/rtcp"
+)
+
+const transportCCURI = "http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01"
+
+// Option can be used to set initial options on CC interceptors
+type Option func(*Interceptor) error
+
+// BandwidthEstimatorFactory creates new BandwidthEstimators
+type BandwidthEstimatorFactory func() (BandwidthEstimator, error)
+
+// BandwidthEstimator is the interface that will be returned by a
+// NewPeerConnectionCallback and can be used to query current bandwidth
+// metrics and add feedback manually.
+type BandwidthEstimator interface {
+	AddStream(*interceptor.StreamInfo, interceptor.RTPWriter) interceptor.RTPWriter
+	WriteRTCP([]rtcp.Packet, interceptor.Attributes) error
+	GetTargetBitrate() int
+	OnTargetBitrateChange(f func(bitrate int))
+	GetStats() map[string]interface{}
+	Close() error
+}
+
+// NewPeerConnectionCallback returns the BandwidthEstimator for the
+// PeerConnection with id
+type NewPeerConnectionCallback func(id string, estimator BandwidthEstimator)
+
+// InterceptorFactory is a factory for CC interceptors
+type InterceptorFactory struct {
+	opts              []Option
+	bweFactory        func() (BandwidthEstimator, error)
+	addPeerConnection NewPeerConnectionCallback
+}
+
+// NewInterceptor returns a new CC interceptor factory
+func NewInterceptor(factory BandwidthEstimatorFactory, opts ...Option) (*InterceptorFactory, error) {
+	if factory == nil {
+		factory = func() (BandwidthEstimator, error) {
+			return gcc.NewSendSideBWE()
+		}
+	}
+	return &InterceptorFactory{
+		opts:              opts,
+		bweFactory:        factory,
+		addPeerConnection: nil,
+	}, nil
+}
+
+// OnNewPeerConnection sets a callback that is called when a new CC interceptor
+// is created.
+func (f *InterceptorFactory) OnNewPeerConnection(cb NewPeerConnectionCallback) {
+	f.addPeerConnection = cb
+}
+
+// NewInterceptor returns a new CC interceptor
+func (f *InterceptorFactory) NewInterceptor(id string) (interceptor.Interceptor, error) {
+	bwe, err := f.bweFactory()
+	if err != nil {
+		return nil, err
+	}
+	i := &Interceptor{
+		NoOp:      interceptor.NoOp{},
+		log:       logging.NewDefaultLoggerFactory().NewLogger("cc_interceptor"),
+		estimator: bwe,
+		feedback:  make(chan []rtcp.Packet),
+		close:     make(chan struct{}),
+	}
+
+	for _, opt := range f.opts {
+		if err := opt(i); err != nil {
+			return nil, err
+		}
+	}
+
+	if f.addPeerConnection != nil {
+		f.addPeerConnection(id, i.estimator)
+	}
+	return i, nil
+}
+
+// Interceptor implements Google Congestion Control
+type Interceptor struct {
+	interceptor.NoOp
+	log       logging.LeveledLogger
+	estimator BandwidthEstimator
+	feedback  chan []rtcp.Packet
+	close     chan struct{}
+}
+
+// BindRTCPReader lets you modify any incoming RTCP packets. It is called once
+// per sender/receiver, however this might change in the future. The returned
+// method will be called once per packet batch.
+func (c *Interceptor) BindRTCPReader(reader interceptor.RTCPReader) interceptor.RTCPReader {
+	return interceptor.RTCPReaderFunc(func(b []byte, a interceptor.Attributes) (int, interceptor.Attributes, error) {
+		i, attr, err := reader.Read(b, a)
+		if err != nil {
+			return 0, nil, err
+		}
+		buf := make([]byte, i)
+
+		copy(buf, b[:i])
+
+		if attr == nil {
+			attr = make(interceptor.Attributes)
+		}
+
+		pkts, err := attr.GetRTCPPackets(buf[:i])
+		if err != nil {
+			return 0, nil, err
+		}
+		if err = c.estimator.WriteRTCP(pkts, attr); err != nil {
+			return 0, nil, err
+		}
+
+		return i, attr, nil
+	})
+}
+
+// BindLocalStream lets you modify any outgoing RTP packets. It is called once
+// for per LocalStream. The returned method will be called once per rtp packet.
+func (c *Interceptor) BindLocalStream(info *interceptor.StreamInfo, writer interceptor.RTPWriter) interceptor.RTPWriter {
+	var hdrExtID uint8
+	for _, e := range info.RTPHeaderExtensions {
+		if e.URI == transportCCURI {
+			hdrExtID = uint8(e.ID)
+			break
+		}
+	}
+	if hdrExtID == 0 { // Nothing to do if header extension ID is 0, because 0 is an invalid extension ID. Means stream is not using TWCC.
+		return writer
+	}
+
+	return c.estimator.AddStream(info, writer)
+}
+
+// Close closes the interceptor and the associated bandwidth estimator.
+func (c *Interceptor) Close() error {
+	return c.estimator.Close()
+}
diff --git a/pkg/gcc/adaptive_threshold.go b/pkg/gcc/adaptive_threshold.go
new file mode 100644
index 0000000..c7a6012
--- /dev/null
+++ b/pkg/gcc/adaptive_threshold.go
@@ -0,0 +1,98 @@
+package gcc
+
+import (
+	"math"
+	"time"
+)
+
+const (
+	maxDeltas = 60
+)
+
+type adaptiveThresholdOption func(*adaptiveThreshold)
+
+func setInitialThreshold(t time.Duration) adaptiveThresholdOption {
+	return func(at *adaptiveThreshold) {
+		at.thresh = t
+	}
+}
+
+// adaptiveThreshold implements a threshold that continuously adapts depending on
+// the current measurements/estimates. This is necessary to avoid starving GCC
+// in the presence of concurrent TCP flows by allowing larger Queueing delays,
+// when measurements/estimates increase. overuseCoefficientU and
+// overuseCoefficientD define by how much the threshold adapts. We basically
+// want the threshold to increase fast, if the measurement is outside [-thresh,
+// thresh] and decrease slowly if it is within.
+//
+// See https://datatracker.ietf.org/doc/html/draft-ietf-rmcat-gcc-02#section-5.4
+// or [Analysis and Design of the Google Congestion Control for Web Real-time
+// Communication (WebRTC)](https://c3lab.poliba.it/images/6/65/Gcc-analysis.pdf)
+// for a more detailed description
+type adaptiveThreshold struct {
+	thresh                 time.Duration
+	overuseCoefficientUp   float64
+	overuseCoefficientDown float64
+	min                    time.Duration
+	max                    time.Duration
+	lastUpdate             time.Time
+	numDeltas              int
+}
+
+// newAdaptiveThreshold initializes a new adaptiveThreshold with default
+// values taken from draft-ietf-rmcat-gcc-02
+func newAdaptiveThreshold(opts ...adaptiveThresholdOption) *adaptiveThreshold {
+	at := &adaptiveThreshold{
+		thresh:                 time.Duration(12500 * float64(time.Microsecond)),
+		overuseCoefficientUp:   0.01,
+		overuseCoefficientDown: 0.00018,
+		min:                    6 * time.Millisecond,
+		max:                    600 * time.Millisecond,
+		lastUpdate:             time.Time{},
+		numDeltas:              0,
+	}
+	for _, opt := range opts {
+		opt(at)
+	}
+	return at
+}
+
+func (a *adaptiveThreshold) compare(estimate, dt time.Duration) (usage, time.Duration, time.Duration) {
+	a.numDeltas++
+	if a.numDeltas < 2 {
+		return usageNormal, estimate, a.max
+	}
+	t := time.Duration(minInt(a.numDeltas, maxDeltas)) * estimate
+	use := usageNormal
+	if t > a.thresh {
+		use = usageOver
+	} else if t < -a.thresh {
+		use = usageUnder
+	}
+	thresh := a.thresh
+	a.update(t)
+	return use, t, thresh
+}
+
+func (a *adaptiveThreshold) update(estimate time.Duration) {
+	now := time.Now()
+	if a.lastUpdate.IsZero() {
+		a.lastUpdate = now
+	}
+	absEstimate := time.Duration(math.Abs(float64(estimate.Microseconds()))) * time.Microsecond
+	if absEstimate > a.thresh+15*time.Millisecond {
+		a.lastUpdate = now
+		return
+	}
+	k := a.overuseCoefficientUp
+	if absEstimate < a.thresh {
+		k = a.overuseCoefficientDown
+	}
+	maxTimeDelta := 100 * time.Millisecond
+	timeDelta := time.Duration(minInt(int(now.Sub(a.lastUpdate).Milliseconds()), int(maxTimeDelta.Milliseconds()))) * time.Millisecond
+	d := absEstimate - a.thresh
+	add := k * float64(d.Milliseconds()) * float64(timeDelta.Milliseconds())
+	a.thresh += time.Duration(add) * 1000 * time.Microsecond
+	a.thresh = clampDuration(a.thresh, a.min, a.max)
+	a.lastUpdate = now
+}
diff --git a/pkg/gcc/arrival_group.go b/pkg/gcc/arrival_group.go
new file mode 100644
index 0000000..e31bb01
--- /dev/null
+++ b/pkg/gcc/arrival_group.go
@@ -0,0 +1,31 @@
+package gcc
+
+import (
+	"fmt"
+	"time"
+
+	"github.com/pion/interceptor/internal/cc"
+)
+
+type arrivalGroup struct {
+	packets   []cc.Acknowledgment
+	departure time.Time
+	arrival   time.Time
+	rtt       time.Duration
+}
+
+func (g *arrivalGroup) add(a cc.Acknowledgment) {
+	g.packets = append(g.packets, a)
+	g.arrival = a.Arrival
+	g.departure = a.Departure
+	g.rtt = a.RTT
+}
+
+func (g arrivalGroup) String() string {
+	s := "ARRIVALGROUP:\n"
+	s += fmt.Sprintf("\tARRIVAL:\t%v\n", int64(float64(g.arrival.UnixNano())/1e+6))
+	s += fmt.Sprintf("\tDEPARTURE:\t%v\n", int64(float64(g.departure.UnixNano())/1e+6))
+	s += fmt.Sprintf("\tRTT:\t%v\n", g.rtt)
+	s += fmt.Sprintf("\tPACKETS:\n%v\n", g.packets)
+	return s
+}
diff --git a/pkg/gcc/arrival_group_accumulator.go b/pkg/gcc/arrival_group_accumulator.go
new file mode 100644
index 0000000..ece28cc
--- /dev/null
+++ b/pkg/gcc/arrival_group_accumulator.go
@@ -0,0 +1,58 @@
+package gcc
+
+import (
+	"time"
+
+	"github.com/pion/interceptor/internal/cc"
+)
+
+type arrivalGroupAccumulator struct {
+	interDepartureThreshold          time.Duration
+	interArrivalThreshold            time.Duration
+	interGroupDelayVariationTreshold time.Duration
+}
+
+func newArrivalGroupAccumulator() *arrivalGroupAccumulator {
+	return &arrivalGroupAccumulator{
+		interDepartureThreshold:          5 * time.Millisecond,
+		interArrivalThreshold:            5 * time.Millisecond,
+		interGroupDelayVariationTreshold: 0,
+	}
+}
+
+func (a *arrivalGroupAccumulator) run(in <-chan cc.Acknowledgment) <-chan arrivalGroup {
+	out := make(chan arrivalGroup)
+	go func() {
+		init := false
+		group := arrivalGroup{}
+		for next := range in {
+			if !init {
+				group.add(next)
+				init = true
+				continue
+			}
+			if next.Arrival.Before(group.arrival) {
+				// ignore out of order arrivals
+				continue
+			}
+			if next.Departure.After(group.departure) {
+				if interDepartureTimePkt(group, next) <= a.interDepartureThreshold {
+					group.add(next)
+					continue
+				}
+
+				if interArrivalTimePkt(group, next) <= a.interArrivalThreshold &&
+					interGroupDelayVariationPkt(group, next) < a.interGroupDelayVariationTreshold {
+					group.add(next)
+					continue
+				}
+
+				out <- group
+				group = arrivalGroup{}
+				group.add(next)
+			}
+		}
+		close(out)
+	}()
+	return out
+}
diff --git a/pkg/gcc/delay_based_bwe.go b/pkg/gcc/delay_based_bwe.go
new file mode 100644
index 0000000..7b848cb
--- /dev/null
+++ b/pkg/gcc/delay_based_bwe.go
@@ -0,0 +1,140 @@
+package gcc
+
+import (
+	"sync"
+	"time"
+
+	"github.com/pion/interceptor/internal/cc"
+)
+
+const (
+	decreaseEMAAlpha = 0.95
+	beta             = 0.85
+)
+
+// DelayStats contains some internal statistics of the delay based congestion
+// controller
+type DelayStats struct {
+	Measurement      time.Duration
+	Estimate         time.Duration
+	Threshold        time.Duration
+	lastReceiveDelta time.Duration
+
+	Usage         usage
+	State         state
+	TargetBitrate int
+	RTT           time.Duration
+}
+
+type estimator interface {
+	updateEstimate(measurement time.Duration) time.Duration
+}
+
+type estimatorFunc func(time.Duration) time.Duration
+
+func (f estimatorFunc) updateEstimate(d time.Duration) time.Duration {
+	return f(d)
+}
+
+type now func() time.Time
+
+type delayController struct {
+	ackPipe     chan<- cc.Acknowledgment
+	ackRatePipe chan<- cc.Acknowledgment
+	ackRTTPipe  chan<- []cc.Acknowledgment
+
+	onUpdateCallback func(DelayStats)
+
+	wg sync.WaitGroup
+}
+
+type delayControllerConfig struct {
+	nowFn          now
+	initialBitrate int
+	minBitrate     int
+	maxBitrate     int
+}
+
+func newDelayController(c delayControllerConfig) *delayController {
+	ackPipe := make(chan cc.Acknowledgment)
+	ackRatePipe := make(chan cc.Acknowledgment)
+	ackRTTPipe := make(chan []cc.Acknowledgment)
+
+	delayController := &delayController{
+		ackPipe:     ackPipe,
+		ackRatePipe: ackRatePipe,
+		ackRTTPipe:  ackRTTPipe,
+		wg:          sync.WaitGroup{},
+	}
+
+	rc := newRateCalculator(500 * time.Millisecond)
+	re := newRTTEstimator()
+
+	reaceivedRate := rc.run(ackRatePipe)
+	rtt := re.run(ackRTTPipe)
+
+	arrivalGroupAccumulator := newArrivalGroupAccumulator()
+	slopeEstimator := newSlopeEstimator(newKalman())
+	overuseDetector := newOveruseDetector(newAdaptiveThreshold(), 10*time.Millisecond)
+	rateController := newRateController(c.nowFn, c.initialBitrate, c.minBitrate, c.maxBitrate)
+
+	arrival := arrivalGroupAccumulator.run(ackPipe)
+	estimate := slopeEstimator.run(arrival)
+	state := overuseDetector.run(estimate)
+	delayStats := rateController.run(state, reaceivedRate, rtt)
+	delayController.loop(delayStats)
+
+	return delayController
+}
+
+func (d *delayController) onUpdate(f func(DelayStats)) {
+	d.onUpdateCallback = f
+}
+
+func (d *delayController) updateDelayEstimate(acks []cc.Acknowledgment) {
+	for _, ack := range acks {
+		d.ackPipe <- ack
+		d.ackRatePipe <- ack
+	}
+	d.ackRTTPipe <- acks
+}
+
+func (d *delayController) loop(in chan DelayStats) {
+	d.wg.Add(1)
+	defer d.wg.Done()
+
+	go func() {
+		for next := range in {
+			if d.onUpdateCallback != nil {
+				d.onUpdateCallback(next)
+			}
+		}
+	}()
+}
+
+func (d *delayController) Close() error {
+	close(d.ackPipe)
+	close(d.ackRTTPipe)
+	close(d.ackRatePipe)
+	d.wg.Wait()
+	return nil
+}
+
+func interArrivalTimePkt(a arrivalGroup, b cc.Acknowledgment) time.Duration {
+	return b.Arrival.Sub(a.arrival)
+}
+
+func interDepartureTimePkt(a arrivalGroup, b cc.Acknowledgment) time.Duration {
+	if len(a.packets) == 0 {
+		return 0
+	}
+	return b.Departure.Sub(a.packets[len(a.packets)-1].Departure)
+}
+
+func interGroupDelayVariationPkt(a arrivalGroup, b cc.Acknowledgment) time.Duration {
+	return b.Arrival.Sub(a.arrival) - b.Departure.Sub(a.departure)
+}
+
+func interGroupDelayVariation(a, b arrivalGroup) time.Duration {
+	return b.arrival.Sub(a.arrival) - b.departure.Sub(a.departure)
+}
diff --git a/pkg/gcc/gcc.go b/pkg/gcc/gcc.go
new file mode 100644
index 0000000..a857ebf
--- /dev/null
+++ b/pkg/gcc/gcc.go
@@ -0,0 +1,31 @@
+// Package gcc implements Google Congestion Control for bandwidth estimation
+package gcc
+
+import "time"
+
+func minInt(a, b int) int {
+	if a < b {
+		return a
+	}
+	return b
+}
+
+func clampInt(b, min, max int) int {
+	if min < b && b < max {
+		return b
+	}
+	if b < min {
+		return min
+	}
+	return max
+}
+
+func clampDuration(d, min, max time.Duration) time.Duration {
+	if min <= d && d <= max {
+		return d
+	}
+	if d <= min {
+		return min
+	}
+	return max
+}
diff --git a/pkg/gcc/kalman.go b/pkg/gcc/kalman.go
new file mode 100644
index 0000000..340a1a4
--- /dev/null
+++ b/pkg/gcc/kalman.go
@@ -0,0 +1,91 @@
+package gcc
+
+import (
+	"math"
+	"time"
+)
+
+const (
+	chi = 0.001
+)
+
+type kalmanOption func(*kalman)
+
+type kalman struct {
+	gain                   float64
+	estimate               time.Duration
+	processUncertainty     float64 // Q_i
+	estimateError          float64
+	measurementUncertainty float64
+
+	disableMeasurementUncertaintyUpdates bool
+}
+
+func initEstimate(e time.Duration) kalmanOption {
+	return func(k *kalman) {
+		k.estimate = e
+	}
+}
+
+func initProcessUncertainty(p float64) kalmanOption {
+	return func(k *kalman) {
+		k.processUncertainty = p
+	}
+}
+
+func initEstimateError(e float64) kalmanOption {
+	return func(k *kalman) {
+		k.estimateError = e * e // Only need variance from now on
+	}
+}
+
+func initMeasurementUncertainty(u float64) kalmanOption {
+	return func(k *kalman) {
+		k.measurementUncertainty = u
+	}
+}
+
+func setDisableMeasurementUncertaintyUpdates(b bool) kalmanOption {
+	return func(k *kalman) {
+		k.disableMeasurementUncertaintyUpdates = b
+	}
+}
+
+func newKalman(opts ...kalmanOption) *kalman {
+	k := &kalman{
+		gain:                                 0,
+		estimate:                             0,
+		processUncertainty:                   1e-3,
+		estimateError:                        0.1,
+		measurementUncertainty:               0,
+		disableMeasurementUncertaintyUpdates: false,
+	}
+	for _, opt := range opts {
+		opt(k)
+	}
+	return k
+}
+
+func (k *kalman) updateEstimate(measurement time.Duration) time.Duration {
+	z := measurement - k.estimate
+
+	zms := float64(z.Microseconds()) / 1000.0
+
+	if !k.disableMeasurementUncertaintyUpdates {
+		alpha := math.Pow((1 - chi), 30.0/(1000.0*5*float64(time.Millisecond)))
+		root := math.Sqrt(k.measurementUncertainty)
+		root3 := 3 * root
+		if zms > root3 {
+			k.measurementUncertainty = math.Max(alpha*k.measurementUncertainty+(1-alpha)*root3*root3, 1)
+		}
+		k.measurementUncertainty = math.Max(alpha*k.measurementUncertainty+(1-alpha)*zms*zms, 1)
+	}
+
+	estimateUncertainty := k.estimateError + k.processUncertainty
+	k.gain = estimateUncertainty / (estimateUncertainty + k.measurementUncertainty)
+
+	k.estimate += time.Duration(k.gain * zms * float64(time.Millisecond))
+
+	k.estimateError = (1 - k.gain) * estimateUncertainty
+	return k.estimate
+}
diff --git a/pkg/gcc/leaky_bucket_pacer.go b/pkg/gcc/leaky_bucket_pacer.go
new file mode 100644
index 0000000..6d4af3f
--- /dev/null
+++ b/pkg/gcc/leaky_bucket_pacer.go
@@ -0,0 +1,149 @@
+package gcc
+
+import (
+	"container/list"
+	"sync"
+	"time"
+
+	"github.com/pion/interceptor"
+	"github.com/pion/logging"
+	"github.com/pion/rtp"
+)
+
+type item struct {
+	header     *rtp.Header
+	payload    *[]byte
+	size       int
+	attributes interceptor.Attributes
+}
+
+// LeakyBucketPacer implements a leaky bucket pacing algorithm
+type LeakyBucketPacer struct {
+	log logging.LeveledLogger
+
+	f                 float64
+	targetBitrate     int
+	targetBitrateLock sync.Mutex
+
+	pacingInterval time.Duration
+
+	qLock sync.RWMutex
+	queue *list.List
+	done  chan struct{}
+
+	ssrcToWriter map[uint32]interceptor.RTPWriter
+	writerLock   sync.Mutex
+
+	pool *sync.Pool
+}
+
+// NewLeakyBucketPacer initializes a new LeakyBucketPacer
+func NewLeakyBucketPacer(initialBitrate int) *LeakyBucketPacer {
+	p := &LeakyBucketPacer{
+		log:            logging.NewDefaultLoggerFactory().NewLogger("pacer"),
+		f:              1.5,
+		targetBitrate:  initialBitrate,
+		pacingInterval: 5 * time.Millisecond,
+		qLock:          sync.RWMutex{},
+		queue:          list.New(),
+		done:           make(chan struct{}),
+		ssrcToWriter:   map[uint32]interceptor.RTPWriter{},
+		pool:           &sync.Pool{},
+	}
+	p.pool = &sync.Pool{
+		New: func() interface{} {
+			b := make([]byte, 1460)
+			return &b
+		},
+	}
+
+	go p.Run()
+	return p
+}
+
+// AddStream adds a new stream and its corresponding writer to the pacer
+func (p *LeakyBucketPacer) AddStream(ssrc uint32, writer interceptor.RTPWriter) {
+	p.writerLock.Lock()
+	defer p.writerLock.Unlock()
+	p.ssrcToWriter[ssrc] = writer
+}
+
+// SetTargetBitrate updates the target bitrate at which the pacer is allowed to
+// send packets. The pacer may exceed this limit by p.f
+func (p *LeakyBucketPacer) SetTargetBitrate(rate int) {
+	p.targetBitrateLock.Lock()
+	defer p.targetBitrateLock.Unlock()
+	p.targetBitrate = int(p.f * float64(rate))
+}
+
+func (p *LeakyBucketPacer) getTargetBitrate() int {
+	p.targetBitrateLock.Lock()
+	defer p.targetBitrateLock.Unlock()
+
+	return p.targetBitrate
+}
+
+// Write sends a packet with header and payload the a previously registered
+// stream.
+func (p *LeakyBucketPacer) Write(header *rtp.Header, payload []byte, attributes interceptor.Attributes) (int, error) {
+	buf := p.pool.Get().(*[]byte)
+	copy(*buf, payload)
+	hdr := header.Clone()
+
+	p.qLock.Lock()
+	p.queue.PushBack(&item{
+		header:     &hdr,
+		payload:    buf,
+		size:       len(payload),
+		attributes: attributes,
+	})
+	p.qLock.Unlock()
+
+	return header.MarshalSize() + len(payload), nil
+}
+
+// Run starts the LeakyBucketPacer
+func (p *LeakyBucketPacer) Run() {
+	ticker := time.NewTicker(p.pacingInterval)
+
+	lastSent := time.Now()
+	for {
+		select {
+		case <-p.done:
+			return
+		case now := <-ticker.C:
+			budget := int(float64(now.Sub(lastSent).Milliseconds()) * float64(p.getTargetBitrate()) / 8000.0)
+			p.qLock.Lock()
+			for p.queue.Len() != 0 && budget > 0 {
+				p.log.Infof("budget=%v, len(queue)=%v, targetBitrate=%v", budget, p.queue.Len(), p.targetBitrate)
+				next := p.queue.Remove(p.queue.Front()).(*item)
+				p.qLock.Unlock()
+
+				writer, ok := p.ssrcToWriter[next.header.SSRC]
+				if !ok {
+					p.log.Warnf("no writer found for ssrc: %v", next.header.SSRC)
+					p.pool.Put(next.payload)
+					p.qLock.Lock()
+					continue
+				}
+
+				n, err := writer.Write(next.header, (*next.payload)[:next.size], next.attributes)
+				if err != nil {
+					p.log.Errorf("failed to write packet: %v", err)
+				}
+				lastSent = now
+				budget -= n
+
+				p.pool.Put(next.payload)
+				p.qLock.Lock()
+			}
+			p.qLock.Unlock()
+		}
+	}
+}
+
+// Close closes the LeakyBucketPacer
+func (p *LeakyBucketPacer) Close() error {
+	close(p.done)
+	return nil
+}
diff --git a/pkg/gcc/loss_based_bwe.go b/pkg/gcc/loss_based_bwe.go
new file mode 100644
index 0000000..4faa98c
--- /dev/null
+++ b/pkg/gcc/loss_based_bwe.go
@@ -0,0 +1,106 @@
+package gcc
+
+import (
+	"math"
+	"sync"
+	"time"
+
+	"github.com/pion/interceptor/internal/cc"
+	"github.com/pion/logging"
+)
+
+const (
+	// constants from
+	// https://datatracker.ietf.org/doc/html/draft-ietf-rmcat-gcc-02#section-6
+
+	increaseLossThreshold = 0.02
+	increaseTimeThreshold = 200 * time.Millisecond
+	increaseFactor        = 1.05
+
+	decreaseLossThreshold = 0.1
+	decreaseTimeThreshold = 200 * time.Millisecond
+)
+
+// LossStats contains internal statistics of the loss based controller
+type LossStats struct {
+	TargetBitrate int
+	AverageLoss   float64
+}
+
+type lossBasedBandwidthEstimator struct {
+	lock           sync.Mutex
+	maxBitrate     int
+	minBitrate     int
+	bitrate        int
+	averageLoss    float64
+	lastLossUpdate time.Time
+	lastIncrease   time.Time
+	lastDecrease   time.Time
+	log            logging.LeveledLogger
+}
+
+func newLossBasedBWE(initialBitrate int) *lossBasedBandwidthEstimator {
+	return &lossBasedBandwidthEstimator{
+		lock:           sync.Mutex{},
+		maxBitrate:     100_000_000, // 100 mbit
+		minBitrate:     100_000,     // 100 kbit
+		bitrate:        initialBitrate,
+		averageLoss:    0,
+		lastLossUpdate: time.Time{},
+		lastIncrease:   time.Time{},
+		lastDecrease:   time.Time{},
+		log:            logging.NewDefaultLoggerFactory().NewLogger("gcc_loss_controller"),
+	}
+}
+
+func (e *lossBasedBandwidthEstimator) getEstimate(wantedRate int) LossStats {
+	e.lock.Lock()
+	defer e.lock.Unlock()
+
+	if e.bitrate <= 0 {
+		e.bitrate = clampInt(wantedRate, e.minBitrate, e.maxBitrate)
+	}
+	e.bitrate = minInt(wantedRate, e.bitrate)
+
+	return LossStats{
+		TargetBitrate: e.bitrate,
+		AverageLoss:   e.averageLoss,
+	}
+}
+
+func (e *lossBasedBandwidthEstimator) updateLossEstimate(results []cc.Acknowledgment) {
+	if len(results) == 0 {
+		return
+	}
+
+	packetsLost := 0
+	for _, p := range results {
+		if p.Arrival.IsZero() {
+			packetsLost++
+		}
+	}
+
+	e.lock.Lock()
+	defer e.lock.Unlock()
+
+	lossRatio := float64(packetsLost) / float64(len(results))
+	e.averageLoss = e.average(time.Since(e.lastLossUpdate), e.averageLoss, lossRatio)
+	e.lastLossUpdate = time.Now()
+
+	increaseLoss := math.Max(e.averageLoss, lossRatio)
+	decreaseLoss := math.Min(e.averageLoss, lossRatio)
+
+	if increaseLoss < increaseLossThreshold && time.Since(e.lastIncrease) > increaseTimeThreshold {
+		e.log.Infof("loss controller increasing; averageLoss: %v, decreaseLoss: %v, increaseLoss: %v", e.averageLoss, decreaseLoss, increaseLoss)
+		e.lastIncrease = time.Now()
+		e.bitrate = clampInt(int(increaseFactor*float64(e.bitrate)), e.minBitrate, e.maxBitrate)
+	} else if decreaseLoss > decreaseLossThreshold && time.Since(e.lastDecrease) > decreaseTimeThreshold {
+		e.log.Infof("loss controller decreasing; averageLoss: %v, decreaseLoss: %v, increaseLoss: %v", e.averageLoss, decreaseLoss, increaseLoss)
+		e.lastDecrease = time.Now()
+		e.bitrate = clampInt(int(float64(e.bitrate)*(1-0.5*decreaseLoss)), e.minBitrate, e.maxBitrate)
+	}
+}
+
+func (e *lossBasedBandwidthEstimator) average(delta time.Duration, prev, sample float64) float64 {
+	return sample + math.Exp(-float64(delta.Milliseconds())/200.0)*(prev-sample)
+}
diff --git a/pkg/gcc/noop_pacer.go b/pkg/gcc/noop_pacer.go
new file mode 100644
index 0000000..9188afb
--- /dev/null
+++ b/pkg/gcc/noop_pacer.go
@@ -0,0 +1,57 @@
+package gcc
+
+import (
+	"errors"
+	"fmt"
+	"sync"
+
+	"github.com/pion/interceptor"
+	"github.com/pion/rtp"
+)
+
+// ErrUnknownStream is returned when trying to send a packet with a SSRC that
+// was never registered with any stream
+var ErrUnknownStream = errors.New("unknown ssrc")
+
+// NoOpPacer implements a pacer that always immediately sends incoming packets
+type NoOpPacer struct {
+	lock         sync.Mutex
+	ssrcToWriter map[uint32]interceptor.RTPWriter
+}
+
+// NewNoOpPacer initializes a new NoOpPacer
+func NewNoOpPacer() *NoOpPacer {
+	return &NoOpPacer{
+		lock:         sync.Mutex{},
+		ssrcToWriter: map[uint32]interceptor.RTPWriter{},
+	}
+}
+
+// SetTargetBitrate sets the bitrate at which the pacer sends data. NoOp for
+// NoOp pacer.
+func (p *NoOpPacer) SetTargetBitrate(int) {
+}
+
+// AddStream adds a stream and corresponding writer to the p
+func (p *NoOpPacer) AddStream(ssrc uint32, writer interceptor.RTPWriter) {
+	p.lock.Lock()
+	defer p.lock.Unlock()
+	p.ssrcToWriter[ssrc] = writer
+}
+
+// Write sends a packet with header and payload to a previously added stream
+func (p *NoOpPacer) Write(header *rtp.Header, payload []byte, attributes interceptor.Attributes) (int, error) {
+	p.lock.Lock()
+	defer p.lock.Unlock()
+
+	if w, ok := p.ssrcToWriter[header.SSRC]; ok {
+		return w.Write(header, payload, attributes)
+	}
+
+	return 0, fmt.Errorf("%w: %v", ErrUnknownStream, header.SSRC)
+}
+
+// Close closes p
+func (p *NoOpPacer) Close() error {
+	return nil
+}
diff --git a/pkg/gcc/overuse_detector.go b/pkg/gcc/overuse_detector.go
new file mode 100644
index 0000000..467b641
--- /dev/null
+++ b/pkg/gcc/overuse_detector.go
@@ -0,0 +1,79 @@
+package gcc
+
+import (
+	"time"
+)
+
+type threshold interface {
+	compare(estimate time.Duration, delta time.Duration) (usage, time.Duration, time.Duration)
+}
+
+type overuseDetector struct {
+	threshold   threshold
+	overuseTime time.Duration
+}
+
+func newOveruseDetector(thresh threshold, overuseTime time.Duration) *overuseDetector {
+	return &overuseDetector{
+		threshold:   thresh,
+		overuseTime: overuseTime,
+	}
+}
+
+func (d *overuseDetector) run(in <-chan DelayStats) <-chan DelayStats {
+	out := make(chan DelayStats)
+	go func() {
+		lastEstimate := 0 * time.Millisecond
+		lastUpdate := time.Now()
+		var increasingDuration time.Duration
+		var increasingCounter int
+
+		for next := range in {
+			now := time.Now()
+			delta := now.Sub(lastUpdate)
+			lastUpdate = now
+
+			thresholdUse, estimate, currentThreshold := d.threshold.compare(next.Estimate, next.lastReceiveDelta)
+
+			use := usageNormal
+			if thresholdUse == usageOver {
+				if increasingDuration == 0 {
+					increasingDuration = delta / 2
+				} else {
+					increasingDuration += delta
+				}
+				increasingCounter++
+				if increasingDuration > d.overuseTime && increasingCounter > 1 {
+					if estimate > lastEstimate {
+						use = usageOver
+					}
+				}
+			}
+			if thresholdUse == usageUnder {
+				increasingCounter = 0
+				increasingDuration = 0
+				use = usageUnder
+			}
+
+			if thresholdUse == usageNormal {
+				increasingDuration = 0
+				increasingCounter = 0
+				use = usageNormal
+			}
+			lastEstimate = estimate
+
+			out <- DelayStats{
+				Measurement:      next.Measurement,
+				Estimate:         estimate,
+				Threshold:        currentThreshold,
+				lastReceiveDelta: delta,
+				Usage:            use,
+				State:            0,
+				TargetBitrate:    0,
+				RTT:              0,
+			}
+		}
+		close(out)
+	}()
+	return out
+}
diff --git a/pkg/gcc/rate_calculator.go b/pkg/gcc/rate_calculator.go
new file mode 100644
index 0000000..56dbe51
--- /dev/null
+++ b/pkg/gcc/rate_calculator.go
@@ -0,0 +1,64 @@
+package gcc
+
+import (
+	"time"
+
+	"github.com/pion/interceptor/internal/cc"
+)
+
+type rateCalculator struct {
+	window time.Duration
+}
+
+func newRateCalculator(window time.Duration) *rateCalculator {
+	return &rateCalculator{
+		window: window,
+	}
+}
+
+func (c *rateCalculator) run(in <-chan cc.Acknowledgment) <-chan int {
+	out := make(chan int)
+	go func() {
+		var history []cc.Acknowledgment
+		init := false
+		sum := 0
+		for next := range in {
+			if next.Arrival.IsZero() {
+				// Ignore packet if it didn't arrive
+				continue
+			}
+			history = append(history, next)
+			sum += next.Size
+
+			if !init {
+				init = true
+				// Don't know any timeframe here, only arrival of last packet,
+				// which is by definition in the window that ends with the last
+				// arrival time
+				out <- next.Size * 8
+				continue
+			}
+
+			del := 0
+			for _, ack := range history {
+				deadline := next.Arrival.Add(-c.window)
+				if !ack.Arrival.Before(deadline) {
+					break
+				}
+				del++
+				sum -= ack.Size
+			}
+			history = history[del:]
+			if len(history) == 0 {
+				out <- 0
+				continue
+			}
+			dt := next.Arrival.Sub(history[0].Arrival)
+			bits := 8 * sum
+			rate := int(float64(bits) / dt.Seconds())
+			out <- rate
+		}
+		close(out)
+	}()
+	return out
+}
diff --git a/pkg/gcc/rate_controller.go b/pkg/gcc/rate_controller.go
new file mode 100644
index 0000000..9eaab67
--- /dev/null
+++ b/pkg/gcc/rate_controller.go
@@ -0,0 +1,157 @@
+package gcc
+
+import (
+	"math"
+	"time"
+
+	"github.com/pion/logging"
+)
+
+type rateController struct {
+	log                  logging.LeveledLogger
+	now                  now
+	initialTargetBitrate int
+	minBitrate           int
+	maxBitrate           int
+
+	target             int
+	lastUpdate         time.Time
+	lastState          state
+	latestRTT          time.Duration
+	latestReceivedRate int
+	latestDecreaseRate *exponentialMovingAverage
+}
+
+type exponentialMovingAverage struct {
+	average      float64
+	variance     float64
+	stdDeviation float64
+}
+
+func (a *exponentialMovingAverage) update(value float64) {
+	if a.average == 0.0 {
+		a.average = value
+	} else {
+		x := value - a.average
+		a.average += decreaseEMAAlpha * x
+		a.variance = (1 - decreaseEMAAlpha) * (a.variance + decreaseEMAAlpha*x*x)
+		a.stdDeviation = math.Sqrt(a.variance)
+	}
+}
+
+func newRateController(now now, initialTargetBitrate, minBitrate, maxBitrate int) *rateController {
+	return &rateController{
+		log:                  logging.NewDefaultLoggerFactory().NewLogger("gcc_rate_controller"),
+		now:                  now,
+		initialTargetBitrate: initialTargetBitrate,
+		minBitrate:           minBitrate,
+		maxBitrate:           maxBitrate,
+		target:               initialTargetBitrate,
+		lastUpdate:           time.Time{},
+		lastState:            stateIncrease,
+		latestRTT:            0,
+		latestReceivedRate:   0,
+		latestDecreaseRate:   &exponentialMovingAverage{},
+	}
+}
+
+func (c *rateController) run(in <-chan DelayStats, receivedRate <-chan int, rtt <-chan time.Duration) chan DelayStats {
+	out := make(chan DelayStats)
+	go func() {
+		c.lastUpdate = c.now()
+
+		defer func() {
+			close(out)
+		}()
+
+		var latestStats DelayStats
+		init := false
+
+		for {
+			select {
+			case c.latestReceivedRate = <-receivedRate:
+			case c.latestRTT = <-rtt:
+			case nextStats, ok := <-in:
+				if !ok {
+					return
+				}
+				if !init {
+					init = true
+					latestStats = nextStats
+					latestStats.State = stateIncrease
+					continue
+				}
+				latestStats = nextStats
+				latestStats.State = latestStats.State.transition(nextStats.Usage)
+
+				now := time.Now()
+				switch latestStats.State {
+				case stateHold:
+				case stateIncrease:
+					c.target = clampInt(c.increase(now), c.minBitrate, c.maxBitrate)
+					out <- DelayStats{
+						Measurement:      latestStats.Measurement,
+						Estimate:         latestStats.Estimate,
+						Threshold:        latestStats.Threshold,
+						lastReceiveDelta: latestStats.lastReceiveDelta,
+						Usage:            latestStats.Usage,
+						State:            latestStats.State,
+						TargetBitrate:    c.target,
+						RTT:              c.latestRTT,
+					}
+
+				case stateDecrease:
+					c.target = clampInt(c.decrease(), c.minBitrate, c.maxBitrate)
+					out <- DelayStats{
+						Measurement:      latestStats.Measurement,
+						Estimate:         latestStats.Estimate,
+						Threshold:        latestStats.Threshold,
+						lastReceiveDelta: latestStats.lastReceiveDelta,
+						Usage:            latestStats.Usage,
+						State:            latestStats.State,
+						TargetBitrate:    c.target,
+						RTT:              c.latestRTT,
+					}
+				}
+			}
+		}
+	}()
+	return out
+}
+
+func (c *rateController) increase(now time.Time) int {
+	if c.latestDecreaseRate.average > 0 && float64(c.latestReceivedRate) > c.latestDecreaseRate.average-3*c.latestDecreaseRate.stdDeviation &&
+		float64(c.latestReceivedRate) < c.latestDecreaseRate.average+3*c.latestDecreaseRate.stdDeviation {
+		bitsPerFrame := float64(c.target) / 30.0
+		packetsPerFrame := math.Ceil(bitsPerFrame / (1200 * 8))
+		expectedPacketSizeBits := bitsPerFrame / packetsPerFrame
+
+		responseTime := 100*time.Millisecond + c.latestRTT
+		alpha := 0.5 * math.Min(float64(now.Sub(c.lastUpdate).Milliseconds())/float64(responseTime.Milliseconds()), 1.0)
+		increase := int(math.Max(1000.0, alpha*expectedPacketSizeBits))
+		c.lastUpdate = now
+		return int(math.Min(float64(c.target+increase), 1.5*float64(c.latestReceivedRate)))
+	}
+	eta := math.Pow(1.08, math.Min(float64(now.Sub(c.lastUpdate).Milliseconds())/1000, 1.0))
+	c.lastUpdate = now
+
+	rate := int(eta * float64(c.target))
+
+	// maximum increase to 1.5 * received rate
+	received := int(1.5 * float64(c.latestReceivedRate))
+	if rate > received && received > c.target {
+		return received
+	}
+
+	if rate < c.target {
+		return c.target
+	}
+	return rate
+}
+
+func (c *rateController) decrease() int {
+	target := int(beta * float64(c.latestReceivedRate))
+	c.latestDecreaseRate.update(float64(c.latestReceivedRate))
+	c.lastUpdate = c.now()
+	return target
+}
diff --git a/pkg/gcc/rtt_estimator.go b/pkg/gcc/rtt_estimator.go
new file mode 100644
index 0000000..b2275e0
--- /dev/null
+++ b/pkg/gcc/rtt_estimator.go
@@ -0,0 +1,49 @@
+package gcc
+
+import (
+	"math"
+	"time"
+
+	"github.com/pion/interceptor/internal/cc"
+)
+
+type rttEstimator struct {
+	samples int
+}
+
+func newRTTEstimator() *rttEstimator {
+	return &rttEstimator{
+		samples: 100,
+	}
+}
+
+func (e *rttEstimator) run(in <-chan []cc.Acknowledgment) <-chan time.Duration {
+	out := make(chan time.Duration)
+	go func() {
+		history := []time.Duration{}
+		for acks := range in {
+			if len(acks) == 0 {
+				continue
+			}
+			minRTT := time.Duration(math.MaxInt64)
+			for _, ack := range acks {
+				if ack.RTT < minRTT {
+					minRTT = ack.RTT
+				}
+			}
+			history = append(history, minRTT)
+			if len(history) >= e.samples {
+				history = history[len(history)-e.samples:]
+			}
+
+			sum := time.Duration(0)
+			for _, rtt := range history {
+				sum += rtt
+			}
+			rtt := float64(sum.Milliseconds()) / float64(len(history)) * float64(time.Millisecond)
+			out <- time.Duration(rtt)
+		}
+		close(out)
+	}()
+	return out
+}
diff --git a/pkg/gcc/send_side_bwe.go b/pkg/gcc/send_side_bwe.go
new file mode 100644
index 0000000..9b7e763
--- /dev/null
+++ b/pkg/gcc/send_side_bwe.go
@@ -0,0 +1,207 @@
+package gcc
+
+import (
+	"sync"
+	"time"
+
+	"github.com/pion/interceptor"
+	"github.com/pion/interceptor/internal/cc"
+	"github.com/pion/rtcp"
+	"github.com/pion/rtp"
+)
+
+const (
+	transportCCURI = "http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01"
+	latestBitrate  = 10_000
+	minBitrate     = 5_000
+	maxBitrate     = 50_000_000
+)
+
+// Pacer is the interface implemented by packet pacers
+type Pacer interface {
+	interceptor.RTPWriter
+	AddStream(ssrc uint32, writer interceptor.RTPWriter)
+	SetTargetBitrate(int)
+	Close() error
+}
+
+// Stats contains internal statistics of the bandwidth estimator
+type Stats struct {
+	LossStats
+	DelayStats
+}
+
+// SendSideBWE implements a combination of loss and delay based GCC
+type SendSideBWE struct {
+	pacer           Pacer
+	lossController  *lossBasedBandwidthEstimator
+	delayController *delayController
+	feedbackAdapter *cc.FeedbackAdapter
+
+	onTargetBitrateChange func(bitrate int)
+
+	lock          sync.Mutex
+	latestStats   Stats
+	latestBitrate int
+	minBitrate    int
+	maxBitrate    int
+
+	close chan struct{}
+}
+
+// Option configures a bandwidth estimator
+type Option func(*SendSideBWE) error
+
+// SendSideBWEInitialBitrate sets the initial bitrate of new GCC interceptors
+func SendSideBWEInitialBitrate(rate int) Option {
+	return func(e *SendSideBWE) error {
+		e.latestBitrate = rate
+		return nil
+	}
+}
+
+// SendSideBWEPacer sets the pacing algorithm to use.
+func SendSideBWEPacer(p Pacer) Option {
+	return func(e *SendSideBWE) error {
+		e.pacer = p
+		return nil
+	}
+}
+
+// NewSendSideBWE creates a new sender side bandwidth estimator
+func NewSendSideBWE(opts ...Option) (*SendSideBWE, error) {
+	e := &SendSideBWE{
+		pacer:                 nil,
+		lossController:        nil,
+		delayController:       nil,
+		feedbackAdapter:       cc.NewFeedbackAdapter(),
+		onTargetBitrateChange: nil,
+		lock:                  sync.Mutex{},
+		latestStats:           Stats{},
+		latestBitrate:         latestBitrate,
+		minBitrate:            minBitrate,
+		maxBitrate:            maxBitrate,
+		close:                 make(chan struct{}),
+	}
+	for _, opt := range opts {
+		if err := opt(e); err != nil {
+			return nil, err
+		}
+	}
+	if e.pacer == nil {
+		e.pacer = NewLeakyBucketPacer(e.latestBitrate)
+	}
+	e.lossController = newLossBasedBWE(e.latestBitrate)
+	e.delayController = newDelayController(delayControllerConfig{
+		nowFn:          time.Now,
+		initialBitrate: e.latestBitrate,
+		minBitrate:     e.minBitrate,
+		maxBitrate:     e.maxBitrate,
+	})
+
+	e.delayController.onUpdate(e.onDelayUpdate)
+
+	return e, nil
+}
+
+// AddStream adds a new stream to the bandwidth estimator
+func (e *SendSideBWE) AddStream(info *interceptor.StreamInfo, writer interceptor.RTPWriter) interceptor.RTPWriter {
+	var hdrExtID uint8
+	for _, e := range info.RTPHeaderExtensions {
+		if e.URI == transportCCURI {
+			hdrExtID = uint8(e.ID)
+			break
+		}
+	}
+
+	e.pacer.AddStream(info.SSRC, interceptor.RTPWriterFunc(func(header *rtp.Header, payload []byte, attributes interceptor.Attributes) (int, error) {
+		if hdrExtID != 0 {
+			if attributes == nil {
+				attributes = make(interceptor.Attributes)
+			}
+			attributes.Set(cc.TwccExtensionAttributesKey, hdrExtID)
+		}
+		if err := e.feedbackAdapter.OnSent(time.Now(), header, len(payload), attributes); err != nil {
+			return 0, err
+		}
+		return writer.Write(header, payload, attributes)
+	}))
+	return e.pacer
+}
+
+// WriteRTCP adds some RTCP feedback to the bandwidth estimator
+func (e *SendSideBWE) WriteRTCP(pkts []rtcp.Packet, attributes interceptor.Attributes) error {
+	for _, pkt := range pkts {
+		acks, err := e.feedbackAdapter.OnFeedback(time.Now(), pkt)
+		if err != nil {
+			return err
+		}
+		e.lossController.updateLossEstimate(acks)
+		e.delayController.updateDelayEstimate(acks)
+	}
+	return nil
+}
+
+// GetTargetBitrate returns the current target bitrate
+func (e *SendSideBWE) GetTargetBitrate() int {
+	e.lock.Lock()
+	defer e.lock.Unlock()
+
+	return e.latestBitrate
+}
+
+// GetStats returns some internal statistics of the bandwidth estimator
+func (e *SendSideBWE) GetStats() map[string]interface{} {
+	e.lock.Lock()
+	defer e.lock.Unlock()
+
+	return map[string]interface{}{
+		"lossTargetBitrate":  e.latestStats.LossStats.TargetBitrate,
+		"averageLoss":        e.latestStats.AverageLoss,
+		"delayTargetBitrate": e.latestStats.DelayStats.TargetBitrate,
+		"delayMeasurement":   float64(e.latestStats.Measurement.Microseconds()) / 1000.0,
+		"delayEstimate":      float64(e.latestStats.Estimate.Microseconds()) / 1000.0,
+		"delayThreshold":     float64(e.latestStats.Threshold.Microseconds()) / 1000.0,
+		"rtt":                float64(e.latestStats.RTT.Microseconds()) / 1000.0,
+		"usage":              e.latestStats.Usage.String(),
+		"state":              e.latestStats.State.String(),
+	}
+}
+
+// OnTargetBitrateChange sets the callback that is called when the target
+// bitrate changes
+func (e *SendSideBWE) OnTargetBitrateChange(f func(bitrate int)) {
+	e.onTargetBitrateChange = f
+}
+
+// Close stops and closes the bandwidth estimator
+func (e *SendSideBWE) Close() error {
+	if err := e.delayController.Close(); err != nil {
+		return err
+	}
+	close(e.close)
+	return e.pacer.Close()
+}
+
+func (e *SendSideBWE) onDelayUpdate(delayStats DelayStats) {
+	e.lock.Lock()
+	defer e.lock.Unlock()
+
+	lossStats := e.lossController.getEstimate(delayStats.TargetBitrate)
+	bitrateChanged := false
+	bitrate := minInt(delayStats.TargetBitrate, lossStats.TargetBitrate)
+	if bitrate != e.latestBitrate {
+		bitrateChanged = true
+		e.latestBitrate = bitrate
+		e.pacer.SetTargetBitrate(e.latestBitrate)
+	}
+
+	if bitrateChanged && e.onTargetBitrateChange != nil {
+		go e.onTargetBitrateChange(bitrate)
+	}
+
+	e.latestStats = Stats{
+		LossStats:  lossStats,
+		DelayStats: delayStats,
+	}
+}
diff --git a/pkg/gcc/slope_estimator.go b/pkg/gcc/slope_estimator.go
new file mode 100644
index 0000000..4ac6c59
--- /dev/null
+++ b/pkg/gcc/slope_estimator.go
@@ -0,0 +1,41 @@
+package gcc
+
+type slopeEstimator struct {
+	estimator
+}
+
+func newSlopeEstimator(e estimator) *slopeEstimator {
+	return &slopeEstimator{
+		estimator: e,
+	}
+}
+
+func (e *slopeEstimator) run(in <-chan arrivalGroup) <-chan DelayStats {
+	out := make(chan DelayStats)
+	go func() {
+		init := false
+		var last arrivalGroup
+		for next := range in {
+			if !init {
+				last = next
+				init = true
+				continue
+			}
+			measurement := interGroupDelayVariation(last, next)
+			delta := next.arrival.Sub(last.arrival)
+			last = next
+			out <- DelayStats{
+				Measurement:      measurement,
+				Estimate:         e.updateEstimate(measurement),
+				Threshold:        0,
+				lastReceiveDelta: delta,
+				Usage:            0,
+				State:            0,
+				TargetBitrate:    0,
+				RTT:              0,
+			}
+		}
+		close(out)
+	}()
+	return out
+}
diff --git a/pkg/gcc/state.go b/pkg/gcc/state.go
new file mode 100644
index 0000000..6565c02
--- /dev/null
+++ b/pkg/gcc/state.go
@@ -0,0 +1,59 @@
+package gcc
+
+import "fmt"
+
+type state int
+
+const (
+	stateIncrease state = iota
+	stateDecrease
+	stateHold
+)
+
+func (s state) transition(u usage) state {
+	switch s {
+	case stateHold:
+		switch u {
+		case usageOver:
+			return stateDecrease
+		case usageNormal:
+			return stateIncrease
+		case usageUnder:
+			return stateHold
+		}
+
+	case stateIncrease:
+		switch u {
+		case usageOver:
+			return stateDecrease
+		case usageNormal:
+			return stateIncrease
+		case usageUnder:
+			return stateHold
+		}
+
+	case stateDecrease:
+		switch u {
+		case usageOver:
+			return stateDecrease
+		case usageNormal:
+			return stateHold
+		case usageUnder:
+			return stateHold
+		}
+	}
+	return stateIncrease
+}
+
+func (s state) String() string {
+	switch s {
+	case stateIncrease:
+		return "increase"
+	case stateDecrease:
+		return "decrease"
+	case stateHold:
+		return "hold"
+	default:
+		return fmt.Sprintf("invalid state: %d", s)
+	}
+}
diff --git a/pkg/gcc/usage.go b/pkg/gcc/usage.go
new file mode 100644
index 0000000..986c22c
--- /dev/null
+++ b/pkg/gcc/usage.go
@@ -0,0 +1,24 @@
+package gcc
+
+import "fmt"
+
+type usage int
+
+const (
+	usageOver usage = iota
+	usageUnder
+	usageNormal
+)
+
+func (u usage) String() string {
+	switch u {
+	case usageOver:
+		return "overuse"
+	case usageUnder:
+		return "underuse"
+	case usageNormal:
+		return "normal"
+	default:
+		return fmt.Sprintf("invalid usage: %d", u)
+	}
+}


Test patch:
diff --git a/internal/cc/feedback_adapter_test.go b/internal/cc/feedback_adapter_test.go
new file mode 100644
index 0000000..a9f1861
--- /dev/null
+++ b/internal/cc/feedback_adapter_test.go
@@ -0,0 +1,1018 @@
+package cc
+
+import (
+	"fmt"
+	"testing"
+	"time"
+
+	"github.com/pion/interceptor"
+	"github.com/pion/rtcp"
+	"github.com/pion/rtp"
+	"github.com/stretchr/testify/assert"
+)
+
+const hdrExtID = uint8(1)
+
+func TestUnpackRunLengthChunk(t *testing.T) {
+	attributes := make(interceptor.Attributes)
+	attributes.Set(TwccExtensionAttributesKey, hdrExtID)
+
+	cases := []struct {
+		sentTLCC []uint16
+		chunk    rtcp.RunLengthChunk
+		deltas   []*rtcp.RecvDelta
+		start    uint16
+		// expect:
+		acks    []Acknowledgment
+		refTime time.Time
+		n       int
+	}{
+		{
+			sentTLCC: []uint16{},
+			chunk:    rtcp.RunLengthChunk{},
+			deltas:   []*rtcp.RecvDelta{},
+			start:    0,
+			acks:     []Acknowledgment{},
+			refTime:  time.Time{},
+			n:        0,
+		},
+		{
+			sentTLCC: []uint16{0, 1, 2, 3, 4, 5},
+			chunk: rtcp.RunLengthChunk{
+				PacketStatusChunk:  nil,
+				Type:               0,
+				PacketStatusSymbol: rtcp.TypeTCCPacketReceivedSmallDelta,
+				RunLength:          6,
+			},
+			deltas: []*rtcp.RecvDelta{
+				{Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 0},
+				{Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 0},
+				{Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 0},
+				{Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 0},
+				{Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 0},
+				{Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 0},
+			},
+			start: 0,
+			//nolint:dupl
+			acks: []Acknowledgment{
+				{
+					TLCC:      0,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{},
+					RTT:       0,
+				},
+				{
+					TLCC:      1,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{},
+					RTT:       0,
+				},
+				{
+					TLCC:      2,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{},
+					RTT:       0,
+				},
+				{
+					TLCC:      3,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{},
+					RTT:       0,
+				},
+				{
+					TLCC:      4,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{},
+					RTT:       0,
+				},
+				{
+					TLCC:      5,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{},
+					RTT:       0,
+				},
+			},
+			n:       6,
+			refTime: time.Time{},
+		},
+		{
+			sentTLCC: []uint16{65534, 65535, 0, 1, 2, 3},
+			chunk: rtcp.RunLengthChunk{
+				PacketStatusChunk:  nil,
+				Type:               0,
+				PacketStatusSymbol: rtcp.TypeTCCPacketReceivedSmallDelta,
+				RunLength:          6,
+			},
+			deltas: []*rtcp.RecvDelta{
+				{Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 250},
+				{Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 250},
+				{Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 250},
+				{Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 250},
+				{Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 250},
+				{Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 250},
+			},
+			start: 65534,
+			acks: []Acknowledgment{
+				{
+					TLCC:      65534,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{}.Add(250 * time.Microsecond),
+					RTT:       0,
+				},
+				{
+					TLCC:      65535,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{}.Add(500 * time.Microsecond),
+					RTT:       0,
+				},
+				{
+					TLCC:      0,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{}.Add(750 * time.Microsecond),
+					RTT:       0,
+				},
+				{
+					TLCC:      1,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{}.Add(1000 * time.Microsecond),
+					RTT:       0,
+				},
+				{
+					TLCC:      2,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{}.Add(1250 * time.Microsecond),
+					RTT:       0,
+				},
+				{
+					TLCC:      3,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{}.Add(1500 * time.Microsecond),
+					RTT:       0,
+				},
+			},
+			n:       6,
+			refTime: time.Time{}.Add(1500 * time.Microsecond),
+		},
+		{
+			sentTLCC: []uint16{65534, 65535, 0, 1, 2, 3},
+			chunk: rtcp.RunLengthChunk{
+				PacketStatusChunk:  nil,
+				Type:               0,
+				PacketStatusSymbol: rtcp.TypeTCCPacketNotReceived,
+				RunLength:          6,
+			},
+			deltas: []*rtcp.RecvDelta{},
+			start:  65534,
+			//nolint:dupl
+			acks: []Acknowledgment{
+				{
+					TLCC:      65534,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{},
+					RTT:       0,
+				},
+				{
+					TLCC:      65535,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{},
+					RTT:       0,
+				},
+				{
+					TLCC:      0,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{},
+					RTT:       0,
+				},
+				{
+					TLCC:      1,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{},
+					RTT:       0,
+				},
+				{
+					TLCC:      2,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{},
+					RTT:       0,
+				},
+				{
+					TLCC:      3,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{},
+					RTT:       0,
+				},
+			},
+			n:       0,
+			refTime: time.Time{},
+		},
+	}
+
+	//nolint:dupl
+	for i, tc := range cases {
+		i := i
+		tc := tc
+		t.Run(fmt.Sprintf("%v", i), func(t *testing.T) {
+			fa := NewFeedbackAdapter()
+
+			headers := []*rtp.Header{}
+			for i, nr := range tc.sentTLCC {
+				headers = append(headers, &getPacketWithTransportCCExt(t, nr).Header)
+				tc.acks[i].Size += headers[i].MarshalSize()
+			}
+			for _, h := range headers {
+				assert.NoError(t, fa.OnSent(time.Time{}, h, 0, attributes))
+			}
+
+			n, refTime, acks, err := fa.unpackRunLengthChunk(time.Time{}, tc.start, time.Time{}, &tc.chunk, tc.deltas)
+			assert.NoError(t, err)
+			assert.Len(t, acks, len(tc.acks))
+			assert.Equal(t, tc.n, n)
+			assert.Equal(t, tc.refTime, refTime)
+
+			for i, a := range acks {
+				assert.Equal(t, tc.sentTLCC[i], a.TLCC)
+			}
+			assert.Equal(t, tc.acks, acks)
+		})
+	}
+}
+
+func TestUnpackStatusVectorChunk(t *testing.T) {
+	attributes := make(interceptor.Attributes)
+	attributes.Set(TwccExtensionAttributesKey, hdrExtID)
+
+	cases := []struct {
+		sentTLCC []uint16
+		chunk    rtcp.StatusVectorChunk
+		deltas   []*rtcp.RecvDelta
+		start    uint16
+		// expect:
+		acks    []Acknowledgment
+		n       int
+		refTime time.Time
+	}{
+		{
+			sentTLCC: []uint16{},
+			chunk:    rtcp.StatusVectorChunk{},
+			deltas:   []*rtcp.RecvDelta{},
+			start:    0,
+			acks:     []Acknowledgment{},
+			n:        0,
+			refTime:  time.Time{},
+		},
+		{
+			sentTLCC: []uint16{0, 1, 2, 3, 4, 5},
+			chunk: rtcp.StatusVectorChunk{
+				PacketStatusChunk: nil,
+				Type:              0,
+				SymbolSize:        rtcp.TypeTCCSymbolSizeTwoBit,
+				SymbolList: []uint16{
+					rtcp.TypeTCCPacketReceivedSmallDelta,
+					rtcp.TypeTCCPacketReceivedSmallDelta,
+					rtcp.TypeTCCPacketReceivedSmallDelta,
+					rtcp.TypeTCCPacketReceivedSmallDelta,
+					rtcp.TypeTCCPacketReceivedSmallDelta,
+					rtcp.TypeTCCPacketReceivedSmallDelta,
+				},
+			},
+			deltas: []*rtcp.RecvDelta{
+				{Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 0},
+				{Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 0},
+				{Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 0},
+				{Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 0},
+				{Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 0},
+				{Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 0},
+			},
+			start: 0,
+			//nolint:dupl
+			acks: []Acknowledgment{
+				{
+					TLCC:      0,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{},
+					RTT:       0,
+				},
+				{
+					TLCC:      1,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{},
+					RTT:       0,
+				},
+				{
+					TLCC:      2,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{},
+					RTT:       0,
+				},
+				{
+					TLCC:      3,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{},
+					RTT:       0,
+				},
+				{
+					TLCC:      4,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{},
+					RTT:       0,
+				},
+				{
+					TLCC:      5,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{},
+					RTT:       0,
+				},
+			},
+			n:       6,
+			refTime: time.Time{},
+		},
+		{
+			sentTLCC: []uint16{65534, 65535, 0, 1, 2, 3},
+			chunk: rtcp.StatusVectorChunk{
+				PacketStatusChunk: nil,
+				Type:              0,
+				SymbolSize:        rtcp.TypeTCCSymbolSizeTwoBit,
+				SymbolList: []uint16{
+					rtcp.TypeTCCPacketReceivedSmallDelta,
+					rtcp.TypeTCCPacketReceivedSmallDelta,
+					rtcp.TypeTCCPacketReceivedSmallDelta,
+					rtcp.TypeTCCPacketReceivedSmallDelta,
+					rtcp.TypeTCCPacketNotReceived,
+					rtcp.TypeTCCPacketReceivedSmallDelta,
+				},
+			},
+			deltas: []*rtcp.RecvDelta{
+				{Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 250},
+				{Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 250},
+				{Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 250},
+				{Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 250},
+				{Type: rtcp.TypeTCCPacketReceivedSmallDelta, Delta: 250},
+			},
+			start: 65534,
+			acks: []Acknowledgment{
+				{
+					TLCC:      65534,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{}.Add(250 * time.Microsecond),
+					RTT:       0,
+				},
+				{
+					TLCC:      65535,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{}.Add(500 * time.Microsecond),
+					RTT:       0,
+				},
+				{
+					TLCC:      0,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{}.Add(750 * time.Microsecond),
+					RTT:       0,
+				},
+				{
+					TLCC:      1,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{}.Add(1000 * time.Microsecond),
+					RTT:       0,
+				},
+				{
+					TLCC:      2,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{},
+					RTT:       0,
+				},
+				{
+					TLCC:      3,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{}.Add(1250 * time.Microsecond),
+					RTT:       0,
+				},
+			},
+			n:       5,
+			refTime: time.Time{}.Add(1250 * time.Microsecond),
+		},
+	}
+
+	//nolint:dupl
+	for i, tc := range cases {
+		i := i
+		tc := tc
+		t.Run(fmt.Sprintf("%v", i), func(t *testing.T) {
+			fa := NewFeedbackAdapter()
+
+			headers := []*rtp.Header{}
+			for i, nr := range tc.sentTLCC {
+				headers = append(headers, &getPacketWithTransportCCExt(t, nr).Header)
+				tc.acks[i].Size += headers[i].MarshalSize()
+			}
+			for _, h := range headers {
+				assert.NoError(t, fa.OnSent(time.Time{}, h, 0, attributes))
+			}
+
+			n, refTime, acks, err := fa.unpackStatusVectorChunk(time.Time{}, tc.start, time.Time{}, &tc.chunk, tc.deltas)
+			assert.NoError(t, err)
+			assert.Len(t, acks, len(tc.acks))
+			assert.Equal(t, tc.n, n)
+			assert.Equal(t, tc.refTime, refTime)
+
+			for i, a := range acks {
+				assert.Equal(t, tc.sentTLCC[i], a.TLCC)
+			}
+			assert.Equal(t, tc.acks, acks)
+		})
+	}
+}
+
+func getPacketWithTransportCCExt(t *testing.T, sequenceNumber uint16) *rtp.Packet {
+	pkt := rtp.Packet{
+		Header:  rtp.Header{},
+		Payload: []byte{},
+	}
+	ext := &rtp.TransportCCExtension{
+		TransportSequence: sequenceNumber,
+	}
+	b, err := ext.Marshal()
+	assert.NoError(t, err)
+	assert.NoError(t, pkt.SetExtension(hdrExtID, b))
+	return &pkt
+}
+
+func TestFeedbackAdapterTWCC(t *testing.T) {
+	t.Run("empty", func(t *testing.T) {
+		adapter := NewFeedbackAdapter()
+		result, err := adapter.onIncomingTransportCC(time.Time{}, &rtcp.TransportLayerCC{})
+		assert.NoError(t, err)
+		assert.Empty(t, result)
+	})
+
+	t.Run("setsCorrectReceiveTime", func(t *testing.T) {
+		t0 := time.Time{}
+		adapter := NewFeedbackAdapter()
+		headers := []rtp.Header{}
+		for i := uint16(0); i < 22; i++ {
+			pkt := getPacketWithTransportCCExt(t, i)
+			headers = append(headers, pkt.Header)
+			assert.NoError(t, adapter.OnSent(t0, &pkt.Header, 1200, interceptor.Attributes{TwccExtensionAttributesKey: hdrExtID}))
+		}
+		results, err := adapter.OnFeedback(t0, &rtcp.TransportLayerCC{
+			Header:             rtcp.Header{},
+			SenderSSRC:         0,
+			MediaSSRC:          0,
+			BaseSequenceNumber: 0,
+			PacketStatusCount:  22,
+			ReferenceTime:      0,
+			FbPktCount:         0,
+			PacketChunks: []rtcp.PacketStatusChunk{
+				&rtcp.StatusVectorChunk{
+					PacketStatusChunk: nil,
+					Type:              rtcp.TypeTCCStatusVectorChunk,
+					SymbolSize:        rtcp.TypeTCCSymbolSizeTwoBit,
+					SymbolList: []uint16{
+						rtcp.TypeTCCPacketReceivedSmallDelta,
+						rtcp.TypeTCCPacketReceivedLargeDelta,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+					},
+				},
+				&rtcp.StatusVectorChunk{
+					PacketStatusChunk: nil,
+					Type:              rtcp.TypeTCCStatusVectorChunk,
+					SymbolSize:        rtcp.TypeTCCSymbolSizeOneBit,
+					SymbolList: []uint16{
+						rtcp.TypeTCCPacketReceivedSmallDelta,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+					},
+				},
+				&rtcp.RunLengthChunk{
+					Type:               rtcp.TypeTCCRunLengthChunk,
+					PacketStatusSymbol: rtcp.TypeTCCPacketReceivedSmallDelta,
+					RunLength:          1,
+				},
+			},
+			RecvDeltas: []*rtcp.RecvDelta{
+				{
+					Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+					Delta: 4,
+				},
+				{
+					Type:  rtcp.TypeTCCPacketReceivedLargeDelta,
+					Delta: 100,
+				},
+				{
+					Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+					Delta: 12,
+				},
+				{
+					Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+					Delta: 4,
+				},
+			},
+		})
+
+		assert.NoError(t, err)
+
+		assert.NotEmpty(t, results)
+		assert.Len(t, results, 22)
+
+		assert.Contains(t, results, Acknowledgment{
+			TLCC:      0,
+			Size:      headers[0].MarshalSize() + 1200,
+			Departure: t0,
+			Arrival:   t0.Add(4 * time.Microsecond),
+			RTT:       0,
+		})
+
+		assert.Contains(t, results, Acknowledgment{
+			TLCC:      1,
+			Size:      headers[1].MarshalSize() + 1200,
+			Departure: t0,
+			Arrival:   t0.Add(104 * time.Microsecond),
+			RTT:       0,
+		})
+
+		for i := uint16(2); i < 7; i++ {
+			assert.Contains(t, results, Acknowledgment{
+				TLCC:      i,
+				Size:      headers[i].MarshalSize() + 1200,
+				Departure: t0,
+				Arrival:   time.Time{},
+				RTT:       0,
+			})
+		}
+
+		assert.Contains(t, results, Acknowledgment{
+			TLCC:      7,
+			Size:      headers[7].MarshalSize() + 1200,
+			Departure: t0,
+			Arrival:   t0.Add(116 * time.Microsecond),
+			RTT:       0,
+		})
+
+		for i := uint16(8); i < 21; i++ {
+			assert.Contains(t, results, Acknowledgment{
+				TLCC:      i,
+				Size:      headers[i].MarshalSize() + 1200,
+				Departure: t0,
+				Arrival:   time.Time{},
+				RTT:       0,
+			})
+		}
+
+		assert.Contains(t, results, Acknowledgment{
+			TLCC:      21,
+			Size:      headers[21].MarshalSize() + 1200,
+			Departure: t0,
+			Arrival:   t0.Add(120 * time.Microsecond),
+			RTT:       0,
+		})
+	})
+
+	t.Run("doesNotCrashOnTooManyFeedbackReports", func(*testing.T) {
+		adapter := NewFeedbackAdapter()
+		assert.NotPanics(t, func() {
+			_, err := adapter.OnFeedback(time.Time{}, &rtcp.TransportLayerCC{
+				Header:             rtcp.Header{},
+				SenderSSRC:         0,
+				MediaSSRC:          0,
+				BaseSequenceNumber: 0,
+				PacketStatusCount:  0,
+				ReferenceTime:      0,
+				FbPktCount:         0,
+				PacketChunks: []rtcp.PacketStatusChunk{
+					&rtcp.StatusVectorChunk{
+						PacketStatusChunk: nil,
+						Type:              rtcp.TypeTCCStatusVectorChunk,
+						SymbolSize:        rtcp.TypeTCCSymbolSizeTwoBit,
+						SymbolList: []uint16{
+							rtcp.TypeTCCPacketReceivedSmallDelta,
+							rtcp.TypeTCCPacketNotReceived,
+							rtcp.TypeTCCPacketNotReceived,
+							rtcp.TypeTCCPacketNotReceived,
+							rtcp.TypeTCCPacketNotReceived,
+							rtcp.TypeTCCPacketNotReceived,
+							rtcp.TypeTCCPacketNotReceived,
+						},
+					},
+				},
+				RecvDeltas: []*rtcp.RecvDelta{
+					{
+						Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+						Delta: 4, // 4*250us=1ms
+					},
+				},
+			})
+			assert.NoError(t, err)
+		})
+	})
+
+	t.Run("worksOnSequenceNumberWrapAround", func(t *testing.T) {
+		t0 := time.Time{}
+		adapter := NewFeedbackAdapter()
+		pkt65535 := getPacketWithTransportCCExt(t, 65535)
+		pkt0 := getPacketWithTransportCCExt(t, 0)
+		assert.NoError(t, adapter.OnSent(t0, &pkt65535.Header, 1200, interceptor.Attributes{TwccExtensionAttributesKey: hdrExtID}))
+		assert.NoError(t, adapter.OnSent(t0, &pkt0.Header, 1200, interceptor.Attributes{TwccExtensionAttributesKey: hdrExtID}))
+
+		results, err := adapter.OnFeedback(t0, &rtcp.TransportLayerCC{
+			Header:             rtcp.Header{},
+			SenderSSRC:         0,
+			MediaSSRC:          0,
+			BaseSequenceNumber: 65535,
+			PacketStatusCount:  2,
+			ReferenceTime:      0,
+			FbPktCount:         0,
+			PacketChunks: []rtcp.PacketStatusChunk{
+				&rtcp.StatusVectorChunk{
+					PacketStatusChunk: nil,
+					Type:              rtcp.TypeTCCStatusVectorChunk,
+					SymbolSize:        rtcp.TypeTCCSymbolSizeTwoBit,
+					SymbolList: []uint16{
+						rtcp.TypeTCCPacketReceivedSmallDelta,
+						rtcp.TypeTCCPacketReceivedSmallDelta,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+					},
+				},
+			},
+			RecvDeltas: []*rtcp.RecvDelta{
+				{
+					Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+					Delta: 4,
+				},
+				{
+					Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+					Delta: 4,
+				},
+			},
+		})
+		assert.NoError(t, err)
+
+		assert.NotEmpty(t, results)
+		assert.Len(t, results, 7)
+		assert.Contains(t, results, Acknowledgment{
+			TLCC:      65535,
+			Size:      pkt65535.Header.MarshalSize() + 1200,
+			Departure: t0,
+			Arrival:   t0.Add(4 * time.Microsecond),
+			RTT:       0,
+		})
+		assert.Contains(t, results, Acknowledgment{
+			TLCC:      0,
+			Size:      pkt0.Header.MarshalSize() + 1200,
+			Departure: t0,
+			Arrival:   t0.Add(8 * time.Microsecond),
+			RTT:       0,
+		})
+	})
+
+	t.Run("ignoresPossiblyInFlightPackets", func(t *testing.T) {
+		t0 := time.Time{}
+		adapter := NewFeedbackAdapter()
+		headers := []rtp.Header{}
+		for i := uint16(0); i < 8; i++ {
+			pkt := getPacketWithTransportCCExt(t, i)
+			headers = append(headers, pkt.Header)
+			assert.NoError(t, adapter.OnSent(t0, &pkt.Header, 1200, interceptor.Attributes{TwccExtensionAttributesKey: hdrExtID}))
+		}
+
+		results, err := adapter.OnFeedback(t0, &rtcp.TransportLayerCC{
+			Header:             rtcp.Header{},
+			SenderSSRC:         0,
+			MediaSSRC:          0,
+			BaseSequenceNumber: 0,
+			PacketStatusCount:  3,
+			ReferenceTime:      0,
+			FbPktCount:         0,
+			PacketChunks: []rtcp.PacketStatusChunk{
+				&rtcp.StatusVectorChunk{
+					PacketStatusChunk: nil,
+					Type:              rtcp.TypeTCCStatusVectorChunk,
+					SymbolSize:        rtcp.TypeTCCSymbolSizeTwoBit,
+					SymbolList: []uint16{
+						rtcp.TypeTCCPacketReceivedSmallDelta,
+						rtcp.TypeTCCPacketReceivedSmallDelta,
+						rtcp.TypeTCCPacketReceivedSmallDelta,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+					},
+				},
+			},
+			RecvDeltas: []*rtcp.RecvDelta{
+				{
+					Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+					Delta: 4, // 4*250us=1ms
+				},
+				{
+					Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+					Delta: 4, // 4*250us=1ms
+				},
+				{
+					Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+					Delta: 4, // 4*250us=1ms
+				},
+			},
+		})
+		assert.NoError(t, err)
+		assert.Len(t, results, 7)
+		for i := uint16(0); i < 3; i++ {
+			assert.Contains(t, results, Acknowledgment{
+				TLCC:      i,
+				Size:      headers[i].MarshalSize() + 1200,
+				Departure: t0,
+				Arrival:   t0.Add(time.Duration((i + 1)) * 4 * time.Microsecond),
+				RTT:       0,
+			})
+		}
+		for i := uint16(3); i < 7; i++ {
+			assert.Contains(t, results, Acknowledgment{
+				TLCC:      i,
+				Size:      headers[i].MarshalSize() + 1200,
+				Departure: t0,
+				Arrival:   time.Time{},
+				RTT:       0,
+			})
+		}
+	})
+
+	t.Run("runLengthChunk", func(t *testing.T) {
+		adapter := NewFeedbackAdapter()
+		t0 := time.Time{}
+		for i := uint16(0); i < 20; i++ {
+			pkt := getPacketWithTransportCCExt(t, i)
+			assert.NoError(t, adapter.OnSent(t0, &pkt.Header, 1200, interceptor.Attributes{TwccExtensionAttributesKey: hdrExtID}))
+		}
+		packets, err := adapter.OnFeedback(t0, &rtcp.TransportLayerCC{
+			Header:             rtcp.Header{},
+			SenderSSRC:         0,
+			MediaSSRC:          0,
+			BaseSequenceNumber: 0,
+			PacketStatusCount:  3,
+			ReferenceTime:      0,
+			FbPktCount:         0,
+			PacketChunks: []rtcp.PacketStatusChunk{
+				&rtcp.RunLengthChunk{
+					PacketStatusSymbol: rtcp.TypeTCCPacketReceivedSmallDelta,
+					RunLength:          3,
+				},
+			},
+			RecvDeltas: []*rtcp.RecvDelta{
+				{
+					Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+					Delta: 4,
+				},
+				{
+					Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+					Delta: 4,
+				},
+				{
+					Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+					Delta: 4,
+				},
+			},
+		})
+
+		assert.NoError(t, err)
+		assert.Len(t, packets, 3)
+	})
+
+	t.Run("statusVectorChunk", func(t *testing.T) {
+		adapter := NewFeedbackAdapter()
+		t0 := time.Time{}
+		for i := uint16(0); i < 20; i++ {
+			pkt := getPacketWithTransportCCExt(t, i)
+			assert.NoError(t, adapter.OnSent(t0, &pkt.Header, 1200, interceptor.Attributes{TwccExtensionAttributesKey: hdrExtID}))
+		}
+		packets, err := adapter.OnFeedback(t0, &rtcp.TransportLayerCC{
+			Header:             rtcp.Header{},
+			SenderSSRC:         0,
+			MediaSSRC:          0,
+			BaseSequenceNumber: 0,
+			PacketStatusCount:  3,
+			ReferenceTime:      0,
+			FbPktCount:         0,
+			PacketChunks: []rtcp.PacketStatusChunk{
+				&rtcp.StatusVectorChunk{
+					SymbolSize: rtcp.TypeTCCSymbolSizeOneBit,
+					SymbolList: []uint16{
+						rtcp.TypeTCCPacketReceivedSmallDelta,
+						rtcp.TypeTCCPacketReceivedSmallDelta,
+						rtcp.TypeTCCPacketReceivedSmallDelta,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+					},
+				},
+			},
+			RecvDeltas: []*rtcp.RecvDelta{
+				{
+					Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+					Delta: 4,
+				},
+				{
+					Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+					Delta: 4,
+				},
+				{
+					Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+					Delta: 4,
+				},
+			},
+		})
+
+		assert.NoError(t, err)
+		assert.Len(t, packets, 14)
+	})
+
+	t.Run("mixedRunLengthAndStatusVector", func(t *testing.T) {
+		adapter := NewFeedbackAdapter()
+
+		t0 := time.Time{}
+		for i := uint16(0); i < 20; i++ {
+			pkt := getPacketWithTransportCCExt(t, i)
+			assert.NoError(t, adapter.OnSent(t0, &pkt.Header, 1200, interceptor.Attributes{TwccExtensionAttributesKey: hdrExtID}))
+		}
+
+		//nolint:dupl
+		packets, err := adapter.OnFeedback(t0, &rtcp.TransportLayerCC{
+			Header:             rtcp.Header{},
+			SenderSSRC:         0,
+			MediaSSRC:          0,
+			BaseSequenceNumber: 0,
+			PacketStatusCount:  10,
+			ReferenceTime:      0,
+			FbPktCount:         0,
+			PacketChunks: []rtcp.PacketStatusChunk{
+				&rtcp.StatusVectorChunk{
+					SymbolSize: rtcp.TypeTCCSymbolSizeTwoBit,
+					SymbolList: []uint16{
+						rtcp.TypeTCCPacketReceivedSmallDelta,
+						rtcp.TypeTCCPacketReceivedSmallDelta,
+						rtcp.TypeTCCPacketReceivedSmallDelta,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+						rtcp.TypeTCCPacketNotReceived,
+					},
+				},
+				&rtcp.RunLengthChunk{
+					PacketStatusSymbol: rtcp.TypeTCCPacketReceivedSmallDelta,
+					RunLength:          3,
+				},
+			},
+			RecvDeltas: []*rtcp.RecvDelta{
+				{
+					Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+					Delta: 4,
+				},
+				{
+					Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+					Delta: 4,
+				},
+				{
+					Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+					Delta: 4,
+				},
+				{
+					Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+					Delta: 4,
+				},
+				{
+					Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+					Delta: 4,
+				},
+				{
+					Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+					Delta: 4,
+				},
+			},
+		})
+		assert.NoError(t, err)
+		assert.Len(t, packets, 10)
+	})
+
+	t.Run("doesNotcrashOnInvalidTWCCPacket", func(t *testing.T) {
+		adapter := NewFeedbackAdapter()
+
+		t0 := time.Time{}
+		for i := uint16(1008); i < 1030; i++ {
+			pkt := getPacketWithTransportCCExt(t, i)
+			assert.NoError(t, adapter.OnSent(t0, &pkt.Header, 1200, interceptor.Attributes{TwccExtensionAttributesKey: hdrExtID}))
+		}
+
+		//nolint:dupl
+		assert.NotPanics(t, func() {
+			packets, err := adapter.OnFeedback(t0, &rtcp.TransportLayerCC{
+				Header:             rtcp.Header{},
+				SenderSSRC:         0,
+				MediaSSRC:          0,
+				BaseSequenceNumber: 1008,
+				PacketStatusCount:  8,
+				ReferenceTime:      278,
+				FbPktCount:         170,
+				PacketChunks: []rtcp.PacketStatusChunk{
+					&rtcp.StatusVectorChunk{
+						SymbolSize: rtcp.TypeTCCSymbolSizeTwoBit,
+						SymbolList: []uint16{
+							rtcp.TypeTCCPacketReceivedSmallDelta,
+							rtcp.TypeTCCPacketReceivedSmallDelta,
+							rtcp.TypeTCCPacketReceivedSmallDelta,
+							rtcp.TypeTCCPacketReceivedSmallDelta,
+							rtcp.TypeTCCPacketReceivedSmallDelta,
+							rtcp.TypeTCCPacketReceivedSmallDelta,
+							rtcp.TypeTCCPacketNotReceived,
+						},
+					},
+					&rtcp.RunLengthChunk{
+						PacketStatusSymbol: rtcp.TypeTCCPacketReceivedSmallDelta,
+						RunLength:          5632,
+					},
+				},
+				RecvDeltas: []*rtcp.RecvDelta{
+					{
+						Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+						Delta: 25000,
+					},
+					{
+						Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+						Delta: 0,
+					},
+					{
+						Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+						Delta: 29500,
+					},
+					{
+						Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+						Delta: 16750,
+					},
+					{
+						Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+						Delta: 23500,
+					},
+					{
+						Type:  rtcp.TypeTCCPacketReceivedSmallDelta,
+						Delta: 0,
+					},
+				},
+			})
+			assert.Error(t, err)
+			assert.Empty(t, packets)
+		})
+	})
+}
diff --git a/pkg/gcc/adaptive_threshold_test.go b/pkg/gcc/adaptive_threshold_test.go
new file mode 100644
index 0000000..c0bcbd4
--- /dev/null
+++ b/pkg/gcc/adaptive_threshold_test.go
@@ -0,0 +1,142 @@
+package gcc
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestAdaptiveThreshold(t *testing.T) {
+	type input struct {
+		estimate, delta time.Duration
+	}
+	cases := []struct {
+		name     string
+		in       []input
+		expected []usage
+		options  []adaptiveThresholdOption
+	}{
+		{
+			name:     "empty",
+			in:       []input{},
+			expected: []usage{},
+			options:  []adaptiveThresholdOption{},
+		},
+		{
+			name: "firstInputIsAlwaysNormal",
+			in: []input{{
+				estimate: 1 * time.Second,
+				delta:    0,
+			}},
+			expected: []usage{usageNormal},
+			options:  []adaptiveThresholdOption{},
+		},
+		{
+			name: "singleOver",
+			in: []input{
+				{
+					estimate: 0,
+					delta:    0,
+				},
+				{
+					estimate: 20 * time.Millisecond,
+					delta:    0,
+				},
+			},
+			expected: []usage{usageNormal, usageOver},
+			options: []adaptiveThresholdOption{
+				setInitialThreshold(10 * time.Millisecond),
+			},
+		},
+		{
+			name: "singleNormal",
+			in: []input{
+				{
+					estimate: 0,
+					delta:    0,
+				},
+				{
+					estimate: 5 * time.Millisecond,
+					delta:    0,
+				},
+			},
+			expected: []usage{usageNormal, usageNormal},
+			options: []adaptiveThresholdOption{
+				setInitialThreshold(10 * time.Millisecond),
+			},
+		},
+		{
+			name: "singleUnder",
+			in: []input{
+				{
+					estimate: 0,
+					delta:    0,
+				},
+				{
+					estimate: -20 * time.Millisecond,
+					delta:    0,
+				},
+			},
+			expected: []usage{usageNormal, usageUnder},
+			options: []adaptiveThresholdOption{
+				setInitialThreshold(10 * time.Millisecond),
+			},
+		},
+		{
+			name: "increaseThresholdOnOveruse",
+			in: []input{
+				{
+					estimate: 0,
+					delta:    0,
+				},
+				{
+					estimate: 25 * time.Millisecond,
+					delta:    30 * time.Millisecond,
+				},
+				{
+					estimate: 13 * time.Millisecond,
+					delta:    30 * time.Millisecond,
+				},
+			},
+			expected: []usage{usageNormal, usageOver, usageNormal},
+			options: []adaptiveThresholdOption{
+				setInitialThreshold(40 * time.Millisecond),
+			},
+		},
+		{
+			name: "overuseAfterOveruse",
+			in: []input{
+				{
+					estimate: 0,
+					delta:    0,
+				},
+				{
+					estimate: 20 * time.Millisecond,
+					delta:    30 * time.Millisecond,
+				},
+				{
+					estimate: 30 * time.Millisecond,
+					delta:    30 * time.Millisecond,
+				},
+			},
+			expected: []usage{usageNormal, usageOver, usageOver},
+			options: []adaptiveThresholdOption{
+				setInitialThreshold(10 * time.Millisecond),
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			threshold := newAdaptiveThreshold(tc.options...)
+			usages := []usage{}
+			for _, in := range tc.in {
+				use, _, _ := threshold.compare(in.estimate, in.delta)
+				usages = append(usages, use)
+			}
+			assert.Equal(t, tc.expected, usages, "%v != %v", tc.expected, usages)
+		})
+	}
+}
diff --git a/pkg/gcc/arrival_group_accumulator_test.go b/pkg/gcc/arrival_group_accumulator_test.go
new file mode 100644
index 0000000..b0d45ee
--- /dev/null
+++ b/pkg/gcc/arrival_group_accumulator_test.go
@@ -0,0 +1,177 @@
+package gcc
+
+import (
+	"testing"
+	"time"
+
+	"github.com/pion/interceptor/internal/cc"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestArrivalGroupAccumulator(t *testing.T) {
+	triggerNewGroupElement := cc.Acknowledgment{
+		Departure: time.Time{}.Add(time.Second),
+		Arrival:   time.Time{}.Add(time.Second),
+	}
+	cases := []struct {
+		name string
+		log  []cc.Acknowledgment
+		exp  []arrivalGroup
+	}{
+		{
+			name: "emptyCreatesNoGroups",
+			log:  []cc.Acknowledgment{},
+			exp:  []arrivalGroup{},
+		},
+		{
+			name: "createsSingleElementGroup",
+			log: []cc.Acknowledgment{
+				{
+					Departure: time.Time{},
+					Arrival:   time.Time{}.Add(time.Millisecond),
+				},
+				triggerNewGroupElement,
+			},
+			exp: []arrivalGroup{
+				{
+					packets: []cc.Acknowledgment{{
+						Departure: time.Time{},
+						Arrival:   time.Time{}.Add(time.Millisecond),
+					}},
+					arrival:   time.Time{}.Add(time.Millisecond),
+					departure: time.Time{},
+				},
+			},
+		},
+		{
+			name: "createsTwoElementGroup",
+			log: []cc.Acknowledgment{
+				{
+					Arrival: time.Time{}.Add(15 * time.Millisecond),
+				},
+				{
+					Departure: time.Time{}.Add(3 * time.Millisecond),
+					Arrival:   time.Time{}.Add(20 * time.Millisecond),
+				},
+				triggerNewGroupElement,
+			},
+			exp: []arrivalGroup{{
+				packets: []cc.Acknowledgment{
+					{
+						Departure: time.Time{},
+						Arrival:   time.Time{}.Add(15 * time.Millisecond),
+					},
+					{
+						Departure: time.Time{}.Add(3 * time.Millisecond),
+						Arrival:   time.Time{}.Add(20 * time.Millisecond),
+					},
+				},
+				arrival:   time.Time{}.Add(20 * time.Millisecond),
+				departure: time.Time{}.Add(3 * time.Millisecond),
+			}},
+		},
+		{
+			name: "createsTwoArrivalGroups",
+			log: []cc.Acknowledgment{
+				{
+					Departure: time.Time{},
+					Arrival:   time.Time{}.Add(15 * time.Millisecond),
+				},
+				{
+					Departure: time.Time{}.Add(3 * time.Millisecond),
+					Arrival:   time.Time{}.Add(20 * time.Millisecond),
+				},
+				{
+					Departure: time.Time{}.Add(9 * time.Millisecond),
+					Arrival:   time.Time{}.Add(30 * time.Millisecond),
+				},
+				triggerNewGroupElement,
+			},
+			exp: []arrivalGroup{
+				{
+					packets: []cc.Acknowledgment{
+						{
+							Arrival: time.Time{}.Add(15 * time.Millisecond),
+						},
+						{
+							Departure: time.Time{}.Add(3 * time.Millisecond),
+							Arrival:   time.Time{}.Add(20 * time.Millisecond),
+						},
+					},
+					arrival:   time.Time{}.Add(20 * time.Millisecond),
+					departure: time.Time{}.Add(3 * time.Millisecond),
+				},
+				{
+					packets: []cc.Acknowledgment{
+						{
+							Departure: time.Time{}.Add(9 * time.Millisecond),
+							Arrival:   time.Time{}.Add(30 * time.Millisecond),
+						},
+					},
+					arrival:   time.Time{}.Add(30 * time.Millisecond),
+					departure: time.Time{}.Add(9 * time.Millisecond),
+				},
+			},
+		},
+		{
+			name: "ignoresOutOfOrderPackets",
+			log: []cc.Acknowledgment{
+				{
+					Departure: time.Time{},
+					Arrival:   time.Time{}.Add(15 * time.Millisecond),
+				},
+				{
+					Departure: time.Time{}.Add(6 * time.Millisecond),
+					Arrival:   time.Time{}.Add(34 * time.Millisecond),
+				},
+				{
+					Departure: time.Time{}.Add(8 * time.Millisecond),
+					Arrival:   time.Time{}.Add(30 * time.Millisecond),
+				},
+				triggerNewGroupElement,
+			},
+			exp: []arrivalGroup{
+				{
+					packets: []cc.Acknowledgment{
+						{
+							Departure: time.Time{},
+							Arrival:   time.Time{}.Add(15 * time.Millisecond),
+						},
+					},
+					arrival:   time.Time{}.Add(15 * time.Millisecond),
+					departure: time.Time{},
+				},
+				{
+					packets: []cc.Acknowledgment{
+						{
+							Departure: time.Time{}.Add(6 * time.Millisecond),
+							Arrival:   time.Time{}.Add(34 * time.Millisecond),
+						},
+					},
+					arrival:   time.Time{}.Add(34 * time.Millisecond),
+					departure: time.Time{}.Add(6 * time.Millisecond),
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			aga := newArrivalGroupAccumulator()
+			in := make(chan cc.Acknowledgment)
+			out := aga.run(in)
+			go func() {
+				for _, as := range tc.log {
+					in <- as
+				}
+				close(in)
+			}()
+			received := []arrivalGroup{}
+			for g := range out {
+				received = append(received, g)
+			}
+			assert.Equal(t, tc.exp, received)
+		})
+	}
+}
diff --git a/pkg/gcc/arrival_group_test.go b/pkg/gcc/arrival_group_test.go
new file mode 100644
index 0000000..79ebd46
--- /dev/null
+++ b/pkg/gcc/arrival_group_test.go
@@ -0,0 +1,95 @@
+package gcc
+
+import (
+	"testing"
+	"time"
+
+	"github.com/pion/interceptor/internal/cc"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestArrivalGroup(t *testing.T) {
+	cases := []struct {
+		name     string
+		acks     []cc.Acknowledgment
+		expected arrivalGroup
+	}{
+		{
+			name: "createsEmptyArrivalGroup",
+			acks: []cc.Acknowledgment{},
+			expected: arrivalGroup{
+				packets:   nil,
+				arrival:   time.Time{},
+				departure: time.Time{},
+				rtt:       0,
+			},
+		},
+		{
+			name: "createsArrivalGroupContainingSingleACK",
+			acks: []cc.Acknowledgment{{
+				TLCC:      0,
+				Size:      0,
+				Departure: time.Time{},
+				Arrival:   time.Time{},
+				RTT:       0,
+			}},
+			expected: arrivalGroup{
+				packets: []cc.Acknowledgment{{
+					TLCC:      0,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{},
+					RTT:       0,
+				}},
+				arrival:   time.Time{},
+				departure: time.Time{},
+				rtt:       0,
+			},
+		},
+		{
+			name: "setsTimesToLastACK",
+			acks: []cc.Acknowledgment{{
+				TLCC:      0,
+				Size:      0,
+				Departure: time.Time{},
+				Arrival:   time.Time{},
+				RTT:       0,
+			}, {
+				TLCC:      0,
+				Size:      0,
+				Departure: time.Time{}.Add(time.Second),
+				Arrival:   time.Time{}.Add(time.Second),
+				RTT:       time.Hour,
+			}},
+			expected: arrivalGroup{
+				packets: []cc.Acknowledgment{{
+					TLCC:      0,
+					Size:      0,
+					Departure: time.Time{},
+					Arrival:   time.Time{},
+					RTT:       0,
+				}, {
+					TLCC:      0,
+					Size:      0,
+					Departure: time.Time{}.Add(time.Second),
+					Arrival:   time.Time{}.Add(time.Second),
+					RTT:       time.Hour,
+				}},
+				arrival:   time.Time{}.Add(time.Second),
+				departure: time.Time{}.Add(time.Second),
+				rtt:       time.Hour,
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			ag := arrivalGroup{}
+			for _, ack := range tc.acks {
+				ag.add(ack)
+			}
+			assert.Equal(t, tc.expected, ag)
+		})
+	}
+}
diff --git a/pkg/gcc/kalman_test.go b/pkg/gcc/kalman_test.go
new file mode 100644
index 0000000..99611f3
--- /dev/null
+++ b/pkg/gcc/kalman_test.go
@@ -0,0 +1,68 @@
+package gcc
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestKalman(t *testing.T) {
+	cases := []struct {
+		name         string
+		opts         []kalmanOption
+		measurements []time.Duration
+		expected     []time.Duration
+	}{
+		{
+			name:         "empty",
+			opts:         []kalmanOption{},
+			measurements: []time.Duration{},
+			expected:     []time.Duration{},
+		},
+		{
+			name: "kalmanfilter.netExample",
+			opts: []kalmanOption{
+				initEstimate(10 * time.Millisecond),
+				initEstimateError(100),
+				initProcessUncertainty(0.15),
+				initMeasurementUncertainty(0.01),
+			},
+			measurements: []time.Duration{
+				time.Duration(50.45 * float64(time.Millisecond)),
+				time.Duration(50.967 * float64(time.Millisecond)),
+				time.Duration(51.6 * float64(time.Millisecond)),
+				time.Duration(52.106 * float64(time.Millisecond)),
+				time.Duration(52.492 * float64(time.Millisecond)),
+				time.Duration(52.819 * float64(time.Millisecond)),
+				time.Duration(53.433 * float64(time.Millisecond)),
+				time.Duration(54.007 * float64(time.Millisecond)),
+				time.Duration(54.523 * float64(time.Millisecond)),
+				time.Duration(54.99 * float64(time.Millisecond)),
+			},
+			expected: []time.Duration{
+				time.Duration(50.449959 * float64(time.Millisecond)),
+				time.Duration(50.936547 * float64(time.Millisecond)),
+				time.Duration(51.560411 * float64(time.Millisecond)),
+				time.Duration(52.07324 * float64(time.Millisecond)),
+				time.Duration(52.466566 * float64(time.Millisecond)),
+				time.Duration(52.797787 * float64(time.Millisecond)),
+				time.Duration(53.395303 * float64(time.Millisecond)),
+				time.Duration(53.970236 * float64(time.Millisecond)),
+				time.Duration(54.489652 * float64(time.Millisecond)),
+				time.Duration(54.960137 * float64(time.Millisecond)),
+			},
+		},
+	}
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			k := newKalman(append(tc.opts, setDisableMeasurementUncertaintyUpdates(true))...)
+			estimates := []time.Duration{}
+			for _, m := range tc.measurements {
+				estimates = append(estimates, k.updateEstimate(m))
+			}
+			assert.Equal(t, tc.expected, estimates, "%v != %v", tc.expected, estimates)
+		})
+	}
+}
diff --git a/pkg/gcc/overuse_detector_test.go b/pkg/gcc/overuse_detector_test.go
new file mode 100644
index 0000000..dca1737
--- /dev/null
+++ b/pkg/gcc/overuse_detector_test.go
@@ -0,0 +1,106 @@
+package gcc
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+type staticThreshold time.Duration
+
+func (t staticThreshold) compare(estimate, _ time.Duration) (usage, time.Duration, time.Duration) {
+	if estimate > time.Duration(t) {
+		return usageOver, estimate, time.Duration(t)
+	}
+	if estimate < -time.Duration(t) {
+		return usageUnder, estimate, time.Duration(t)
+	}
+	return usageNormal, estimate, time.Duration(t)
+}
+
+func TestOveruseDetectorWithoutDelay(t *testing.T) {
+	cases := []struct {
+		name      string
+		estimates []DelayStats
+		expected  []usage
+		thresh    threshold
+		delay     time.Duration
+	}{
+		{
+			name:      "noEstimateNoUsage",
+			estimates: []DelayStats{},
+			expected:  []usage{},
+			thresh:    staticThreshold(time.Millisecond),
+			delay:     0,
+		},
+		{
+			name: "overuse",
+			estimates: []DelayStats{
+				{},
+				{Estimate: 2 * time.Millisecond},
+				{Estimate: 3 * time.Millisecond},
+			},
+			expected: []usage{usageNormal, usageNormal, usageOver},
+			thresh:   staticThreshold(time.Millisecond),
+			delay:    13 * time.Millisecond,
+		},
+		{
+			name:      "normaluse",
+			estimates: []DelayStats{{Estimate: 0}},
+			expected:  []usage{usageNormal},
+			thresh:    staticThreshold(time.Millisecond),
+			delay:     0,
+		},
+		{
+			name:      "underuse",
+			estimates: []DelayStats{{Estimate: -2 * time.Millisecond}},
+			expected:  []usage{usageUnder},
+			thresh:    staticThreshold(time.Millisecond),
+			delay:     0,
+		},
+		{
+			name: "noOverUseBeforeDelay",
+			estimates: []DelayStats{
+				{},
+				{Estimate: 3 * time.Millisecond},
+				{Estimate: 5 * time.Millisecond},
+			},
+			expected: []usage{usageNormal, usageNormal, usageOver},
+			thresh:   staticThreshold(1 * time.Millisecond),
+			delay:    10 * time.Millisecond,
+		},
+		{
+			name: "noOverUseIfEstimateDecreased",
+			estimates: []DelayStats{
+				{},
+				{Estimate: 4 * time.Millisecond},
+				{Estimate: 5 * time.Millisecond},
+				{Estimate: 3 * time.Millisecond},
+			},
+			expected: []usage{usageNormal, usageNormal, usageOver, usageNormal},
+			thresh:   staticThreshold(1 * time.Millisecond),
+			delay:    0,
+		},
+	}
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			od := newOveruseDetector(tc.thresh, tc.delay)
+			in := make(chan DelayStats)
+			out := od.run(in)
+			go func() {
+				for _, e := range tc.estimates {
+					in <- e
+					time.Sleep(tc.delay)
+				}
+				close(in)
+			}()
+			received := []usage{}
+			for s := range out {
+				received = append(received, s.Usage)
+			}
+			assert.Equal(t, tc.expected, received, "%v != %v", tc.expected, received)
+		})
+	}
+}
diff --git a/pkg/gcc/rate_calculator_test.go b/pkg/gcc/rate_calculator_test.go
new file mode 100644
index 0000000..e08ff01
--- /dev/null
+++ b/pkg/gcc/rate_calculator_test.go
@@ -0,0 +1,115 @@
+package gcc
+
+import (
+	"testing"
+	"time"
+
+	"github.com/pion/interceptor/internal/cc"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestRateCalculator(t *testing.T) {
+	t0 := time.Now()
+	cases := []struct {
+		name     string
+		acks     []cc.Acknowledgment
+		expected []int
+	}{
+		{
+			name:     "emptyCreatesNoRate",
+			acks:     []cc.Acknowledgment{},
+			expected: []int{},
+		},
+		{
+			name: "ignoresZeroArrivalTimes",
+			acks: []cc.Acknowledgment{{
+				TLCC:      0,
+				Size:      0,
+				Departure: time.Time{},
+				Arrival:   time.Time{},
+				RTT:       0,
+			}},
+			expected: []int{},
+		},
+		{
+			name: "singleAckCreatesRate",
+			acks: []cc.Acknowledgment{{
+				TLCC:      0,
+				Size:      1000,
+				Departure: time.Time{},
+				Arrival:   t0,
+				RTT:       0,
+			}},
+			expected: []int{8000},
+		},
+		{
+			name: "twoAcksCalculateCorrectRates",
+			acks: []cc.Acknowledgment{{
+				TLCC:      0,
+				Size:      125,
+				Departure: time.Time{},
+				Arrival:   t0,
+				RTT:       0,
+			}, {
+				TLCC:      0,
+				Size:      125,
+				Departure: time.Time{},
+				Arrival:   t0.Add(100 * time.Millisecond),
+				RTT:       0,
+			}},
+			expected: []int{1000, 20_000},
+		},
+		{
+			name: "steadyACKsCalculateCorrectRates",
+			acks: getACKStream(10, 1200, 100*time.Millisecond),
+			expected: []int{
+				9_600,
+				192_000,
+				144_000,
+				128_000,
+				120_000,
+				115_200,
+				115_200,
+				115_200,
+				115_200,
+				115_200,
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			rc := rateCalculator{
+				window: 500 * time.Millisecond,
+			}
+			in := make(chan cc.Acknowledgment)
+			out := rc.run(in)
+			go func() {
+				for _, ack := range tc.acks {
+					in <- ack
+				}
+				close(in)
+			}()
+
+			received := []int{}
+			for r := range out {
+				received = append(received, r)
+			}
+			assert.Equal(t, tc.expected, received)
+		})
+	}
+}
+
+func getACKStream(length int, size int, interval time.Duration) []cc.Acknowledgment {
+	res := []cc.Acknowledgment{}
+	t0 := time.Now()
+	for i := 0; i < length; i++ {
+		res = append(res, cc.Acknowledgment{
+			Size:    size,
+			Arrival: t0,
+		})
+		t0 = t0.Add(interval)
+	}
+	return res
+}
diff --git a/pkg/gcc/rate_controller_test.go b/pkg/gcc/rate_controller_test.go
new file mode 100644
index 0000000..6225893
--- /dev/null
+++ b/pkg/gcc/rate_controller_test.go
@@ -0,0 +1,77 @@
+package gcc
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestRateControllerRun(t *testing.T) {
+	cases := []struct {
+		name           string
+		initialBitrate int
+		usage          []usage
+		expected       []DelayStats
+	}{
+		{
+			name:           "empty",
+			initialBitrate: 100_000,
+			usage:          []usage{},
+			expected:       []DelayStats{},
+		},
+		{
+			name:           "increasesMultiplicativelyBy8000",
+			initialBitrate: 100_000,
+			usage:          []usage{usageNormal, usageNormal},
+			expected: []DelayStats{{
+				Usage:         usageNormal,
+				State:         stateIncrease,
+				TargetBitrate: 108_000,
+				Estimate:      0,
+				Threshold:     0,
+				RTT:           300 * time.Millisecond,
+			}},
+		},
+	}
+
+	t0 := time.Time{}
+	mockNoFn := func() time.Time {
+		t0 = t0.Add(100 * time.Millisecond)
+		return t0
+	}
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			dc := newRateController(mockNoFn, 100_000, 1_000, 50_000_000)
+			in := make(chan DelayStats)
+			receivedRate := make(chan int)
+			rtt := make(chan time.Duration)
+			out := dc.run(in, receivedRate, rtt)
+			receivedRate <- 100_000
+			rtt <- 300 * time.Millisecond
+			go func() {
+				for _, state := range tc.usage {
+					in <- DelayStats{
+						Measurement:   0,
+						Estimate:      0,
+						Threshold:     0,
+						Usage:         state,
+						State:         0,
+						TargetBitrate: 0,
+						RTT:           0,
+					}
+				}
+				time.Sleep(2 * time.Second)
+				close(in)
+			}()
+			received := []DelayStats{}
+			for ds := range out {
+				received = append(received, ds)
+			}
+			if len(tc.expected) > 0 {
+				assert.Equal(t, tc.expected[0], received[0])
+			}
+		})
+	}
+}
diff --git a/pkg/gcc/rtt_estimator_test.go b/pkg/gcc/rtt_estimator_test.go
new file mode 100644
index 0000000..3fd792d
--- /dev/null
+++ b/pkg/gcc/rtt_estimator_test.go
@@ -0,0 +1,74 @@
+package gcc
+
+import (
+	"testing"
+	"time"
+
+	"github.com/pion/interceptor/internal/cc"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestRTTEstimator(t *testing.T) {
+	cases := []struct {
+		name     string
+		ackLists [][]cc.Acknowledgment
+		expected []time.Duration
+	}{
+		{
+			name:     "noACKsNoRTT",
+			ackLists: [][]cc.Acknowledgment{},
+			expected: []time.Duration{},
+		},
+		{
+			name: "staticRTT",
+			ackLists: [][]cc.Acknowledgment{
+				{{RTT: 5 * time.Millisecond}},
+				{{RTT: 5 * time.Millisecond}},
+				{{RTT: 5 * time.Millisecond}},
+				{{RTT: 5 * time.Millisecond}},
+			},
+			expected: []time.Duration{
+				5 * time.Millisecond,
+				5 * time.Millisecond,
+				5 * time.Millisecond,
+				5 * time.Millisecond,
+			},
+		},
+		{
+			name: "",
+			ackLists: [][]cc.Acknowledgment{
+				{},
+				{{RTT: 5 * time.Millisecond}},
+				{{RTT: 5 * time.Millisecond}},
+				{{RTT: 5 * time.Millisecond}},
+				{{RTT: 5 * time.Millisecond}},
+			},
+			expected: []time.Duration{
+				5 * time.Millisecond,
+				5 * time.Millisecond,
+				5 * time.Millisecond,
+				5 * time.Millisecond,
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			re := newRTTEstimator()
+			in := make(chan []cc.Acknowledgment)
+			out := re.run(in)
+			go func() {
+				for _, acks := range tc.ackLists {
+					in <- acks
+				}
+				close(in)
+			}()
+			received := []time.Duration{}
+			for rtt := range out {
+				received = append(received, rtt)
+			}
+			assert.Equal(t, tc.expected, received)
+		})
+	}
+}
diff --git a/pkg/gcc/send_side_bwe_test.go b/pkg/gcc/send_side_bwe_test.go
new file mode 100644
index 0000000..d8a0a04
--- /dev/null
+++ b/pkg/gcc/send_side_bwe_test.go
@@ -0,0 +1,92 @@
+package gcc
+
+import (
+	"testing"
+
+	"github.com/pion/interceptor"
+	"github.com/pion/interceptor/pkg/twcc"
+	"github.com/pion/rtcp"
+	"github.com/pion/rtp"
+	"github.com/stretchr/testify/require"
+)
+
+// mockTWCCResponder is a RTPWriter that writes
+// TWCC feedback to a embedded SendSideBWE instantly
+type mockTWCCResponder struct {
+	bwe     *SendSideBWE
+	rtpChan chan []byte
+}
+
+func (m *mockTWCCResponder) Read(out []byte, attributes interceptor.Attributes) (int, interceptor.Attributes, error) {
+	pkt := <-m.rtpChan
+	copy(out, pkt)
+	return len(pkt), nil, nil
+}
+
+func (m *mockTWCCResponder) Write(pkts []rtcp.Packet, attributes interceptor.Attributes) (int, error) {
+	return 0, m.bwe.WriteRTCP(pkts, attributes)
+}
+
+// mockGCCWriteStream receives RTP packets that have been paced by
+// the congestion controller
+type mockGCCWriteStream struct {
+	twccResponder *mockTWCCResponder
+}
+
+func (m *mockGCCWriteStream) Write(header *rtp.Header, payload []byte, attributes interceptor.Attributes) (int, error) {
+	pkt, err := (&rtp.Packet{Header: *header, Payload: payload}).Marshal()
+	if err != nil {
+		panic(err)
+	}
+
+	m.twccResponder.rtpChan <- pkt
+	return 0, err
+}
+
+func TestSendSideBWE(t *testing.T) {
+	buffer := make([]byte, 1500)
+	rtpPayload := make([]byte, 1460)
+	streamInfo := &interceptor.StreamInfo{
+		SSRC:                1,
+		RTPHeaderExtensions: []interceptor.RTPHeaderExtension{{URI: transportCCURI, ID: 1}},
+	}
+
+	bwe, err := NewSendSideBWE()
+	require.NoError(t, err)
+	require.NotNil(t, bwe)
+
+	m := &mockGCCWriteStream{
+		&mockTWCCResponder{
+			bwe,
+			make(chan []byte, 500),
+		},
+	}
+
+	twccSender, err := (&twcc.SenderInterceptorFactory{}).NewInterceptor("")
+	require.NoError(t, err)
+	require.NotNil(t, twccSender)
+
+	twccInboundRTP := twccSender.BindRemoteStream(streamInfo, m.twccResponder)
+	twccSender.BindRTCPWriter(m.twccResponder)
+
+	require.Equal(t, latestBitrate, bwe.GetTargetBitrate())
+	require.NotEqual(t, 0, len(bwe.GetStats()))
+
+	rtpWriter := bwe.AddStream(streamInfo, m)
+	require.NotNil(t, rtpWriter)
+
+	twccWriter := twcc.HeaderExtensionInterceptor{}
+	rtpWriter = twccWriter.BindLocalStream(streamInfo, rtpWriter)
+
+	for i := 0; i <= 100; i++ {
+		if _, err = rtpWriter.Write(&rtp.Header{SSRC: 1, Extensions: []rtp.Extension{}}, rtpPayload, nil); err != nil {
+			panic(err)
+		}
+		if _, _, err = twccInboundRTP.Read(buffer, nil); err != nil {
+			panic(err)
+		}
+	}
+
+	// Sending a stream with zero loss and no RTT should increase estimate
+	require.Less(t, latestBitrate, bwe.GetTargetBitrate())
+}
diff --git a/pkg/gcc/slope_estimator_test.go b/pkg/gcc/slope_estimator_test.go
new file mode 100644
index 0000000..a92bf62
--- /dev/null
+++ b/pkg/gcc/slope_estimator_test.go
@@ -0,0 +1,111 @@
+package gcc
+
+import (
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func identity(d time.Duration) time.Duration {
+	return d
+}
+
+func TestSlopeEstimator(t *testing.T) {
+	cases := []struct {
+		name     string
+		ags      []arrivalGroup
+		expected []DelayStats
+	}{
+		{
+			name:     "emptyReturnsEmpty",
+			ags:      []arrivalGroup{},
+			expected: []DelayStats{},
+		},
+		{
+			name: "simpleDeltaTest",
+			ags: []arrivalGroup{
+				{
+					arrival:   time.Time{}.Add(5 * time.Millisecond),
+					departure: time.Time{}.Add(15 * time.Millisecond),
+				},
+				{
+					arrival:   time.Time{}.Add(10 * time.Millisecond),
+					departure: time.Time{}.Add(20 * time.Millisecond),
+				},
+			},
+			expected: []DelayStats{
+				{
+					Measurement:      0,
+					Estimate:         0,
+					Threshold:        0,
+					lastReceiveDelta: 5 * time.Millisecond,
+					Usage:            0,
+					State:            0,
+					TargetBitrate:    0,
+					RTT:              0,
+				},
+			},
+		},
+		{
+			name: "twoMeasurements",
+			ags: []arrivalGroup{
+				{
+					arrival:   time.Time{}.Add(5 * time.Millisecond),
+					departure: time.Time{}.Add(15 * time.Millisecond),
+				},
+				{
+					arrival:   time.Time{}.Add(10 * time.Millisecond),
+					departure: time.Time{}.Add(20 * time.Millisecond),
+				},
+				{
+					arrival:   time.Time{}.Add(15 * time.Millisecond),
+					departure: time.Time{}.Add(30 * time.Millisecond),
+				},
+			},
+			expected: []DelayStats{
+				{
+					Measurement:      0,
+					Estimate:         0,
+					Threshold:        0,
+					lastReceiveDelta: 5 * time.Millisecond,
+					Usage:            0,
+					State:            0,
+					TargetBitrate:    0,
+					RTT:              0,
+				},
+				{
+					Measurement:      -5 * time.Millisecond,
+					Estimate:         -5 * time.Millisecond,
+					Threshold:        0,
+					lastReceiveDelta: 5 * time.Millisecond,
+					Usage:            0,
+					State:            0,
+					TargetBitrate:    0,
+					RTT:              0,
+				},
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		tc := tc
+		t.Run(tc.name, func(t *testing.T) {
+			se := newSlopeEstimator(estimatorFunc(identity))
+			in := make(chan arrivalGroup)
+			out := se.run(in)
+			input := []time.Duration{}
+			go func() {
+				for _, ag := range tc.ags {
+					in <- ag
+				}
+				close(in)
+			}()
+			received := []DelayStats{}
+			for d := range out {
+				received = append(received, d)
+			}
+			assert.Equal(t, tc.expected, received, "%v != %v", input, received)
+		})
+	}
+}