Initial clean commit

This commit is contained in:
harshabose
2025-07-13 22:30:33 +05:30
commit 4a0788ff70
48 changed files with 6306 additions and 0 deletions

50
.gitignore vendored Normal file
View File

@@ -0,0 +1,50 @@
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
# Go workspace file
go.work
# Dependency directories
vendor/
# Build directories
build/
third_party/
# Environment files
*.env
!.env.example
# macOS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
# Windows
ehthumbs.db
Thumbs.db
# Linux
*~
# IDE files
.vscode/
.idea/
*.swp
*.swo
# Logs
*.log
logs/

177
bwestimator.go Normal file
View File

@@ -0,0 +1,177 @@
//go:build cgo_enabled
package client
import (
"context"
"errors"
"fmt"
"sync"
"time"
"github.com/pion/interceptor/pkg/cc"
"github.com/harshabose/simple_webrtc_comm/client/pkg/mediasource"
"github.com/harshabose/simple_webrtc_comm/client/pkg/transcode"
)
type subscriber struct {
id string // unique identifier
priority mediasource.Priority
callback transcode.UpdateBitrateCallBack
}
type BWEController struct {
estimator cc.BandwidthEstimator
interval time.Duration
subs []subscriber
mux sync.RWMutex
ctx context.Context
}
func createBWController(ctx context.Context) *BWEController {
return &BWEController{
subs: make([]subscriber, 0),
estimator: nil,
ctx: ctx,
}
}
func (bwc *BWEController) Start() {
go bwc.loop()
}
func (bwc *BWEController) Subscribe(id string, priority mediasource.Priority, callback transcode.UpdateBitrateCallBack) error {
bwc.mux.Lock()
defer bwc.mux.Unlock()
for _, sub := range bwc.subs {
if sub.id == id {
return errors.New("subscriber already exists")
}
}
bwc.subs = append(bwc.subs, subscriber{
id: id,
priority: priority,
callback: callback,
})
return nil
}
// getSubscribers returns a copy of subscribers for safe iteration
func (bwc *BWEController) getSubscribers() []subscriber {
bwc.mux.RLock()
defer bwc.mux.RUnlock()
// Return a copy to avoid holding the lock during iteration
subs := make([]subscriber, len(bwc.subs))
copy(subs, bwc.subs)
return subs
}
// calculateTotalPriority calculates sum of all subscriber priorities
func (bwc *BWEController) calculateTotalPriority(subs []subscriber) mediasource.Priority {
var totalPriority = mediasource.Level0
for _, sub := range subs {
totalPriority += sub.priority
}
return totalPriority
}
func (bwc *BWEController) loop() {
ticker := time.NewTicker(bwc.interval)
defer ticker.Stop()
for {
select {
case <-bwc.ctx.Done():
return
case <-ticker.C:
if bwc.estimator == nil {
continue
}
subs := bwc.getSubscribers()
if len(subs) == 0 {
continue
}
totalPriority := bwc.calculateTotalPriority(subs)
if totalPriority == mediasource.Level0 {
continue // No active priorities
}
totalBitrate, err := bwc.getBitrate()
if err != nil {
continue
}
// Process each subscriber
for _, sub := range subs {
if sub.priority == mediasource.Level0 {
continue
}
bitrate := int64(float64(totalBitrate) * float64(sub.priority) / float64(totalPriority))
go bwc.sendBitrateUpdate(len(subs), sub.callback, bitrate)
}
}
}
}
func (bwc *BWEController) sendBitrateUpdate(n int, callback transcode.UpdateBitrateCallBack, bitrate int64) {
timeout := bwc.interval
if n > 0 {
timeout = bwc.interval / time.Duration(n)
}
ctx, cancel := context.WithTimeout(bwc.ctx, timeout)
defer cancel()
done := make(chan error, 1)
go func() {
done <- callback(bitrate)
}()
select {
case err := <-done:
if err != nil {
fmt.Printf("bitrate update callback failed: %v\n", err)
return
}
case <-ctx.Done():
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
fmt.Println("bitrate update callback timed out")
return
}
fmt.Println("bitrate update callback cancelled")
}
}
func (bwc *BWEController) getBitrate() (int, error) {
if bwc.estimator == nil {
return 0, errors.New("estimator is nil")
}
return bwc.estimator.GetTargetBitrate(), nil
}
func (bwc *BWEController) Unsubscribe(id string) error {
bwc.mux.Lock()
defer bwc.mux.Unlock()
for i, sub := range bwc.subs {
if sub.id == id {
// Remove the subscriber by swapping with the last element
bwc.subs[i] = bwc.subs[len(bwc.subs)-1]
bwc.subs = bwc.subs[:len(bwc.subs)-1]
return nil
}
}
return errors.New("subscriber not found")
}

149
client.go Normal file
View File

@@ -0,0 +1,149 @@
package client
import (
"context"
"errors"
"github.com/pion/interceptor"
"github.com/pion/interceptor/pkg/cc"
"github.com/pion/webrtc/v4"
"github.com/harshabose/tools/pkg/multierr"
)
type Client struct {
peerConnections map[string]*PeerConnection
mediaEngine *webrtc.MediaEngine
settingsEngine *webrtc.SettingEngine
interceptorRegistry *interceptor.Registry
estimatorChan chan cc.BandwidthEstimator
api *webrtc.API
ctx context.Context
cancel context.CancelFunc
}
func NewClient(
ctx context.Context, cancel context.CancelFunc,
mediaEngine *webrtc.MediaEngine, interceptorRegistry *interceptor.Registry,
settings *webrtc.SettingEngine, options ...ClientOption,
) (*Client, error) {
if mediaEngine == nil {
mediaEngine = &webrtc.MediaEngine{}
}
if interceptorRegistry == nil {
interceptorRegistry = &interceptor.Registry{}
}
if settings == nil {
settings = &webrtc.SettingEngine{}
}
settings.DetachDataChannels()
peerConnections := &Client{
mediaEngine: mediaEngine,
interceptorRegistry: interceptorRegistry,
settingsEngine: settings,
peerConnections: make(map[string]*PeerConnection),
estimatorChan: make(chan cc.BandwidthEstimator, 10),
ctx: ctx,
cancel: cancel,
}
for _, option := range options {
if err := option(peerConnections); err != nil {
return nil, err
}
}
peerConnections.api = webrtc.NewAPI(webrtc.WithMediaEngine(peerConnections.mediaEngine), webrtc.WithInterceptorRegistry(peerConnections.interceptorRegistry), webrtc.WithSettingEngine(*peerConnections.settingsEngine))
return peerConnections, nil
}
func NewClientFromConfig(
ctx context.Context, cancel context.CancelFunc,
mediaEngine *webrtc.MediaEngine, interceptorRegistry *interceptor.Registry,
settings *webrtc.SettingEngine, config *ClientConfig,
) (*Client, error) {
return NewClient(
ctx, cancel, mediaEngine, interceptorRegistry, settings,
config.ToOptions()..., // The magic spread operator!
)
}
func (client *Client) CreatePeerConnection(label string, config webrtc.Configuration, options ...PeerConnectionOption) (*PeerConnection, error) {
var err error
if _, exists := client.peerConnections[label]; exists {
return nil, errors.New("peer connection already exists")
}
// TODO: CHANGE THE SIGNATURE; SENDING A CANCEL FUNC IS IDIOTIC
if client.peerConnections[label], err = CreatePeerConnection(client.ctx, client.cancel, label, client.api, config, options...); err != nil {
return nil, err
}
return client.peerConnections[label], nil
}
func (client *Client) CreatePeerConnectionFromConfig(config PeerConnectionConfig) (*PeerConnection, error) {
pc, err := client.CreatePeerConnection(config.Name, config.RTCConfig, config.ToOptions()...)
if err != nil {
return nil, err
}
if err := config.CreateDataChannels(pc); err != nil {
return nil, err
}
if err := config.CreateMediaSources(pc); err != nil {
return nil, err
}
if err := config.CreateMediaSinks(pc); err != nil {
return nil, err
}
return pc, nil
}
func (client *Client) GetPeerConnection(label string) (*PeerConnection, error) {
if _, exists := client.peerConnections[label]; !exists {
return nil, errors.New("peer connection not found")
}
return client.peerConnections[label], nil
}
func (client *Client) WaitUntilClosed() {
<-client.ctx.Done()
}
func (client *Client) Connect(category string) error {
var merr error
for _, pc := range client.peerConnections {
if err := pc.Connect(category); err != nil {
merr = multierr.Append(merr, err)
}
}
return merr
}
func (client *Client) ClosePeerConnection(label string) error {
pc, err := client.GetPeerConnection(label)
if err != nil {
return err
}
return pc.Close()
}
func (client *Client) Close() error {
for _, peerConnection := range client.peerConnections {
if err := peerConnection.Close(); err != nil {
return err
}
}
return nil
}

35
client_cgo.go Normal file
View File

@@ -0,0 +1,35 @@
//go:build cgo_enabled
package client
import (
"errors"
"time"
"github.com/pion/webrtc/v4"
)
func (client *Client) CreatePeerConnectionWithBWEstimator(label string, config webrtc.Configuration, options ...PeerConnectionOption) (*PeerConnection, error) {
var err error
if _, exists := client.peerConnections[label]; exists {
return nil, errors.New("peer connection already exists")
}
// TODO: CHANGE THE SIGNATURE; SENDING A CANCEL FUNC IS IDIOTIC
if client.peerConnections[label], err = CreatePeerConnection(client.ctx, client.cancel, label, client.api, config, options...); err != nil {
return nil, err
}
// TODO: THIS WEIRD CHANNEL BASED APPROACH OF SETTING BW CONTROLLER IS REQUIRED BECAUSE OF THE
// TODO: THE WEIRD DESIGN OF CC INTERCEPTOR IN PION. TRACK THE ISSUE WITH "https://github.com/pion/webrtc/issues/3053"
if client.peerConnections[label].bwController != nil {
select {
case estimator := <-client.estimatorChan:
client.peerConnections[label].bwController.estimator = estimator
client.peerConnections[label].bwController.interval = 50 * time.Millisecond
}
}
return client.peerConnections[label], nil
}

52
client_constants.go Normal file
View File

@@ -0,0 +1,52 @@
package client
import (
"time"
"github.com/pion/interceptor/pkg/nack"
"github.com/pion/webrtc/v4"
)
const (
H264PayloadType webrtc.PayloadType = 102
H264RTXPayloadType webrtc.PayloadType = 103
VP8PayloadType webrtc.PayloadType = 96
VP8RTXPayloadType webrtc.PayloadType = 97
OpusPayloadType webrtc.PayloadType = 111
)
type NACKGeneratorOptions []nack.GeneratorOption
var (
NACKGeneratorLowLatency NACKGeneratorOptions = []nack.GeneratorOption{nack.GeneratorSize(256), nack.GeneratorSkipLastN(2), nack.GeneratorMaxNacksPerPacket(1), nack.GeneratorInterval(10 * time.Millisecond)}
NACKGeneratorDefault NACKGeneratorOptions = []nack.GeneratorOption{nack.GeneratorSize(512), nack.GeneratorSkipLastN(5), nack.GeneratorMaxNacksPerPacket(2), nack.GeneratorInterval(50 * time.Millisecond)}
NACKGeneratorHighQuality NACKGeneratorOptions = []nack.GeneratorOption{nack.GeneratorSize(4096), nack.GeneratorSkipLastN(10), nack.GeneratorMaxNacksPerPacket(5), nack.GeneratorInterval(100 * time.Millisecond)}
NACKGeneratorLowBandwidth NACKGeneratorOptions = []nack.GeneratorOption{nack.GeneratorSize(256), nack.GeneratorSkipLastN(15), nack.GeneratorMaxNacksPerPacket(1), nack.GeneratorInterval(200 * time.Millisecond)}
)
type NACKResponderOptions []nack.ResponderOption
var (
NACKResponderLowLatency NACKResponderOptions = []nack.ResponderOption{nack.ResponderSize(256)}
NACKResponderDefault NACKResponderOptions = []nack.ResponderOption{nack.ResponderSize(1024)}
NACKResponderHighQuality NACKResponderOptions = []nack.ResponderOption{nack.ResponderSize(4096)}
NACKResponderLowBandwidth NACKResponderOptions = []nack.ResponderOption{nack.ResponderSize(256)}
)
type TWCCSenderInterval time.Duration
const (
TWCCIntervalLowLatency = TWCCSenderInterval(100 * time.Millisecond)
TWCCIntervalDefault = TWCCSenderInterval(200 * time.Millisecond)
TWCCIntervalHighQuality = TWCCSenderInterval(300 * time.Millisecond)
TWCCIntervalLowBandwidth = TWCCSenderInterval(500 * time.Millisecond)
)
type RTCPReportInterval time.Duration
const (
RTCPReportIntervalLowLatency = RTCPReportInterval(1 * time.Second)
RTCPReportIntervalDefault = RTCPReportInterval(3 * time.Second)
RTCPReportIntervalHighQuality = RTCPReportInterval(2 * time.Second)
RTCPReportIntervalLowBandwidth = RTCPReportInterval(10 * time.Second)
)

248
client_options.go Normal file
View File

@@ -0,0 +1,248 @@
package client
import (
"fmt"
"time"
"github.com/pion/interceptor/pkg/cc"
"github.com/pion/interceptor/pkg/flexfec"
"github.com/pion/interceptor/pkg/gcc"
"github.com/pion/interceptor/pkg/jitterbuffer"
"github.com/pion/interceptor/pkg/nack"
"github.com/pion/interceptor/pkg/report"
"github.com/pion/interceptor/pkg/twcc"
"github.com/pion/sdp/v3"
"github.com/pion/webrtc/v4"
"github.com/harshabose/simple_webrtc_comm/client/pkg/mediasource"
)
type ClientOption = func(*Client) error
func WithH264MediaEngine(clockrate uint32, packetisationMode mediasource.PacketisationMode, profileLevelID mediasource.ProfileLevel, sps, pps string) ClientOption {
return func(client *Client) error {
RTCPFeedback := []webrtc.RTCPFeedback{{Type: webrtc.TypeRTCPFBGoogREMB}, {Type: webrtc.TypeRTCPFBCCM, Parameter: "fir"}, {Type: webrtc.TypeRTCPFBNACK}, {Type: webrtc.TypeRTCPFBNACK, Parameter: "pli"}}
if err := client.mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeH264,
ClockRate: clockrate,
Channels: 0,
SDPFmtpLine: fmt.Sprintf("level-asymmetry-allowed=1;packetization-mode=%d;profile-level-id=%s;sprop-parameter-sets=%s,%s", packetisationMode, profileLevelID, sps, pps),
RTCPFeedback: RTCPFeedback,
},
PayloadType: H264PayloadType,
}, webrtc.RTPCodecTypeVideo); err != nil {
return err
}
if err := client.mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeRTX,
ClockRate: clockrate,
Channels: 0,
SDPFmtpLine: fmt.Sprintf("apt=%d", H264PayloadType),
RTCPFeedback: nil,
},
PayloadType: H264RTXPayloadType,
}, webrtc.RTPCodecTypeVideo); err != nil {
return err
}
return nil
}
}
func WithVP8MediaEngine(clockrate uint32) ClientOption {
return func(client *Client) error {
RTCPFeedback := []webrtc.RTCPFeedback{{Type: webrtc.TypeRTCPFBGoogREMB}, {Type: webrtc.TypeRTCPFBCCM, Parameter: "fir"}, {Type: webrtc.TypeRTCPFBNACK}, {Type: webrtc.TypeRTCPFBNACK, Parameter: "pli"}}
if err := client.mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeVP8,
ClockRate: clockrate,
RTCPFeedback: RTCPFeedback,
SDPFmtpLine: fmt.Sprintf(""),
},
PayloadType: VP8PayloadType,
}, webrtc.RTPCodecTypeVideo); err != nil {
return err
}
if err := client.mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeRTX,
ClockRate: clockrate,
RTCPFeedback: nil,
SDPFmtpLine: fmt.Sprintf("apt=%d", VP8PayloadType),
},
PayloadType: VP8RTXPayloadType,
}, webrtc.RTPCodecTypeVideo); err != nil {
return err
}
return nil
}
}
func WithDefaultMediaEngine() ClientOption {
return func(client *Client) error {
if err := client.mediaEngine.RegisterDefaultCodecs(); err != nil {
return err
}
return nil
}
}
func WithDefaultInterceptorRegistry() ClientOption {
return func(client *Client) error {
if err := webrtc.RegisterDefaultInterceptors(client.mediaEngine, client.interceptorRegistry); err != nil {
return err
}
return nil
}
}
func WithOpusMediaEngine(samplerate uint32, channelLayout uint16, stereo mediasource.StereoType) ClientOption {
return func(client *Client) error {
if err := client.mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeOpus,
ClockRate: samplerate,
Channels: channelLayout,
RTCPFeedback: nil,
SDPFmtpLine: fmt.Sprintf("minptime=10;useinbandfec=1;stereo=%d", stereo),
},
PayloadType: OpusPayloadType,
}, webrtc.RTPCodecTypeAudio); err != nil {
return err
}
return nil
}
}
func WithNACKInterceptor(generatorOptions NACKGeneratorOptions, responderOptions NACKResponderOptions) ClientOption {
return func(client *Client) error {
generator, err := nack.NewGeneratorInterceptor(generatorOptions...)
if err != nil {
return err
}
responder, err := nack.NewResponderInterceptor(responderOptions...)
if err != nil {
return err
}
client.mediaEngine.RegisterFeedback(webrtc.RTCPFeedback{Type: webrtc.TypeRTCPFBNACK}, webrtc.RTPCodecTypeVideo)
client.mediaEngine.RegisterFeedback(webrtc.RTCPFeedback{Type: webrtc.TypeRTCPFBNACK, Parameter: "pli"}, webrtc.RTPCodecTypeVideo)
client.interceptorRegistry.Add(responder)
client.interceptorRegistry.Add(generator)
return nil
}
}
func WithTWCCSenderInterceptor(interval TWCCSenderInterval) ClientOption {
return func(client *Client) error {
client.mediaEngine.RegisterFeedback(webrtc.RTCPFeedback{Type: webrtc.TypeRTCPFBTransportCC}, webrtc.RTPCodecTypeVideo)
if err := client.mediaEngine.RegisterHeaderExtension(webrtc.RTPHeaderExtensionCapability{URI: sdp.TransportCCURI}, webrtc.RTPCodecTypeVideo); err != nil {
return err
}
client.mediaEngine.RegisterFeedback(webrtc.RTCPFeedback{Type: webrtc.TypeRTCPFBTransportCC}, webrtc.RTPCodecTypeAudio)
if err := client.mediaEngine.RegisterHeaderExtension(webrtc.RTPHeaderExtensionCapability{URI: sdp.TransportCCURI}, webrtc.RTPCodecTypeAudio); err != nil {
return err
}
generator, err := twcc.NewSenderInterceptor(twcc.SendInterval(time.Duration(interval)))
if err != nil {
return err
}
client.interceptorRegistry.Add(generator)
return nil
}
}
// WARN: DO NOT USE THIS, PION HAS SOME ISSUE WITH THIS WHICH MAKES THE ONTRACK CALLBACK NOT FIRE
func WithJitterBufferInterceptor() ClientOption {
return func(client *Client) error {
var (
jitterBuffer *jitterbuffer.InterceptorFactory
err error
)
if jitterBuffer, err = jitterbuffer.NewInterceptor(); err != nil {
return err
}
client.interceptorRegistry.Add(jitterBuffer)
return nil
}
}
func WithRTCPReportsInterceptor(interval RTCPReportInterval) ClientOption {
return func(client *Client) error {
receiver, err := report.NewReceiverInterceptor(report.ReceiverInterval(time.Duration(interval)))
if err != nil {
return err
}
sender, err := report.NewSenderInterceptor(report.SenderInterval(time.Duration(interval)))
if err != nil {
return err
}
client.interceptorRegistry.Add(receiver)
client.interceptorRegistry.Add(sender)
return nil
}
}
// WARN: DO NOT USE FLEXFEC YET, AS THE FECOPTION ARE NOT YET IMPLEMENTED
func WithFLEXFECInterceptor() ClientOption {
return func(client *Client) error {
var (
fecInterceptor *flexfec.FecInterceptorFactory
err error
)
// NOTE: Pion's FLEXFEC does not implement FecOption yet, if needed, someone needs to contribute to the repo
if fecInterceptor, err = flexfec.NewFecInterceptor(); err != nil {
return err
}
client.interceptorRegistry.Add(fecInterceptor)
return nil
}
}
func WithSimulcastExtensionHeaders() ClientOption {
return func(client *Client) error {
return webrtc.ConfigureSimulcastExtensionHeaders(client.mediaEngine)
}
}
func WithBandwidthControlInterceptor(initialBitrate, minimumBitrate, maximumBitrate uint64, interval time.Duration) ClientOption {
return func(client *Client) error {
congestionController, err := cc.NewInterceptor(func() (cc.BandwidthEstimator, error) {
return gcc.NewSendSideBWE(gcc.SendSideBWEInitialBitrate(int(initialBitrate)), gcc.SendSideBWEMinBitrate(int(minimumBitrate)), gcc.SendSideBWEMaxBitrate(int(maximumBitrate)))
})
if err != nil {
return err
}
congestionController.OnNewPeerConnection(func(id string, estimator cc.BandwidthEstimator) {
client.estimatorChan <- estimator
})
client.interceptorRegistry.Add(congestionController)
return nil
}
}
func WithTWCCHeaderExtensionSender() ClientOption {
return func(client *Client) error {
return webrtc.ConfigureTWCCHeaderExtensionSender(client.mediaEngine, client.interceptorRegistry)
}
}

469
config.go Normal file
View File

@@ -0,0 +1,469 @@
package client
import (
"time"
"github.com/pion/webrtc/v4"
"github.com/harshabose/simple_webrtc_comm/client/pkg/datachannel"
"github.com/harshabose/simple_webrtc_comm/client/pkg/mediasink"
"github.com/harshabose/simple_webrtc_comm/client/pkg/mediasource"
)
type ClientConfig struct {
Name string
// Media configuration
H264 *H264Config `json:"h264,omitempty"`
VP8 *VP8Config `json:"vp8,omitempty"`
Opus *OpusConfig `json:"opus,omitempty"`
// Interceptor configurations
NACK *NACKPreset `json:"nack,omitempty"`
RTCPReports *RTCPReportsPreset `json:"rtcp_reports,omitempty"`
TWCC *TWCCPreset `json:"twcc,omitempty"`
Bandwidth *BandwidthConfig `json:"bandwidth,omitempty"`
// Feature flags
SimulcastExtensions bool `json:"simulcast_extensions,omitempty"`
TWCCHeaderExtension bool `json:"twcc_header_extension,omitempty"`
}
type H264Config struct {
ClockRate uint32 `json:"clock_rate"`
PacketisationMode mediasource.PacketisationMode `json:"packetisation_mode"`
ProfileLevel mediasource.ProfileLevel `json:"profile_level"`
SPSBase64 string `json:"sps_base64"`
PPSBase64 string `json:"pps_base64"`
}
type VP8Config struct {
ClockRate uint32 `json:"clock_rate"`
}
type OpusConfig struct {
SampleRate uint32 `json:"sample_rate"`
ChannelLayout uint16 `json:"channel_layout"`
Stereo mediasource.StereoType `json:"stereo"`
}
type NACKPreset string
type RTCPReportsPreset string
type TWCCPreset string
const (
NACKLowLatency NACKPreset = "low_latency"
NACKDefault NACKPreset = "default"
NACKHighQuality NACKPreset = "high_quality"
NACKLowBandwidth NACKPreset = "low_bandwidth"
RTCPReportsLowLatency RTCPReportsPreset = "low_latency"
RTCPReportsDefault RTCPReportsPreset = "default"
RTCPReportsHighQuality RTCPReportsPreset = "high_quality"
RTCPReportsLowBandwidth RTCPReportsPreset = "low_bandwidth"
TWCCLowLatency TWCCPreset = "low_latency"
TWCCDefault TWCCPreset = "default"
TWCCHighQuality TWCCPreset = "high_quality"
TWCCLowBandwidth TWCCPreset = "low_bandwidth"
)
type BandwidthConfig struct {
Initial uint64 `json:"initial"`
Minimum uint64 `json:"minimum"`
Maximum uint64 `json:"maximum"`
Interval time.Duration `json:"interval"`
}
type optionBuilder struct {
options []ClientOption
}
func (ob *optionBuilder) add(option ClientOption) *optionBuilder {
if option != nil {
ob.options = append(ob.options, option)
}
return ob
}
var (
nackGeneratorPresets = map[NACKPreset]NACKGeneratorOptions{
NACKLowLatency: NACKGeneratorLowLatency,
NACKDefault: NACKGeneratorDefault,
NACKHighQuality: NACKGeneratorHighQuality,
NACKLowBandwidth: NACKGeneratorLowBandwidth,
}
nackResponderPresets = map[NACKPreset]NACKResponderOptions{
NACKLowLatency: NACKResponderLowLatency,
NACKDefault: NACKResponderDefault,
NACKHighQuality: NACKResponderHighQuality,
NACKLowBandwidth: NACKResponderLowBandwidth,
}
rtcpReportsPresets = map[RTCPReportsPreset]RTCPReportInterval{
RTCPReportsLowLatency: RTCPReportIntervalLowLatency,
RTCPReportsDefault: RTCPReportIntervalDefault,
RTCPReportsHighQuality: RTCPReportIntervalHighQuality,
RTCPReportsLowBandwidth: RTCPReportIntervalLowBandwidth,
}
twccPresets = map[TWCCPreset]TWCCSenderInterval{
TWCCLowLatency: TWCCIntervalLowLatency,
TWCCDefault: TWCCIntervalDefault,
TWCCHighQuality: TWCCIntervalHighQuality,
TWCCLowBandwidth: TWCCIntervalLowBandwidth,
}
)
func (c *ClientConfig) ToOptions() []ClientOption {
builder := &optionBuilder{}
return builder.
add(c.h264Option()).
add(c.vp8Option()).
add(c.opusOption()).
add(c.nackOption()).
add(c.rtcpReportsOption()).
add(c.twccOption()).
add(c.bandwidthOption()).
add(c.simulcastOption()).
add(c.twccHeaderOption()).
options
}
func (c *ClientConfig) h264Option() ClientOption {
if c.H264 == nil {
return nil
}
return WithH264MediaEngine(
c.H264.ClockRate,
c.H264.PacketisationMode,
c.H264.ProfileLevel,
c.H264.SPSBase64,
c.H264.PPSBase64,
)
}
func (c *ClientConfig) vp8Option() ClientOption {
if c.VP8 == nil {
return nil
}
return WithVP8MediaEngine(c.VP8.ClockRate)
}
func (c *ClientConfig) opusOption() ClientOption {
if c.Opus == nil {
return nil
}
return WithOpusMediaEngine(
c.Opus.SampleRate,
c.Opus.ChannelLayout,
c.Opus.Stereo,
)
}
func (c *ClientConfig) nackOption() ClientOption {
if c.NACK == nil {
return nil
}
generator, generatorExists := nackGeneratorPresets[*c.NACK]
responder, responderExists := nackResponderPresets[*c.NACK]
if !generatorExists || !responderExists {
return nil
}
return WithNACKInterceptor(generator, responder)
}
func (c *ClientConfig) rtcpReportsOption() ClientOption {
if c.RTCPReports == nil {
return nil
}
interval, exists := rtcpReportsPresets[*c.RTCPReports]
if !exists {
return nil
}
return WithRTCPReportsInterceptor(interval)
}
func (c *ClientConfig) twccOption() ClientOption {
if c.TWCC == nil {
return nil
}
interval, exists := twccPresets[*c.TWCC]
if !exists {
return nil
}
return WithTWCCSenderInterceptor(interval)
}
func (c *ClientConfig) bandwidthOption() ClientOption {
if c.Bandwidth == nil {
return nil
}
return WithBandwidthControlInterceptor(
c.Bandwidth.Initial,
c.Bandwidth.Minimum,
c.Bandwidth.Maximum,
c.Bandwidth.Interval,
)
}
func (c *ClientConfig) simulcastOption() ClientOption {
if !c.SimulcastExtensions {
return nil
}
return WithSimulcastExtensionHeaders()
}
func (c *ClientConfig) twccHeaderOption() ClientOption {
if !c.TWCCHeaderExtension {
return nil
}
return WithTWCCHeaderExtensionSender()
}
type PeerConnectionConfig struct {
// Basic settings
Name string `json:"name"`
// RTC and Singnaling Control
FirebaseOfferSignal *bool `json:"firebase_offer_signal,omitempty"`
FirebaseOfferAnswer *bool `json:"firebase_offer_answer"`
RTCConfig webrtc.Configuration `json:"rtc_config"`
// Declarative resource definitions
DataChannels []DataChannelSpec `json:"data_channels_specs,omitempty"`
MediaSources []MediaSourceSpec `json:"media_sources_specs,omitempty"`
MediaSinks []MediaSinkSpec `json:"media_sinks_specs,omitempty"`
}
type DataChannelSpec struct {
Label string `json:"label"`
ID *uint16 `json:"id,omitempty"`
Ordered *bool `json:"ordered,omitempty"`
Protocol *string `json:"protocol,omitempty"`
Negotiated *bool `json:"negotiated,omitempty"`
MaxPacketLifeTime *uint16 `json:"max_packet_life_time,omitempty"`
MaxRetransmits *uint16 `json:"max_retransmits,omitempty"`
}
type MediaSourceSpec struct {
Name string `json:"name"`
H264 *H264TrackConfig `json:"h264,omitempty"`
VP8 *VP8TrackConfig `json:"vp8,omitempty"`
Opus *OpusTrackConfig `json:"opus,omitempty"`
Priority *mediasource.Priority `json:"priority,omitempty"`
}
type trackOptionBuilder struct {
options []mediasource.TrackOption
}
func (ob *trackOptionBuilder) add(option mediasource.TrackOption) *trackOptionBuilder {
if option != nil {
ob.options = append(ob.options, option)
}
return ob
}
func (c *MediaSourceSpec) withTrackOption() mediasource.TrackOption {
if c.H264 != nil {
return mediasource.WithH264Track(c.H264.ClockRate, c.H264.PacketisationMode, c.H264.ProfileLevel)
}
if c.VP8 != nil {
return mediasource.WithVP8Track(c.VP8.ClockRate)
}
if c.Opus != nil {
return mediasource.WithOpusTrack(c.Opus.Samplerate, c.Opus.ChannelLayout, c.Opus.Stereo)
}
return nil
}
func (c *MediaSourceSpec) ToOptions() []mediasource.TrackOption {
builder := trackOptionBuilder{}
return builder.add(c.withTrackOption()).add(c.withPriority()).options
}
func (c *MediaSourceSpec) withPriority() mediasource.TrackOption {
if c.Priority == nil {
return nil
}
return mediasource.WithPriority(*c.Priority)
}
// MediaSinkSpec defines a media sink to create
type MediaSinkSpec struct {
Name string `json:"name"`
H264 *H264TrackConfig `json:"h264,omitempty"`
VP8 *VP8TrackConfig `json:"vp8,omitempty"`
Opus *OpusTrackConfig `json:"opus,omitempty"`
}
type sinkOptionBuilder struct {
options []mediasink.SinkOption
}
func (ob *sinkOptionBuilder) add(option mediasink.SinkOption) *sinkOptionBuilder {
if option != nil {
ob.options = append(ob.options, option)
}
return ob
}
func (c *MediaSinkSpec) withTrackOption() mediasink.SinkOption {
if c.H264 != nil {
return mediasink.WithH264Track(c.H264.ClockRate)
}
if c.VP8 != nil {
return mediasink.WithVP8Track(c.VP8.ClockRate)
}
if c.Opus != nil {
return mediasink.WithOpusTrack(c.Opus.Samplerate, c.Opus.ChannelLayout)
}
return nil
}
func (c *MediaSinkSpec) ToOptions() []mediasink.SinkOption {
builder := sinkOptionBuilder{}
return builder.add(c.withTrackOption()).options
}
type H264TrackConfig struct {
ClockRate uint32 `json:"clock_rate"`
PacketisationMode mediasource.PacketisationMode `json:"packetisation_mode"`
ProfileLevel mediasource.ProfileLevel `json:"profile_level"`
}
type VP8TrackConfig struct {
ClockRate uint32 `json:"clock_rate"`
}
type OpusTrackConfig struct {
Samplerate uint32 `json:"samplerate"`
ChannelLayout uint16 `json:"channel_layout"`
Stereo mediasource.StereoType `json:"stereo"`
}
type pcOptionBuilder struct {
options []PeerConnectionOption
}
func (ob *pcOptionBuilder) add(option PeerConnectionOption) *pcOptionBuilder {
if option != nil {
ob.options = append(ob.options, option)
}
return ob
}
func (c *PeerConnectionConfig) ToOptions() []PeerConnectionOption {
builder := pcOptionBuilder{}
return builder.
add(c.withFirebaseOfferSignal()).
add(c.withFirebaseOfferAnswer()).
add(c.withDataChannels()).
add(c.withMediaSource()).
add(c.withMediaSinks()).
options
}
func (c *PeerConnectionConfig) withFirebaseOfferSignal() PeerConnectionOption {
if c.FirebaseOfferSignal == nil {
return nil
}
return WithFirebaseOfferSignal
}
func (c *PeerConnectionConfig) withFirebaseOfferAnswer() PeerConnectionOption {
if c.FirebaseOfferAnswer == nil {
return nil
}
return WithFirebaseAnswerSignal
}
func (c *PeerConnectionConfig) withMediaSource() PeerConnectionOption {
if len(c.MediaSources) == 0 {
return nil
}
return WithMediaSources()
}
func (c *PeerConnectionConfig) withMediaSinks() PeerConnectionOption {
if len(c.MediaSinks) == 0 {
return nil
}
return WithMediaSinks()
}
func (c *PeerConnectionConfig) withDataChannels() PeerConnectionOption {
if len(c.DataChannels) == 0 {
return nil
}
return WithDataChannels()
}
func (c *PeerConnectionConfig) CreateDataChannels(pc *PeerConnection) error {
if len(c.DataChannels) == 0 {
return nil
}
for _, config := range c.DataChannels {
if _, err := pc.CreateDataChannel(config.Label, datachannel.WithDataChannelInit(&webrtc.DataChannelInit{
Ordered: config.Ordered,
MaxPacketLifeTime: config.MaxPacketLifeTime,
MaxRetransmits: config.MaxRetransmits,
Protocol: config.Protocol,
Negotiated: config.Negotiated,
ID: config.ID,
})); err != nil {
return err
}
}
return nil
}
func (c *PeerConnectionConfig) CreateMediaSources(pc *PeerConnection) error {
if len(c.MediaSources) == 0 {
return nil
}
for _, config := range c.MediaSources {
if _, err := pc.CreateMediaSource(c.Name, config.ToOptions()...); err != nil {
return err
}
}
return nil
}
func (c *PeerConnectionConfig) CreateMediaSinks(pc *PeerConnection) error {
if len(c.MediaSinks) == 0 {
return nil
}
for _, config := range c.MediaSinks {
if _, err := pc.CreateMediaSink(c.Name, config.ToOptions()...); err != nil {
return err
}
}
return nil
}

18
config_cgo.go Normal file
View File

@@ -0,0 +1,18 @@
//go:build cgo_enabled
package client
// func (c *PeerConnectionConfig) withBandwidthControl() PeerConnectionOption {
// if len(c.BWSubscriptions) == 0 {
// return nil
// }
// return WithBandwidthControl()
// }
// func (c *PeerConnectionConfig) ToOptions() []PeerConnectionOption {
// builder := pcOptionBuilder{}
//
// return builder.
// add(c.withFirebaseOfferSignal()).
// options
// }

180
file_answer.go Normal file
View File

@@ -0,0 +1,180 @@
package client
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/pion/webrtc/v4"
)
// FileAnswerSignal implements BaseSignal interface for file-based signaling (answer side)
type FileAnswerSignal struct {
peerConnection *PeerConnection
ctx context.Context
offerPath string
answerPath string
}
// CreateFileAnswerSignal creates a new FileAnswerSignal
func CreateFileAnswerSignal(ctx context.Context, peerConnection *PeerConnection, offerPath string, answerPath string) *FileAnswerSignal {
return &FileAnswerSignal{
peerConnection: peerConnection,
ctx: ctx,
offerPath: offerPath,
answerPath: answerPath,
}
}
// Connect implements the BaseSignal interface
func (signal *FileAnswerSignal) Connect(category, connectionLabel string) error {
// Use category and connectionLabel to create unique filenames if needed
if category != "" && connectionLabel != "" {
signal.offerPath = filepath.Join(signal.offerPath, category, connectionLabel, "offer.txt")
signal.answerPath = filepath.Join(signal.answerPath, category, connectionLabel, "answer.txt")
// Ensure directory exists
dir := filepath.Dir(signal.answerPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("error creating directory %s: %w", dir, err)
}
}
// Wait for offer file to exist
offer, err := signal.waitForOffer()
if err != nil {
return err
}
// Process the offer
return signal.processOffer(offer)
}
// waitForOffer waits for the offer file to appear and loads it
func (signal *FileAnswerSignal) waitForOffer() (webrtc.SessionDescription, error) {
var offer webrtc.SessionDescription
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
timeout := time.NewTimer(5 * time.Minute)
defer timeout.Stop()
fmt.Printf("Waiting for offer file at %s...\n", signal.offerPath)
for {
select {
case <-ticker.C:
// Check if offer file exists
if _, err := os.Stat(signal.offerPath); err == nil {
// Load offer from file
offer, err := signal.loadSDPFromFile(signal.offerPath)
if err != nil {
fmt.Printf("Error loading offer: %v, retrying...\n", err)
continue
}
return offer, nil
}
case <-timeout.C:
return offer, errors.New("timeout waiting for offer file")
case <-signal.ctx.Done():
return offer, errors.New("context canceled")
}
}
}
// processOffer processes the offer and creates an answer
func (signal *FileAnswerSignal) processOffer(offer webrtc.SessionDescription) error {
// SetInputOption remote description
if err := signal.peerConnection.peerConnection.SetRemoteDescription(offer); err != nil {
return fmt.Errorf("error setting remote description: %w", err)
}
// Create answer
answer, err := signal.peerConnection.peerConnection.CreateAnswer(nil)
if err != nil {
return fmt.Errorf("error creating answer: %w", err)
}
// SetInputOption local description
if err := signal.peerConnection.peerConnection.SetLocalDescription(answer); err != nil {
return fmt.Errorf("error setting local description: %w", err)
}
// Wait for ICE gathering to complete
timer := time.NewTicker(30 * time.Second)
defer timer.Stop()
select {
case <-webrtc.GatheringCompletePromise(signal.peerConnection.peerConnection):
fmt.Println("ICE Gathering complete")
case <-timer.C:
return errors.New("failed to gather ICE candidates within 30 seconds")
}
// Save answer to file
if err := signal.saveSDPToFile(signal.peerConnection.peerConnection.LocalDescription(), signal.answerPath); err != nil {
return fmt.Errorf("error saving answer to file: %w", err)
}
fmt.Printf("Answer saved to %s\n", signal.answerPath)
return nil
}
// Close implements the BaseSignal interface
func (signal *FileAnswerSignal) Close() error {
// Nothing to close for file-based signaling
return nil
}
// saveSDPToFile saves a SessionDescription to a file
func (signal *FileAnswerSignal) saveSDPToFile(sdp *webrtc.SessionDescription, filename string) error {
// Ensure directory exists
dir := filepath.Dir(filename)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("error creating directory %s: %w", dir, err)
}
// Encode SDP
b, err := json.Marshal(sdp)
if err != nil {
return err
}
encoded := base64.StdEncoding.EncodeToString(b)
// Write to file
if err := os.WriteFile(filename, []byte(encoded), 0644); err != nil {
return err
}
fmt.Printf("SDP saved to %s (%d bytes)\n", filename, len(encoded))
return nil
}
// loadSDPFromFile loads a SessionDescription from a file
func (signal *FileAnswerSignal) loadSDPFromFile(filename string) (webrtc.SessionDescription, error) {
var sdp webrtc.SessionDescription
data, err := os.ReadFile(filename)
if err != nil {
return sdp, fmt.Errorf("error reading %s: %w", filename, err)
}
encoded := string(data)
b, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return sdp, fmt.Errorf("base64 decode error: %w", err)
}
if err = json.Unmarshal(b, &sdp); err != nil {
return sdp, fmt.Errorf("JSON unmarshal error: %w", err)
}
fmt.Printf("SDP loaded from %s (%d bytes)\n", filename, len(data))
return sdp, nil
}

181
file_offer.go Normal file
View File

@@ -0,0 +1,181 @@
package client
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/pion/webrtc/v4"
)
// FileOfferSignal implements BaseSignal interface for file-based signaling (offer side)
type FileOfferSignal struct {
peerConnection *PeerConnection
ctx context.Context
offerPath string
answerPath string
}
// CreateFileOfferSignal creates a new FileOfferSignal
func CreateFileOfferSignal(ctx context.Context, peerConnection *PeerConnection, offerPath string, answerPath string) *FileOfferSignal {
return &FileOfferSignal{
peerConnection: peerConnection,
ctx: ctx,
offerPath: offerPath,
answerPath: answerPath,
}
}
// Connect implements the BaseSignal interface
func (signal *FileOfferSignal) Connect(category, connectionLabel string) error {
// Use category and connectionLabel to create unique filenames if needed
if category != "" && connectionLabel != "" {
signal.offerPath = filepath.Join(signal.offerPath, category, connectionLabel, "offer.txt")
signal.answerPath = filepath.Join(signal.answerPath, category, connectionLabel, "answer.txt")
// Ensure directory exists
dir := filepath.Dir(signal.offerPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("error creating directory %s: %w", dir, err)
}
}
// Remove existing answer file if it exists
if _, err := os.Stat(signal.offerPath); err == nil {
if err := os.Remove(signal.offerPath); err != nil {
return fmt.Errorf("error removing existing offer file: %w", err)
}
}
// Remove existing answer file if it exists
if _, err := os.Stat(signal.answerPath); err == nil {
if err := os.Remove(signal.answerPath); err != nil {
return fmt.Errorf("error removing existing answer file: %w", err)
}
}
// Create offer
offer, err := signal.peerConnection.peerConnection.CreateOffer(nil)
if err != nil {
return fmt.Errorf("error creating offer: %w", err)
}
if err := signal.peerConnection.peerConnection.SetLocalDescription(offer); err != nil {
return fmt.Errorf("error setting local description: %w", err)
}
// Wait for ICE gathering to complete
timer := time.NewTicker(30 * time.Second)
defer timer.Stop()
select {
case <-webrtc.GatheringCompletePromise(signal.peerConnection.peerConnection):
fmt.Println("ICE Gathering complete")
case <-timer.C:
return errors.New("failed to gather ICE candidates within 30 seconds")
}
// Save offer to file
if err := signal.saveSDPToFile(signal.peerConnection.peerConnection.LocalDescription(), signal.offerPath); err != nil {
return fmt.Errorf("error saving offer to file: %w", err)
}
fmt.Printf("Offer saved to %s. Waiting for answer...\n", signal.offerPath)
// Wait for answer file
return signal.waitForAnswer()
}
// waitForAnswer waits for the answer file to appear and processes it
func (signal *FileOfferSignal) waitForAnswer() error {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
timeout := time.NewTimer(5 * time.Minute)
defer timeout.Stop()
for {
select {
case <-ticker.C:
// Check if answer file exists
if _, err := os.Stat(signal.answerPath); err == nil {
// Load answer from file
answer, err := signal.loadSDPFromFile(signal.answerPath)
if err != nil {
fmt.Printf("Error loading answer: %v, retrying...\n", err)
continue
}
// SetInputOption remote description
if err = signal.peerConnection.peerConnection.SetRemoteDescription(answer); err != nil {
return fmt.Errorf("error setting remote description: %w", err)
}
fmt.Println("Answer processed successfully")
return nil
}
case <-timeout.C:
return errors.New("timeout waiting for answer file")
case <-signal.ctx.Done():
return errors.New("context canceled")
}
}
}
// Close implements the BaseSignal interface
func (signal *FileOfferSignal) Close() error {
// Nothing to close for file-based signaling
return nil
}
// saveSDPToFile saves a SessionDescription to a file
func (signal *FileOfferSignal) saveSDPToFile(sdp *webrtc.SessionDescription, filename string) error {
// Ensure directory exists
dir := filepath.Dir(filename)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("error creating directory %s: %w", dir, err)
}
// Encode SDP
b, err := json.Marshal(sdp)
if err != nil {
return err
}
encoded := base64.StdEncoding.EncodeToString(b)
// Write to file
if err := os.WriteFile(filename, []byte(encoded), 0644); err != nil {
return err
}
fmt.Printf("SDP saved to %s (%d bytes)\n", filename, len(encoded))
return nil
}
// loadSDPFromFile loads a SessionDescription from a file
func (signal *FileOfferSignal) loadSDPFromFile(filename string) (webrtc.SessionDescription, error) {
var sdp webrtc.SessionDescription
data, err := os.ReadFile(filename)
if err != nil {
return sdp, fmt.Errorf("error reading %s: %w", filename, err)
}
encoded := string(data)
b, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return sdp, fmt.Errorf("base64 decode error: %w", err)
}
if err = json.Unmarshal(b, &sdp); err != nil {
return sdp, fmt.Errorf("JSON unmarshal error: %w", err)
}
fmt.Printf("SDP loaded from %s (%d bytes)\n", filename, len(data))
return sdp, nil
}

130
firebase_answer.go Normal file
View File

@@ -0,0 +1,130 @@
package client
import (
"context"
"errors"
"fmt"
"time"
"cloud.google.com/go/firestore"
"firebase.google.com/go"
"google.golang.org/api/option"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/pion/webrtc/v4"
)
type AnswerSignal struct {
peerConnection *PeerConnection
app *firebase.App
client *firestore.Client
docRef *firestore.DocumentRef
ctx context.Context
}
func CreateFirebaseAnswerSignal(ctx context.Context, peerConnection *PeerConnection) *AnswerSignal {
var (
configuration option.ClientOption
app *firebase.App
client *firestore.Client
err error
)
if configuration, err = GetFirebaseConfiguration(); err != nil {
panic(err)
}
if app, err = firebase.NewApp(ctx, nil, configuration); err != nil {
panic(err)
}
if client, err = app.Firestore(ctx); err != nil {
panic(err)
}
return &AnswerSignal{
app: app,
client: client,
peerConnection: peerConnection,
ctx: ctx,
}
}
func (signal *AnswerSignal) Connect(category, connectionLabel string) error {
signal.docRef = signal.client.Collection(category).Doc(connectionLabel)
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
var data map[string]interface{}
loop:
for {
select {
case <-ticker.C:
snapshot, err := signal.docRef.Get(signal.ctx)
if err != nil || snapshot == nil {
if status.Code(err) == codes.NotFound {
continue loop
}
}
data = snapshot.Data()
if currentStatus, exists := data[FieldStatus]; !exists || currentStatus != FieldStatusPending {
continue loop
}
break loop
}
}
fmt.Println("Found Offer. Creating answer...")
return signal.answer(data[FieldOffer].(map[string]interface{}))
}
func (signal *AnswerSignal) answer(offer map[string]interface{}) error {
sdp, ok := offer[FieldSDP].(string)
if !ok {
return fmt.Errorf("invalid SDP format in offer")
}
if err := signal.peerConnection.peerConnection.SetRemoteDescription(webrtc.SessionDescription{
Type: webrtc.SDPTypeOffer,
SDP: sdp,
}); err != nil {
return err
}
answer, err := signal.peerConnection.peerConnection.CreateAnswer(nil)
if err != nil {
return fmt.Errorf("error while creating answer: %w", err)
}
if err := signal.peerConnection.peerConnection.SetLocalDescription(answer); err != nil {
return fmt.Errorf("error while setting local sdp: %w", err)
}
timer := time.NewTicker(30 * time.Second)
defer timer.Stop()
select {
case <-webrtc.GatheringCompletePromise(signal.peerConnection.peerConnection):
fmt.Println("ICE Gathering complete")
case <-timer.C:
return errors.New("failed to gather ICE candidates within 30 seconds")
}
if _, err = signal.docRef.Set(signal.ctx, map[string]interface{}{
FieldAnswer: map[string]interface{}{
FieldSDP: signal.peerConnection.peerConnection.LocalDescription().SDP,
FieldUpdatedAt: firestore.ServerTimestamp,
},
FieldStatus: FieldStatusConnected,
}, firestore.MergeAll); err != nil {
return err
}
return nil
}
func (signal *AnswerSignal) Close() error {
return signal.client.Close()
}

49
firebase_config.go Normal file
View File

@@ -0,0 +1,49 @@
package client
import (
"encoding/json"
"os"
"strings"
"google.golang.org/api/option"
)
type firebaseConfig struct {
Type string `json:"type"`
ProjectID string `json:"project_id"`
PrivateKeyID string `json:"private_key_id"`
PrivateKey string `json:"private_key"`
ClientEmail string `json:"client_email"`
ClientID string `json:"client_id"`
AuthURI string `json:"auth_uri"`
TokenURI string `json:"token_uri"`
AuthProviderX509CertURL string `json:"auth_provider_x509_cert_url"`
ClientX509CertURL string `json:"client_x509_cert_url"`
UniverseDomain string `json:"universe_domain"`
}
func GetFirebaseConfiguration() (option.ClientOption, error) {
var (
config firebaseConfig
err error
configBytes []byte
)
config = firebaseConfig{
Type: os.Getenv("FIREBASE_TYPE"),
ProjectID: os.Getenv("FIREBASE_PROJECT_ID"),
PrivateKeyID: os.Getenv("FIREBASE_PRIVATE_KEY_ID"),
PrivateKey: strings.ReplaceAll(os.Getenv("FIREBASE_PRIVATE_KEY"), "\\n", "\n"),
ClientEmail: os.Getenv("FIREBASE_CLIENT_EMAIL"),
ClientID: os.Getenv("FIREBASE_CLIENT_ID"),
AuthURI: os.Getenv("FIREBASE_AUTH_URI"),
TokenURI: os.Getenv("FIREBASE_AUTH_TOKEN_URI"),
AuthProviderX509CertURL: os.Getenv("FIREBASE_AUTH_PROVIDER_X509_CERT_URL"),
ClientX509CertURL: os.Getenv("FIREBASE_AUTH_CLIENT_X509_CERT_URL"),
UniverseDomain: os.Getenv("FIREBASE_UNIVERSE_DOMAIN"),
}
if configBytes, err = json.Marshal(config); err != nil {
return nil, err
}
return option.WithCredentialsJSON(configBytes), nil
}

143
firebase_offer.go Normal file
View File

@@ -0,0 +1,143 @@
package client
import (
"context"
"errors"
"fmt"
"time"
"cloud.google.com/go/firestore"
"firebase.google.com/go"
"github.com/pion/webrtc/v4"
"google.golang.org/api/option"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type OfferSignal struct {
peerConnection *PeerConnection
app *firebase.App
firebaseClient *firestore.Client
docRef *firestore.DocumentRef
ctx context.Context
}
func CreateFirebaseOfferSignal(ctx context.Context, peerConnection *PeerConnection) *OfferSignal {
var (
configuration option.ClientOption
app *firebase.App
firebaseClient *firestore.Client
err error
)
if configuration, err = GetFirebaseConfiguration(); err != nil {
panic(err)
}
if app, err = firebase.NewApp(ctx, nil, configuration); err != nil {
panic(err)
}
if firebaseClient, err = app.Firestore(ctx); err != nil {
panic(err)
}
return &OfferSignal{
app: app,
firebaseClient: firebaseClient,
peerConnection: peerConnection,
ctx: ctx,
}
}
func (signal *OfferSignal) Connect(category, connectionLabel string) error {
signal.docRef = signal.firebaseClient.Collection(category).Doc(connectionLabel)
_, err := signal.docRef.Get(signal.ctx)
if err != nil && status.Code(err) != codes.NotFound {
fmt.Println(status.Code(err))
return err
}
if err == nil {
if _, err := signal.docRef.Delete(signal.ctx); err != nil {
return err
}
}
offer, err := signal.peerConnection.peerConnection.CreateOffer(nil)
if err != nil {
return fmt.Errorf("error while creating offer: %w", err)
}
if err := signal.peerConnection.peerConnection.SetLocalDescription(offer); err != nil {
return fmt.Errorf("error while setting local sdp: %w", err)
}
timer := time.NewTicker(30 * time.Second)
defer timer.Stop()
select {
case <-webrtc.GatheringCompletePromise(signal.peerConnection.peerConnection):
fmt.Println("ICE Gathering complete")
case <-timer.C:
return errors.New("failed to gather ICE candidates within 30 seconds")
}
if _, err = signal.docRef.Set(signal.ctx, map[string]interface{}{
FieldOffer: map[string]interface{}{
FieldCreatedAt: firestore.ServerTimestamp,
FieldSDP: signal.peerConnection.peerConnection.LocalDescription().SDP,
FieldUpdatedAt: firestore.ServerTimestamp,
},
FieldStatus: FieldStatusPending,
}, firestore.MergeAll); err != nil {
return fmt.Errorf("error while setting data to firestore: %w", err)
}
fmt.Println("Offer updated in firestore. Waiting for peer connection...")
return signal.offer()
}
func (signal *OfferSignal) offer() error {
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
loop:
for {
select {
case <-ticker.C:
snapshot, err := signal.docRef.Get(signal.ctx)
if err != nil {
if status.Code(err) == codes.NotFound {
continue loop
}
}
answer, exists := snapshot.Data()[FieldAnswer].(map[string]interface{})
if !exists {
continue loop
}
sdp, ok := answer[FieldSDP].(string)
if !ok {
continue loop
}
if err = signal.peerConnection.peerConnection.SetRemoteDescription(webrtc.SessionDescription{
Type: webrtc.SDPTypeAnswer,
SDP: sdp,
}); err != nil {
fmt.Printf("error while setting remote description: %s", err.Error())
continue loop
}
break loop
}
}
return nil
}
func (signal *OfferSignal) Close() error {
return signal.firebaseClient.Close()
}

76
go.mod Normal file
View File

@@ -0,0 +1,76 @@
module github.com/harshabose/simple_webrtc_comm/client
go 1.24.1
require (
cloud.google.com/go/firestore v1.18.0
firebase.google.com/go v3.13.0+incompatible
github.com/asticode/go-astiav v0.37.0
github.com/harshabose/mediapipe v0.0.0
github.com/harshabose/tools v0.0.0
github.com/pion/interceptor v0.1.40
github.com/pion/rtp v1.8.19
github.com/pion/sdp/v3 v3.0.13
github.com/pion/webrtc/v4 v4.1.2
google.golang.org/api v0.222.0
google.golang.org/grpc v1.70.0
)
require (
cloud.google.com/go v0.117.0 // indirect
cloud.google.com/go/auth v0.14.1 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
cloud.google.com/go/iam v1.2.2 // indirect
cloud.google.com/go/longrunning v0.6.2 // indirect
cloud.google.com/go/storage v1.43.0 // indirect
github.com/asticode/go-astikit v0.52.0 // indirect
github.com/bluenviron/gortsplib/v4 v4.14.1 // indirect
github.com/bluenviron/mediacommon/v2 v2.2.0 // indirect
github.com/coder/websocket v1.8.13 // indirect
github.com/emirpasic/gods/v2 v2.0.0-alpha // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/pion/datachannel v1.5.10 // indirect
github.com/pion/dtls/v3 v3.0.6 // indirect
github.com/pion/ice/v4 v4.0.10 // indirect
github.com/pion/logging v0.2.3 // indirect
github.com/pion/mdns/v2 v2.0.7 // indirect
github.com/pion/randutil v0.1.0 // indirect
github.com/pion/rtcp v1.2.15 // indirect
github.com/pion/sctp v1.8.39 // indirect
github.com/pion/srtp/v3 v3.0.5 // indirect
github.com/pion/stun/v3 v3.0.0 // indirect
github.com/pion/transport/v3 v3.0.7 // indirect
github.com/pion/turn/v4 v4.0.0 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
go.opentelemetry.io/otel v1.34.0 // indirect
go.opentelemetry.io/otel/metric v1.34.0 // indirect
go.opentelemetry.io/otel/trace v1.34.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/oauth2 v0.26.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b // indirect
google.golang.org/protobuf v1.36.5 // indirect
)
replace (
github.com/harshabose/mediapipe => ../mediapipe
github.com/harshabose/tools => ../tools
)

171
go.sum Normal file
View File

@@ -0,0 +1,171 @@
cloud.google.com/go v0.117.0 h1:Z5TNFfQxj7WG2FgOGX1ekC5RiXrYgms6QscOm32M/4s=
cloud.google.com/go v0.117.0/go.mod h1:ZbwhVTb1DBGt2Iwb3tNO6SEK4q+cplHZmLWH+DelYYc=
cloud.google.com/go/auth v0.14.1 h1:AwoJbzUdxA/whv1qj3TLKwh3XX5sikny2fc40wUl+h0=
cloud.google.com/go/auth v0.14.1/go.mod h1:4JHUxlGXisL0AW8kXPtUF6ztuOksyfUQNFjfsOCXkPM=
cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M=
cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
cloud.google.com/go/firestore v1.18.0 h1:cuydCaLS7Vl2SatAeivXyhbhDEIR8BDmtn4egDhIn2s=
cloud.google.com/go/firestore v1.18.0/go.mod h1:5ye0v48PhseZBdcl0qbl3uttu7FIEwEYVaWm0UIEOEU=
cloud.google.com/go/iam v1.2.2 h1:ozUSofHUGf/F4tCNy/mu9tHLTaxZFLOUiKzjcgWHGIA=
cloud.google.com/go/iam v1.2.2/go.mod h1:0Ys8ccaZHdI1dEUilwzqng/6ps2YB6vRsjIe00/+6JY=
cloud.google.com/go/longrunning v0.6.2 h1:xjDfh1pQcWPEvnfjZmwjKQEcHnpz6lHjfy7Fo0MK+hc=
cloud.google.com/go/longrunning v0.6.2/go.mod h1:k/vIs83RN4bE3YCswdXC5PFfWVILjm3hpEUlSko4PiI=
cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs=
cloud.google.com/go/storage v1.43.0/go.mod h1:ajvxEa7WmZS1PxvKRq4bq0tFT3vMd502JwstCcYv0Q0=
firebase.google.com/go v3.13.0+incompatible h1:3TdYC3DDi6aHn20qoRkxwGqNgdjtblwVAyRLQwGn/+4=
firebase.google.com/go v3.13.0+incompatible/go.mod h1:xlah6XbEyW6tbfSklcfe5FHJIwjt8toICdV5Wh9ptHs=
github.com/asticode/go-astiav v0.37.0 h1:Ph4usW4lulotVvne8hqZ1JCOHX1f8ces6yVKdg+PnyQ=
github.com/asticode/go-astiav v0.37.0/go.mod h1:GI0pHw6K2/pl/o8upCtT49P/q4KCwhv/8nGLlCsZLdA=
github.com/asticode/go-astikit v0.52.0 h1:kTl2XjgiVQhUl1H7kim7NhmTtCMwVBbPrXKqhQhbk8Y=
github.com/asticode/go-astikit v0.52.0/go.mod h1:fV43j20UZYfXzP9oBn33udkvCvDvCDhzjVqoLFuuYZE=
github.com/bluenviron/gortsplib/v4 v4.14.1 h1:v99NmXeeJFfbrO+ipPzPxYGibQaR5ZOUESOA9UQZhsI=
github.com/bluenviron/gortsplib/v4 v4.14.1/go.mod h1:3LaEcg0d47+kfXju5KSlsSxCiZ3IKBI/sqIrBPcsS64=
github.com/bluenviron/mediacommon/v2 v2.2.0 h1:fGXEX0OEvv5VhGHOv3Q2ABzOtSkIpl9UbwOHrnKWNTk=
github.com/bluenviron/mediacommon/v2 v2.2.0/go.mod h1:a6MbPmXtYda9mKibKVMZlW20GYLLrX2R7ZkUE+1pwV0=
github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE=
github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/emirpasic/gods/v2 v2.0.0-alpha h1:dwFlh8pBg1VMOXWGipNMRt8v96dKAIvBehtCt6OtunU=
github.com/emirpasic/gods/v2 v2.0.0-alpha/go.mod h1:W0y4M2dtBB9U5z3YlghmpuUhiaZT2h6yoeE+C1sCp6A=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/martian/v3 v3.3.3 h1:DIhPTQrbPkgs2yJYdXU/eNACCG5DVQjySNRNlflZ9Fc=
github.com/google/martian/v3 v3.3.3/go.mod h1:iEPrYcgCF7jA9OtScMFQyAlZZ4YXTKEtJ1E6RWzmBA0=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o=
github.com/pion/datachannel v1.5.10/go.mod h1:p/jJfC9arb29W7WrxyKbepTU20CFgyx5oLo8Rs4Py/M=
github.com/pion/dtls/v3 v3.0.6 h1:7Hkd8WhAJNbRgq9RgdNh1aaWlZlGpYTzdqjy9x9sK2E=
github.com/pion/dtls/v3 v3.0.6/go.mod h1:iJxNQ3Uhn1NZWOMWlLxEEHAN5yX7GyPvvKw04v9bzYU=
github.com/pion/ice/v4 v4.0.10 h1:P59w1iauC/wPk9PdY8Vjl4fOFL5B+USq1+xbDcN6gT4=
github.com/pion/ice/v4 v4.0.10/go.mod h1:y3M18aPhIxLlcO/4dn9X8LzLLSma84cx6emMSu14FGw=
github.com/pion/interceptor v0.1.40 h1:e0BjnPcGpr2CFQgKhrQisBU7V3GXK6wrfYrGYaU6Jq4=
github.com/pion/interceptor v0.1.40/go.mod h1:Z6kqH7M/FYirg3frjGJ21VLSRJGBXB/KqaTIrdqnOic=
github.com/pion/logging v0.2.3 h1:gHuf0zpoh1GW67Nr6Gj4cv5Z9ZscU7g/EaoC/Ke/igI=
github.com/pion/logging v0.2.3/go.mod h1:z8YfknkquMe1csOrxK5kc+5/ZPAzMxbKLX5aXpbpC90=
github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM=
github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.15 h1:LZQi2JbdipLOj4eBjK4wlVoQWfrZbh3Q6eHtWtJBZBo=
github.com/pion/rtcp v1.2.15/go.mod h1:jlGuAjHMEXwMUHK78RgX0UmEJFV4zUKOFHR7OP+D3D0=
github.com/pion/rtp v1.8.19 h1:jhdO/3XhL/aKm/wARFVmvTfq0lC/CvN1xwYKmduly3c=
github.com/pion/rtp v1.8.19/go.mod h1:bAu2UFKScgzyFqvUKmbvzSdPr+NGbZtv6UB2hesqXBk=
github.com/pion/sctp v1.8.39 h1:PJma40vRHa3UTO3C4MyeJDQ+KIobVYRZQZ0Nt7SjQnE=
github.com/pion/sctp v1.8.39/go.mod h1:cNiLdchXra8fHQwmIoqw0MbLLMs+f7uQ+dGMG2gWebE=
github.com/pion/sdp/v3 v3.0.13 h1:uN3SS2b+QDZnWXgdr69SM8KB4EbcnPnPf2Laxhty/l4=
github.com/pion/sdp/v3 v3.0.13/go.mod h1:88GMahN5xnScv1hIMTqLdu/cOcUkj6a9ytbncwMCq2E=
github.com/pion/srtp/v3 v3.0.5 h1:8XLB6Dt3QXkMkRFpoqC3314BemkpMQK2mZeJc4pUKqo=
github.com/pion/srtp/v3 v3.0.5/go.mod h1:r1G7y5r1scZRLe2QJI/is+/O83W2d+JoEsuIexpw+uM=
github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw=
github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU=
github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0=
github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo=
github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM=
github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA=
github.com/pion/webrtc/v4 v4.1.2 h1:mpuUo/EJ1zMNKGE79fAdYNFZBX790KE7kQQpLMjjR54=
github.com/pion/webrtc/v4 v4.1.2/go.mod h1:xsCXiNAmMEjIdFxAYU0MbB3RwRieJsegSB2JZsGN+8U=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU=
github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0 h1:PS8wXpbyaDJQ2VDHHncMe9Vct0Zn1fEjpsjrLxGJoSc=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0/go.mod h1:HDBUsEjOuRC0EzKZ1bSaRGZWUBAzo+MhAcUUORSr4D0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU=
go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE=
golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.222.0 h1:Aiewy7BKLCuq6cUCeOUrsAlzjXPqBkEeQ/iwGHVQa/4=
google.golang.org/api v0.222.0/go.mod h1:efZia3nXpWELrwMlN5vyQrD4GmJN1Vw0x68Et3r+a9c=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk=
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697/go.mod h1:JJrvXBWRZaFMxBufik1a4RpFw4HhgVtBBWQeQgUj2cc=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b h1:FQtJ1MxbXoIIrZHZ33M+w5+dAP9o86rgpjoKr/ZmT7k=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250212204824-5a70512c5d8b/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk=
google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

158
peerconnection.go Normal file
View File

@@ -0,0 +1,158 @@
package client
import (
"context"
"errors"
"fmt"
"github.com/pion/webrtc/v4"
"github.com/harshabose/simple_webrtc_comm/client/pkg/datachannel"
"github.com/harshabose/simple_webrtc_comm/client/pkg/mediasink"
"github.com/harshabose/simple_webrtc_comm/client/pkg/mediasource"
)
type PeerConnection struct {
label string
peerConnection *webrtc.PeerConnection
dataChannels *datachannel.DataChannels
tracks *mediasource.Tracks
sinks *mediasink.Sinks
signal BaseSignal
bwController *BWEController
ctx context.Context
cancel context.CancelFunc
}
func CreatePeerConnection(ctx context.Context, cancel context.CancelFunc, label string, api *webrtc.API, config webrtc.Configuration, options ...PeerConnectionOption) (*PeerConnection, error) {
pc := &PeerConnection{
label: label,
ctx: ctx,
cancel: cancel,
}
peerConnection, err := api.NewPeerConnection(config)
if err != nil {
return nil, err
}
pc.peerConnection = peerConnection
for _, option := range options {
if err := option(pc); err != nil {
return nil, err
}
}
if pc.signal == nil {
return nil, errors.New("signaling protocol not provided")
}
return pc.onConnectionStateChangeEvent().onICEConnectionStateChange().onICEGatheringStateChange().onICECandidate(), err
}
func (pc *PeerConnection) GetLabel() string {
return pc.label
}
func (pc *PeerConnection) GetPeerConnection() (*webrtc.PeerConnection, error) {
if pc.peerConnection == nil {
return nil, errors.New("raw peer connection is nil")
}
return pc.peerConnection, nil
}
func (pc *PeerConnection) onConnectionStateChangeEvent() *PeerConnection {
pc.peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("peer connection state with label changed to %s\n", state.String())
if state == webrtc.PeerConnectionStateDisconnected || state == webrtc.PeerConnectionStateClosed || state == webrtc.PeerConnectionStateFailed {
fmt.Println("tying to cancel context for restart")
pc.cancel()
}
})
return pc
}
func (pc *PeerConnection) onICEConnectionStateChange() *PeerConnection {
pc.peerConnection.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
fmt.Printf("ICE Connection State changed: %s\n", state.String())
})
return pc
}
func (pc *PeerConnection) onICEGatheringStateChange() *PeerConnection {
pc.peerConnection.OnICEGatheringStateChange(func(state webrtc.ICEGatheringState) {
fmt.Printf("ICE Gathering State changed: %s\n", state.String())
})
return pc
}
func (pc *PeerConnection) onICECandidate() *PeerConnection {
pc.peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate == nil {
fmt.Println("ICE gathering complete")
return
}
fmt.Printf("Found candidate: %s (type: %s)\n", candidate.String(), candidate.Typ)
})
return pc
}
func (pc *PeerConnection) CreateDataChannel(label string, options ...datachannel.Option) (*datachannel.DataChannel, error) {
if pc.dataChannels == nil {
return nil, errors.New("data channels are not enabled")
}
channel, err := pc.dataChannels.CreateDataChannel(label, pc.peerConnection, options...)
if err != nil {
return nil, err
}
return channel, nil
}
func (pc *PeerConnection) CreateMediaSource(label string, options ...mediasource.TrackOption) (*mediasource.Track, error) {
if pc.tracks == nil {
return nil, errors.New("media source are not enabled")
}
track, err := pc.tracks.CreateTrack(label, pc.peerConnection, options...)
if err != nil {
return nil, err
}
return track, nil
}
func (pc *PeerConnection) CreateMediaSink(label string, options ...mediasink.SinkOption) (*mediasink.Sink, error) {
if pc.sinks == nil {
return nil, errors.New("media sinks are not enabled")
}
sink, err := pc.sinks.CreateSink(label, options...)
if err != nil {
return nil, err
}
return sink, nil
}
func (pc *PeerConnection) Connect(category string) error {
if pc.signal == nil {
return errors.New("no signaling protocol provided")
}
if err := pc.signal.Connect(category, pc.label); err != nil {
return err
}
return nil
}
func (pc *PeerConnection) Close() error {
// TODO:
// clear data channels if any
// clear tracks if any
// clear sinks if any
// clear bwController ??
return pc.peerConnection.Close()
}

6
peerconnection_!cgo.go Normal file
View File

@@ -0,0 +1,6 @@
//go:build !cgo_enabled
package client
// BWEController dummy when BWE is disabled
type BWEController struct{}

13
peerconnection_cgo.go Normal file
View File

@@ -0,0 +1,13 @@
//go:build cgo_enabled
package client
import "errors"
func (pc *PeerConnection) GetBWEstimator() (*BWEController, error) {
if pc.bwController == nil || pc.bwController.estimator == nil {
return nil, errors.New("bitrate control is not enabled")
}
return pc.bwController, nil
}

71
peerconnection_options.go Normal file
View File

@@ -0,0 +1,71 @@
package client
import (
"errors"
"github.com/harshabose/simple_webrtc_comm/client/pkg/datachannel"
"github.com/harshabose/simple_webrtc_comm/client/pkg/mediasink"
"github.com/harshabose/simple_webrtc_comm/client/pkg/mediasource"
)
type PeerConnectionOption = func(*PeerConnection) error
func WithFirebaseOfferSignal(connection *PeerConnection) error {
if connection.signal != nil {
return errors.New("multiple options for signaling were provided. this is not supported")
}
connection.signal = CreateFirebaseOfferSignal(connection.ctx, connection)
return nil
}
func WithFirebaseAnswerSignal(connection *PeerConnection) error {
if connection.signal != nil {
return errors.New("multiple options for signaling were provided. this is not supported")
}
connection.signal = CreateFirebaseAnswerSignal(connection.ctx, connection)
return nil
}
func WithFileOfferSignal(offerPath, answerPath string) PeerConnectionOption {
return func(connection *PeerConnection) error {
if connection.signal != nil {
return errors.New("multiple options for signaling were provided. this is not supported")
}
connection.signal = CreateFileOfferSignal(connection.ctx, connection, offerPath, answerPath)
return nil
}
}
func WithFileAnswerSignal(offerPath, answerPath string) PeerConnectionOption {
return func(connection *PeerConnection) error {
if connection.signal != nil {
return errors.New("multiple options for signaling were provided. this is not supported")
}
connection.signal = CreateFileAnswerSignal(connection.ctx, connection, offerPath, answerPath)
return nil
}
}
func WithMediaSources() PeerConnectionOption {
return func(pc *PeerConnection) error {
pc.tracks = mediasource.CreateTracks(pc.ctx)
return nil
}
}
func WithMediaSinks() PeerConnectionOption {
return func(pc *PeerConnection) error {
pc.sinks = mediasink.CreateSinks(pc.ctx, pc.peerConnection)
return nil
}
}
func WithDataChannels() PeerConnectionOption {
return func(pc *PeerConnection) error {
pc.dataChannels = datachannel.CreateDataChannels(pc.ctx)
return nil
}
}

View File

@@ -0,0 +1,10 @@
//go:build cgo_enabled
package client
func WithBandwidthControl() PeerConnectionOption {
return func(connection *PeerConnection) error {
connection.bwController = createBWController(connection.ctx)
return nil
}
}

View File

@@ -0,0 +1,139 @@
package datachannel
import (
"context"
"errors"
"fmt"
"github.com/pion/webrtc/v4"
)
type DataChannel struct {
label string
datachannel *webrtc.DataChannel
init *webrtc.DataChannelInit
ctx context.Context
}
func CreateDataChannel(ctx context.Context, label string, peerConnection *webrtc.PeerConnection, options ...Option) (*DataChannel, error) {
dc := &DataChannel{
label: label,
datachannel: nil,
ctx: ctx,
}
for _, option := range options {
if err := option(dc); err != nil {
return nil, err
}
}
datachannel, err := peerConnection.CreateDataChannel(label, dc.init)
if err != nil {
return nil, err
}
dc.datachannel = datachannel
return dc.onOpen().onClose(), nil
}
func CreateRawDataChannel(ctx context.Context, channel *webrtc.DataChannel) (*DataChannel, error) {
dataChannel := &DataChannel{
label: channel.Label(),
datachannel: channel,
ctx: ctx,
}
return dataChannel.onOpen().onClose(), nil
}
func (dataChannel *DataChannel) GetLabel() string {
return dataChannel.label
}
func (dataChannel *DataChannel) Close() error {
if err := dataChannel.datachannel.Close(); err != nil {
return err
}
return nil
}
func (dataChannel *DataChannel) onOpen() *DataChannel {
dataChannel.datachannel.OnOpen(func() {
fmt.Printf("dataChannel Open with Label: %s\n", dataChannel.datachannel.Label())
})
return dataChannel
}
func (dataChannel *DataChannel) onClose() *DataChannel {
dataChannel.datachannel.OnClose(func() {
fmt.Printf("dataChannel Closed with Label: %s\n", dataChannel.datachannel.Label())
})
return dataChannel
}
func (dataChannel *DataChannel) DataChannel() *webrtc.DataChannel {
return dataChannel.datachannel
}
// +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
type DataChannels struct {
datachannel map[string]*DataChannel
ctx context.Context
}
func CreateDataChannels(ctx context.Context) *DataChannels {
return &DataChannels{
datachannel: map[string]*DataChannel{},
ctx: ctx,
}
}
func (dataChannels *DataChannels) CreateDataChannel(label string, peerConnection *webrtc.PeerConnection, options ...Option) (*DataChannel, error) {
if _, exits := dataChannels.datachannel[label]; exits {
return nil, fmt.Errorf("datachannel with id = '%s' already exists", label)
}
channel, err := CreateDataChannel(dataChannels.ctx, label, peerConnection, options...)
if err != nil {
return nil, err
}
dataChannels.datachannel[label] = channel
return channel, nil
}
func (dataChannels *DataChannels) CreateRawDataChannel(channel *webrtc.DataChannel) (*DataChannel, error) {
_, exists := dataChannels.datachannel[channel.Label()]
if exists {
return nil, fmt.Errorf("data channel already exists with label: %s", channel.Label())
}
dataChannel, err := CreateRawDataChannel(dataChannels.ctx, channel)
if err != nil {
return nil, err
}
dataChannels.datachannel[channel.Label()] = dataChannel
return dataChannel, nil
}
func (dataChannels *DataChannels) GetDataChannel(label string) (*DataChannel, error) {
dataChannel, exists := dataChannels.datachannel[label]
if !exists {
return nil, errors.New("datachannel does not exists")
}
return dataChannel, nil
}
func (dataChannels *DataChannels) Close(label string) (err error) {
if err = dataChannels.datachannel[label].Close(); err == nil {
return nil
}
delete(dataChannels.datachannel, label)
return err
}

View File

@@ -0,0 +1,12 @@
package datachannel
import "github.com/pion/webrtc/v4"
type Option = func(*DataChannel) error
func WithDataChannelInit(init *webrtc.DataChannelInit) Option {
return func(channel *DataChannel) error {
channel.init = init
return nil
}
}

56
pkg/mediasink/options.go Normal file
View File

@@ -0,0 +1,56 @@
package mediasink
import (
"errors"
"github.com/pion/webrtc/v4"
)
type SinkOption = func(*Sink) error
// TODO: CLOCKRATE, STEREO, PROFILE etc ARE IN MEDIA SOURCE. MAYBE BE INCLUDE THEM HERE?
func WithH264Track(clockrate uint32) SinkOption {
return func(track *Sink) error {
if track.codecCapability != nil {
return errors.New("multiple tracks are not supported on single media source")
}
track.codecCapability = &webrtc.RTPCodecParameters{}
track.codecCapability.PayloadType = webrtc.PayloadType(102)
track.codecCapability.MimeType = webrtc.MimeTypeH264
track.codecCapability.ClockRate = clockrate
track.codecCapability.Channels = 0
return nil
}
}
func WithVP8Track(clockrate uint32) SinkOption {
return func(track *Sink) error {
if track.codecCapability != nil {
return errors.New("multiple tracks are not supported on single media source")
}
track.codecCapability = &webrtc.RTPCodecParameters{}
track.codecCapability.PayloadType = webrtc.PayloadType(96)
track.codecCapability.MimeType = webrtc.MimeTypeVP8
track.codecCapability.ClockRate = clockrate
track.codecCapability.Channels = 0
return nil
}
}
func WithOpusTrack(samplerate uint32, channelLayout uint16) SinkOption {
return func(track *Sink) error {
if track.codecCapability != nil {
return errors.New("multiple tracks are not supported on single media source")
}
track.codecCapability = &webrtc.RTPCodecParameters{}
track.codecCapability.PayloadType = webrtc.PayloadType(111)
track.codecCapability.MimeType = webrtc.MimeTypeOpus
track.codecCapability.ClockRate = samplerate
track.codecCapability.Channels = channelLayout
return nil
}
}

187
pkg/mediasink/sinks.go Normal file
View File

@@ -0,0 +1,187 @@
package mediasink
import (
"context"
"errors"
"fmt"
"reflect"
"sync"
"time"
"github.com/pion/interceptor"
"github.com/pion/rtp"
"github.com/pion/webrtc/v4"
"github.com/harshabose/mediapipe/pkg/generators"
)
type Sink struct {
generator generators.CanGeneratePionRTPPacket
codecCapability *webrtc.RTPCodecParameters
rtpReceiver *webrtc.RTPReceiver
mux sync.RWMutex
ctx context.Context
}
func CreateSink(ctx context.Context, options ...SinkOption) (*Sink, error) {
sink := &Sink{ctx: ctx}
for _, option := range options {
if err := option(sink); err != nil {
return nil, err
}
}
if sink.codecCapability == nil {
return nil, errors.New("no sink capabilities given")
}
return sink, nil
}
func (s *Sink) setGenerator(generator generators.CanGeneratePionRTPPacket) {
s.mux.Lock()
defer s.mux.Unlock()
s.generator = generator
}
func (s *Sink) setRTPReceiver(receiver *webrtc.RTPReceiver) {
s.mux.Lock()
defer s.mux.Unlock()
s.rtpReceiver = receiver
}
func (s *Sink) readRTPReceiver(rtcpBuf []byte) {
s.mux.RLock()
defer s.mux.RUnlock()
if s.rtpReceiver == nil {
time.Sleep(10 * time.Millisecond)
return
}
if _, _, err := s.rtpReceiver.Read(rtcpBuf); err != nil {
fmt.Printf("error while reading rtcp packets")
}
}
func (s *Sink) rtpReceiverLoop() {
// THIS IS NEEDED AS interceptors (pion) do not work
for {
rtcpBuf := make([]byte, 1500)
s.readRTPReceiver(rtcpBuf)
}
}
func (s *Sink) ReadRTP() (*rtp.Packet, interceptor.Attributes, error) {
s.mux.RLock()
defer s.mux.RUnlock()
if s.generator == nil {
return nil, interceptor.Attributes{}, nil
}
return s.generator.ReadRTP()
}
type Sinks struct {
sinks map[string]*Sink
mux sync.RWMutex
ctx context.Context
}
func CreateSinks(ctx context.Context, pc *webrtc.PeerConnection) *Sinks {
s := &Sinks{
sinks: make(map[string]*Sink),
ctx: ctx,
}
s.onTrack(pc)
return s
}
func (s *Sinks) onTrack(pc *webrtc.PeerConnection) {
pc.OnTrack(func(remote *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) {
sink, err := s.GetSink(remote.ID())
if err != nil {
fmt.Println(err.Error())
// TODO: MAYBE SET A DEFAULT SINK?
return
}
if !CompareRTPCodecParameters(remote.Codec(), *(sink.codecCapability)) {
fmt.Println("sink registered codec did not match. skipping...")
return
}
sink.setRTPReceiver(receiver)
sink.setGenerator(remote)
go sink.rtpReceiverLoop()
})
}
func (s *Sinks) CreateSink(label string, options ...SinkOption) (*Sink, error) {
s.mux.Lock()
defer s.mux.Unlock()
if _, exists := s.sinks[label]; exists {
return nil, fmt.Errorf("sink with id='%s' already exists", label)
}
sink, err := CreateSink(s.ctx, options...)
if err != nil {
return nil, err
}
s.sinks[label] = sink
return sink, nil
}
func (s *Sinks) GetSink(label string) (*Sink, error) {
s.mux.RLock()
defer s.mux.RUnlock()
sink, exists := s.sinks[label]
if !exists {
return nil, fmt.Errorf("ERROR: no sink set for track with id %s; ignoring track...\n", label)
}
return sink, nil
}
func CompareRTPCodecParameters(a, b webrtc.RTPCodecParameters) bool {
identical := true
if a.PayloadType != b.PayloadType {
fmt.Printf("PayloadType differs: %v != %v\n", a.PayloadType, b.PayloadType)
identical = false
}
if a.MimeType != b.MimeType {
fmt.Printf("MimeType differs: %s != %s\n", a.MimeType, b.MimeType)
identical = false
}
if a.ClockRate != b.ClockRate {
fmt.Printf("ClockRate differs: %d != %d\n", a.ClockRate, b.ClockRate)
identical = false
}
if a.Channels != b.Channels {
fmt.Printf("Channels differs: %d != %d\n", a.Channels, b.Channels)
identical = false
}
if a.SDPFmtpLine != b.SDPFmtpLine {
fmt.Printf("SDPFmtpLine differs (ignored): %s != %s\n", a.SDPFmtpLine, b.SDPFmtpLine)
}
if !reflect.DeepEqual(a.RTCPFeedback, b.RTCPFeedback) {
fmt.Printf("RTCPFeedback differs (ignored): %v != %v\n", a.RTCPFeedback, b.RTCPFeedback)
}
return identical
}

View File

@@ -0,0 +1,46 @@
package mediasource
type PacketisationMode uint8
const (
PacketisationMode0 PacketisationMode = 0
PacketisationMode1 PacketisationMode = 1
PacketisationMode2 PacketisationMode = 2
)
type ProfileLevel string
const (
ProfileLevelBaseline21 ProfileLevel = "420015" // Level 2.1 (480p)
ProfileLevelBaseline31 ProfileLevel = "42001f" // Level 3.1 (720p)
ProfileLevelBaseline41 ProfileLevel = "420029" // Level 4.1 (1080p)
ProfileLevelBaseline42 ProfileLevel = "42002a" // Level 4.2 (2K)
ProfileLevelMain21 ProfileLevel = "4D0015" // Level 2.1
ProfileLevelMain31 ProfileLevel = "4D001f" // Level 3.1
ProfileLevelMain41 ProfileLevel = "4D0029" // Level 4.1
ProfileLevelMain42 ProfileLevel = "4D002a" // Level 4.2
ProfileLevelHigh21 ProfileLevel = "640015" // Level 2.1
ProfileLevelHigh31 ProfileLevel = "64001f" // Level 3.1
ProfileLevelHigh41 ProfileLevel = "640029" // Level 4.1
ProfileLevelHigh42 ProfileLevel = "64002a" // Level 4.2
)
type StereoType uint8
const (
StereoMono = StereoType(0)
StereoDual = StereoType(1)
)
type Priority uint8
const (
Level0 Priority = 0
Level1 Priority = 1
Level2 Priority = 2
Level3 Priority = 3
Level4 Priority = 4
Level5 Priority = 5
)

View File

@@ -0,0 +1,58 @@
package mediasource
import (
"errors"
"github.com/pion/webrtc/v4"
)
type TrackOption = func(*Track) error
func WithH264Track(clockrate uint32, packetisationMode PacketisationMode, profileLevel ProfileLevel) TrackOption {
return func(track *Track) error {
if track.codecCapability != nil {
return errors.New("multiple tracks are not supported on single media source")
}
track.codecCapability = &webrtc.RTPCodecCapability{}
track.codecCapability.MimeType = webrtc.MimeTypeH264
track.codecCapability.ClockRate = clockrate
track.codecCapability.Channels = 0
return nil
}
}
func WithVP8Track(clockrate uint32) TrackOption {
return func(track *Track) error {
if track.codecCapability != nil {
return errors.New("multiple tracks are not supported on single media source")
}
track.codecCapability = &webrtc.RTPCodecCapability{}
track.codecCapability.MimeType = webrtc.MimeTypeVP8
track.codecCapability.ClockRate = clockrate
track.codecCapability.Channels = 0
return nil
}
}
func WithOpusTrack(samplerate uint32, channelLayout uint16, stereo StereoType) TrackOption {
return func(track *Track) error {
if track.codecCapability != nil {
return errors.New("multiple tracks are not supported on single media source")
}
track.codecCapability = &webrtc.RTPCodecCapability{}
track.codecCapability.MimeType = webrtc.MimeTypeOpus
track.codecCapability.ClockRate = samplerate
track.codecCapability.Channels = channelLayout
return nil
}
}
func WithPriority(level Priority) TrackOption {
return func(track *Track) error {
track.priority = level
return nil
}
}

73
pkg/mediasource/track.go Normal file
View File

@@ -0,0 +1,73 @@
package mediasource
import (
"context"
"errors"
"fmt"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
_ "github.com/harshabose/mediapipe"
"github.com/harshabose/mediapipe/pkg/consumers"
)
// NO BUFFER IMPLEMENTATION
type Track struct {
consumer consumers.CanConsumePionSamplePacket
codecCapability *webrtc.RTPCodecCapability
rtpSender *webrtc.RTPSender
priority Priority
ctx context.Context
}
func CreateTrack(ctx context.Context, label string, peerConnection *webrtc.PeerConnection, options ...TrackOption) (*Track, error) {
track := &Track{ctx: ctx}
for _, option := range options {
if err := option(track); err != nil {
return nil, err
}
}
if track.codecCapability == nil {
return nil, errors.New("no track capabilities given")
}
consumer, err := webrtc.NewTrackLocalStaticSample(*track.codecCapability, label, "webrtc")
if err != nil {
return nil, err
}
track.consumer = consumer
if track.rtpSender, err = peerConnection.AddTrack(consumer); err != nil {
return nil, err
}
go track.rtpSenderLoop()
return track, nil
}
func (track *Track) GetPriority() Priority {
return track.priority
}
func (track *Track) rtpSenderLoop() {
// THIS IS NEEDED AS interceptors (pion) doesnt work
for {
rtcpBuf := make([]byte, 1500)
if _, _, err := track.rtpSender.Read(rtcpBuf); err != nil {
fmt.Printf("error while reading rtcp packets")
}
}
}
func (track *Track) WriteSample(sample media.Sample) error {
if err := track.consumer.WriteSample(sample); err != nil {
fmt.Printf("error while writing samples to track (id: ); err; %v. Continuing...", err)
}
return nil
}

44
pkg/mediasource/tracks.go Normal file
View File

@@ -0,0 +1,44 @@
package mediasource
import (
"context"
"errors"
"fmt"
"github.com/pion/webrtc/v4"
)
type Tracks struct {
tracks map[string]*Track
ctx context.Context
}
func CreateTracks(ctx context.Context) *Tracks {
return &Tracks{
tracks: make(map[string]*Track),
ctx: ctx,
}
}
func (tracks *Tracks) CreateTrack(label string, peerConnection *webrtc.PeerConnection, options ...TrackOption) (*Track, error) {
if _, exists := tracks.tracks[label]; exists {
return nil, fmt.Errorf("track with id = '%s' already exists", label)
}
track, err := CreateTrack(tracks.ctx, label, peerConnection, options...)
if err != nil {
return nil, err
}
tracks.tracks[label] = track
return track, nil
}
func (tracks *Tracks) GetTrack(id string) (*Track, error) {
track, exists := tracks.tracks[id]
if !exists {
return nil, errors.New("track does not exits")
}
return track, nil
}

229
pkg/transcode/decoder.go Normal file
View File

@@ -0,0 +1,229 @@
//go:build cgo_enabled
package transcode
import (
"context"
"errors"
"time"
"github.com/asticode/go-astiav"
"github.com/harshabose/tools/pkg/buffer"
)
type GeneralDecoder struct {
demuxer CanProduceMediaPacket
decoderContext *astiav.CodecContext
codec *astiav.Codec
buffer buffer.BufferWithGenerator[*astiav.Frame]
ctx context.Context
cancel context.CancelFunc
}
func CreateGeneralDecoder(ctx context.Context, canProduceMediaType CanProduceMediaPacket, options ...DecoderOption) (*GeneralDecoder, error) {
var (
err error
contextOption DecoderOption
decoder *GeneralDecoder
)
ctx2, cancel := context.WithCancel(ctx)
decoder = &GeneralDecoder{
demuxer: canProduceMediaType,
ctx: ctx2,
cancel: cancel,
}
canDescribeMediaPacket, ok := canProduceMediaType.(CanDescribeMediaPacket)
if !ok {
return nil, ErrorInterfaceMismatch
}
if canDescribeMediaPacket.MediaType() == astiav.MediaTypeVideo {
contextOption = withVideoSetDecoderContext(canDescribeMediaPacket)
}
if canDescribeMediaPacket.MediaType() == astiav.MediaTypeAudio {
contextOption = withAudioSetDecoderContext(canDescribeMediaPacket)
}
options = append([]DecoderOption{contextOption}, options...)
for _, option := range options {
if err = option(decoder); err != nil {
return nil, err
}
}
if decoder.buffer == nil {
decoder.buffer = buffer.CreateChannelBuffer(ctx, 256, buffer.CreateFramePool())
}
if err = decoder.decoderContext.Open(decoder.codec, nil); err != nil {
return nil, err
}
return decoder, nil
}
func (decoder *GeneralDecoder) Ctx() context.Context {
return decoder.ctx
}
func (decoder *GeneralDecoder) Start() {
go decoder.loop()
}
func (decoder *GeneralDecoder) Stop() {
decoder.cancel()
}
func (decoder *GeneralDecoder) loop() {
defer decoder.close()
loop1:
for {
select {
case <-decoder.ctx.Done():
return
default:
packet, err := decoder.getPacket()
if err != nil {
// fmt.Println("unable to get packet from demuxer; err:", err.Error())
continue
}
if err := decoder.decoderContext.SendPacket(packet); err != nil {
decoder.demuxer.PutBack(packet)
if !errors.Is(err, astiav.ErrEagain) {
continue loop1
}
}
loop2:
for {
frame := decoder.buffer.Generate()
if err := decoder.decoderContext.ReceiveFrame(frame); err != nil {
decoder.buffer.PutBack(frame)
break loop2
}
frame.SetPictureType(astiav.PictureTypeNone)
if err := decoder.pushFrame(frame); err != nil {
decoder.buffer.PutBack(frame)
continue loop2
}
}
decoder.demuxer.PutBack(packet)
}
}
}
func (decoder *GeneralDecoder) pushFrame(frame *astiav.Frame) error {
ctx, cancel := context.WithTimeout(decoder.ctx, 50*time.Millisecond)
defer cancel()
return decoder.buffer.Push(ctx, frame)
}
func (decoder *GeneralDecoder) getPacket() (*astiav.Packet, error) {
ctx, cancel := context.WithTimeout(decoder.ctx, 50*time.Millisecond)
defer cancel()
return decoder.demuxer.GetPacket(ctx)
}
func (decoder *GeneralDecoder) GetFrame(ctx context.Context) (*astiav.Frame, error) {
return decoder.buffer.Pop(ctx)
}
func (decoder *GeneralDecoder) PutBack(frame *astiav.Frame) {
decoder.buffer.PutBack(frame)
}
func (decoder *GeneralDecoder) close() {
if decoder.decoderContext != nil {
decoder.decoderContext.Free()
}
}
func (decoder *GeneralDecoder) SetBuffer(buffer buffer.BufferWithGenerator[*astiav.Frame]) {
decoder.buffer = buffer
}
func (decoder *GeneralDecoder) SetCodec(producer CanDescribeMediaPacket) error {
if decoder.codec = astiav.FindDecoder(producer.CodecID()); decoder.codec == nil {
return ErrorNoCodecFound
}
decoder.decoderContext = astiav.AllocCodecContext(decoder.codec)
if decoder.decoderContext == nil {
return ErrorAllocateCodecContext
}
return nil
}
func (decoder *GeneralDecoder) FillContextContent(producer CanDescribeMediaPacket) error {
return producer.GetCodecParameters().ToCodecContext(decoder.decoderContext)
}
func (decoder *GeneralDecoder) SetFrameRate(producer CanDescribeFrameRate) {
decoder.decoderContext.SetFramerate(producer.FrameRate())
}
func (decoder *GeneralDecoder) SetTimeBase(producer CanDescribeTimeBase) {
decoder.decoderContext.SetTimeBase(producer.TimeBase())
}
// ### IMPLEMENTS CanDescribeMediaVideoFrame
func (decoder *GeneralDecoder) FrameRate() astiav.Rational {
return decoder.decoderContext.Framerate()
}
func (decoder *GeneralDecoder) TimeBase() astiav.Rational {
return decoder.decoderContext.TimeBase()
}
func (decoder *GeneralDecoder) Height() int {
return decoder.decoderContext.Height()
}
func (decoder *GeneralDecoder) Width() int {
return decoder.decoderContext.Width()
}
func (decoder *GeneralDecoder) PixelFormat() astiav.PixelFormat {
return decoder.decoderContext.PixelFormat()
}
func (decoder *GeneralDecoder) SampleAspectRatio() astiav.Rational {
return decoder.decoderContext.SampleAspectRatio()
}
func (decoder *GeneralDecoder) ColorSpace() astiav.ColorSpace {
return decoder.decoderContext.ColorSpace()
}
func (decoder *GeneralDecoder) ColorRange() astiav.ColorRange {
return decoder.decoderContext.ColorRange()
}
// ## CanDescribeMediaAudioFrame
func (decoder *GeneralDecoder) SampleRate() int {
return decoder.decoderContext.SampleRate()
}
func (decoder *GeneralDecoder) SampleFormat() astiav.SampleFormat {
return decoder.decoderContext.SampleFormat()
}
func (decoder *GeneralDecoder) ChannelLayout() astiav.ChannelLayout {
return decoder.decoderContext.ChannelLayout()
}
// ## CanDescribeMediaFrame
func (decoder *GeneralDecoder) MediaType() astiav.MediaType {
return decoder.decoderContext.MediaType()
}

View File

@@ -0,0 +1,63 @@
//go:build cgo_enabled
package transcode
import (
"github.com/asticode/go-astiav"
"github.com/harshabose/tools/pkg/buffer"
)
type DecoderOption = func(decoder Decoder) error
func withVideoSetDecoderContext(demuxer CanDescribeMediaPacket) DecoderOption {
return func(decoder Decoder) error {
consumer, ok := decoder.(CanSetMediaPacket)
if !ok {
return ErrorInterfaceMismatch
}
if err := consumer.SetCodec(demuxer); err != nil {
return err
}
if err := consumer.FillContextContent(demuxer); err != nil {
return err
}
consumer.SetFrameRate(demuxer)
consumer.SetTimeBase(demuxer)
return nil
}
}
func withAudioSetDecoderContext(demuxer CanDescribeMediaPacket) DecoderOption {
return func(decoder Decoder) error {
consumer, ok := decoder.(CanSetMediaPacket)
if !ok {
return ErrorInterfaceMismatch
}
if err := consumer.SetCodec(demuxer); err != nil {
return err
}
if err := consumer.FillContextContent(demuxer); err != nil {
return err
}
consumer.SetTimeBase(demuxer)
return nil
}
}
func WithDecoderBuffer(size int, pool buffer.Pool[*astiav.Frame]) DecoderOption {
return func(decoder Decoder) error {
s, ok := decoder.(CanSetBuffer[*astiav.Frame])
if !ok {
return ErrorInterfaceMismatch
}
s.SetBuffer(buffer.CreateChannelBuffer(decoder.Ctx(), size, pool))
return nil
}
}

171
pkg/transcode/demuxer.go Normal file
View File

@@ -0,0 +1,171 @@
//go:build cgo_enabled
package transcode
import (
"context"
"time"
"github.com/asticode/go-astiav"
"github.com/harshabose/tools/pkg/buffer"
)
type GeneralDemuxer struct {
formatContext *astiav.FormatContext
inputOptions *astiav.Dictionary
inputFormat *astiav.InputFormat
stream *astiav.Stream
codecParameters *astiav.CodecParameters
buffer buffer.BufferWithGenerator[*astiav.Packet]
ctx context.Context
cancel context.CancelFunc
}
func CreateGeneralDemuxer(ctx context.Context, containerAddress string, options ...DemuxerOption) (*GeneralDemuxer, error) {
ctx2, cancel := context.WithCancel(ctx)
astiav.RegisterAllDevices()
demuxer := &GeneralDemuxer{
formatContext: astiav.AllocFormatContext(),
inputOptions: astiav.NewDictionary(),
ctx: ctx2,
cancel: cancel,
}
if demuxer.formatContext == nil {
return nil, ErrorAllocateFormatContext
}
if demuxer.inputOptions == nil {
return nil, ErrorGeneralAllocate
}
for _, option := range options {
if err := option(demuxer); err != nil {
return nil, err
}
}
if err := demuxer.formatContext.OpenInput(containerAddress, demuxer.inputFormat, demuxer.inputOptions); err != nil {
return nil, err
}
if err := demuxer.formatContext.FindStreamInfo(nil); err != nil {
return nil, ErrorNoStreamFound
}
for _, stream := range demuxer.formatContext.Streams() {
demuxer.stream = stream
break
}
if demuxer.stream == nil {
return nil, ErrorNoVideoStreamFound
}
demuxer.codecParameters = demuxer.stream.CodecParameters()
if demuxer.buffer == nil {
demuxer.buffer = buffer.CreateChannelBuffer(ctx, 256, buffer.CreatePacketPool())
}
return demuxer, nil
}
func (demuxer *GeneralDemuxer) Ctx() context.Context {
return demuxer.ctx
}
func (demuxer *GeneralDemuxer) Start() {
go demuxer.loop()
}
func (demuxer *GeneralDemuxer) Stop() {
demuxer.cancel()
}
func (demuxer *GeneralDemuxer) loop() {
defer demuxer.close()
loop1:
for {
select {
case <-demuxer.ctx.Done():
return
default:
loop2:
for {
packet := demuxer.buffer.Generate()
if err := demuxer.formatContext.ReadFrame(packet); err != nil {
demuxer.buffer.PutBack(packet)
continue loop1
}
if packet.StreamIndex() != demuxer.stream.Index() {
demuxer.buffer.PutBack(packet)
continue loop2
}
if err := demuxer.pushPacket(packet); err != nil {
demuxer.buffer.PutBack(packet)
continue loop1
}
break loop2
}
}
}
}
func (demuxer *GeneralDemuxer) pushPacket(packet *astiav.Packet) error {
ctx, cancel := context.WithTimeout(demuxer.ctx, 50*time.Millisecond) // TODO: NEEDS TO BE BASED ON FPS ON INPUT_FORMAT
defer cancel()
return demuxer.buffer.Push(ctx, packet)
}
func (demuxer *GeneralDemuxer) GetPacket(ctx context.Context) (*astiav.Packet, error) {
return demuxer.buffer.Pop(ctx)
}
func (demuxer *GeneralDemuxer) PutBack(packet *astiav.Packet) {
demuxer.buffer.PutBack(packet)
}
func (demuxer *GeneralDemuxer) close() {
if demuxer.formatContext != nil {
demuxer.formatContext.CloseInput()
demuxer.formatContext.Free()
}
}
func (demuxer *GeneralDemuxer) SetInputOption(key, value string, flags astiav.DictionaryFlags) error {
return demuxer.inputOptions.Set(key, value, flags)
}
func (demuxer *GeneralDemuxer) SetInputFormat(format *astiav.InputFormat) {
demuxer.inputFormat = format
}
func (demuxer *GeneralDemuxer) SetBuffer(buffer buffer.BufferWithGenerator[*astiav.Packet]) {
demuxer.buffer = buffer
}
func (demuxer *GeneralDemuxer) GetCodecParameters() *astiav.CodecParameters {
return demuxer.codecParameters
}
func (demuxer *GeneralDemuxer) MediaType() astiav.MediaType {
return demuxer.codecParameters.MediaType()
}
func (demuxer *GeneralDemuxer) CodecID() astiav.CodecID {
return demuxer.codecParameters.CodecID()
}
func (demuxer *GeneralDemuxer) FrameRate() astiav.Rational {
return demuxer.formatContext.GuessFrameRate(demuxer.stream, nil)
}
func (demuxer *GeneralDemuxer) TimeBase() astiav.Rational {
return demuxer.stream.TimeBase()
}

View File

@@ -0,0 +1,98 @@
//go:build cgo_enabled
package transcode
import (
"github.com/asticode/go-astiav"
"github.com/harshabose/tools/pkg/buffer"
)
type DemuxerOption = func(demuxer Demuxer) error
func WithRTSPInputOption(demuxer Demuxer) error {
s, ok := demuxer.(CanSetDemuxerInputOption)
if !ok {
return ErrorInterfaceMismatch
}
if err := s.SetInputOption("rtsp_transport", "tcp", 0); err != nil {
return err
}
if err := s.SetInputOption("stimeout", "5000000", 0); err != nil {
return err
}
if err := s.SetInputOption("fflags", "nobuffer", 0); err != nil {
return err
}
if err := s.SetInputOption("flags", "low_delay", 0); err != nil {
return err
}
if err := s.SetInputOption("reorder_queue_size", "0", 0); err != nil {
return err
}
return nil
}
func WithFileInputOption(demuxer Demuxer) error {
s, ok := demuxer.(CanSetDemuxerInputOption)
if !ok {
return ErrorInterfaceMismatch
}
if err := s.SetInputOption("re", "", 0); err != nil {
return err
}
// // Additional options for smooth playback
// if err := demuxer.inputOptions.SetInputOption("fflags", "+genpts", 0); err != nil {
// return err
// }
return nil
}
func WithAlsaInputFormatOption(demuxer Demuxer) error {
s, ok := demuxer.(CanSetDemuxerInputFormat)
if !ok {
return ErrorInterfaceMismatch
}
s.SetInputFormat(astiav.FindInputFormat("alsa"))
return nil
}
func WithAvFoundationInputFormatOption(demuxer Demuxer) error {
setInputFormat, ok := demuxer.(CanSetDemuxerInputFormat)
if !ok {
return ErrorInterfaceMismatch
}
setInputFormat.SetInputFormat(astiav.FindInputFormat("avfoundation"))
setInputOption, ok := demuxer.(CanSetDemuxerInputOption)
if !ok {
return ErrorInterfaceMismatch
}
if err := setInputOption.SetInputOption("video_size", "1280x720", 0); err != nil {
return err
}
if err := setInputOption.SetInputOption("framerate", "30", 0); err != nil {
return err
}
if err := setInputOption.SetInputOption("pixel_format", "uyvy422", 0); err != nil {
return err
}
return nil
}
func WithDemuxerBuffer(size int, pool buffer.Pool[*astiav.Packet]) DemuxerOption {
return func(demuxer Demuxer) error {
s, ok := demuxer.(CanSetBuffer[*astiav.Packet])
if !ok {
return ErrorInterfaceMismatch
}
s.SetBuffer(buffer.CreateChannelBuffer(demuxer.Ctx(), size, pool))
return nil
}
}

226
pkg/transcode/encoder.go Normal file
View File

@@ -0,0 +1,226 @@
//go:build cgo_enabled
package transcode
import (
"context"
"encoding/base64"
"errors"
"fmt"
"time"
"github.com/asticode/go-astiav"
"github.com/harshabose/tools/pkg/buffer"
)
type GeneralEncoder struct {
buffer buffer.BufferWithGenerator[*astiav.Packet]
producer CanProduceMediaFrame
codec *astiav.Codec
encoderContext *astiav.CodecContext
codecFlags *astiav.Dictionary
encoderSettings codecSettings
sps []byte
pps []byte
ctx context.Context
cancel context.CancelFunc
}
func CreateGeneralEncoder(ctx context.Context, codecID astiav.CodecID, canProduceMediaFrame CanProduceMediaFrame, options ...EncoderOption) (*GeneralEncoder, error) {
ctx2, cancel := context.WithCancel(ctx)
encoder := &GeneralEncoder{
producer: canProduceMediaFrame,
codecFlags: astiav.NewDictionary(),
ctx: ctx2,
cancel: cancel,
}
encoder.codec = astiav.FindEncoder(codecID)
if encoder.encoderContext = astiav.AllocCodecContext(encoder.codec); encoder.encoderContext == nil {
return nil, ErrorAllocateCodecContext
}
canDescribeMediaFrame, ok := canProduceMediaFrame.(CanDescribeMediaFrame)
if !ok {
return nil, ErrorInterfaceMismatch
}
if canDescribeMediaFrame.MediaType() == astiav.MediaTypeAudio {
withAudioSetEncoderContextParameters(canDescribeMediaFrame, encoder.encoderContext)
}
if canDescribeMediaFrame.MediaType() == astiav.MediaTypeVideo {
withVideoSetEncoderContextParameter(canDescribeMediaFrame, encoder.encoderContext)
}
for _, option := range options {
if err := option(encoder); err != nil {
return nil, err
}
}
if encoder.encoderSettings == nil {
fmt.Println("warn: no encoder settings are provided")
}
encoder.encoderContext.SetFlags(astiav.NewCodecContextFlags(astiav.CodecContextFlagGlobalHeader))
if err := encoder.encoderContext.Open(encoder.codec, encoder.codecFlags); err != nil {
return nil, err
}
if encoder.buffer == nil {
encoder.buffer = buffer.CreateChannelBuffer(ctx2, 256, buffer.CreatePacketPool())
}
encoder.findParameterSets(encoder.encoderContext.ExtraData())
return encoder, nil
}
func (encoder *GeneralEncoder) Ctx() context.Context {
return encoder.ctx
}
func (encoder *GeneralEncoder) Start() {
go encoder.loop()
}
func (encoder *GeneralEncoder) GetParameterSets() ([]byte, []byte, error) {
encoder.findParameterSets(encoder.encoderContext.ExtraData())
return encoder.sps, encoder.pps, nil
}
func (encoder *GeneralEncoder) TimeBase() astiav.Rational {
return encoder.encoderContext.TimeBase()
}
func (encoder *GeneralEncoder) loop() {
defer encoder.close()
loop1:
for {
select {
case <-encoder.ctx.Done():
return
default:
frame, err := encoder.getFrame()
if err != nil {
continue
}
if err := encoder.encoderContext.SendFrame(frame); err != nil {
encoder.producer.PutBack(frame)
if !errors.Is(err, astiav.ErrEagain) {
continue loop1
}
}
loop2:
for {
packet := encoder.buffer.Generate()
if err = encoder.encoderContext.ReceivePacket(packet); err != nil {
encoder.buffer.PutBack(packet)
break loop2
}
if err := encoder.pushPacket(packet); err != nil {
encoder.buffer.PutBack(packet)
continue loop2
}
}
encoder.producer.PutBack(frame)
}
}
}
func (encoder *GeneralEncoder) getFrame() (*astiav.Frame, error) {
ctx, cancel := context.WithTimeout(encoder.ctx, 100*time.Millisecond)
defer cancel()
return encoder.producer.GetFrame(ctx)
}
func (encoder *GeneralEncoder) GetPacket(ctx context.Context) (*astiav.Packet, error) {
return encoder.buffer.Pop(ctx)
}
func (encoder *GeneralEncoder) pushPacket(packet *astiav.Packet) error {
ctx, cancel := context.WithTimeout(encoder.ctx, 100*time.Millisecond)
defer cancel()
return encoder.buffer.Push(ctx, packet)
}
func (encoder *GeneralEncoder) PutBack(packet *astiav.Packet) {
encoder.buffer.PutBack(packet)
}
func (encoder *GeneralEncoder) Stop() {
encoder.cancel()
}
func (encoder *GeneralEncoder) close() {
if encoder.encoderContext != nil {
encoder.encoderContext.Free()
}
if encoder.codecFlags != nil {
encoder.codecFlags.Free()
}
}
func (encoder *GeneralEncoder) findParameterSets(extraData []byte) {
if len(extraData) > 0 {
// Find the first start code (0x00000001)
for i := 0; i < len(extraData)-4; i++ {
if extraData[i] == 0 && extraData[i+1] == 0 && extraData[i+2] == 0 && extraData[i+3] == 1 {
// Skip start code to get the NAL type
nalType := extraData[i+4] & 0x1F
// Find the next start code or end
nextStart := len(extraData)
for j := i + 4; j < len(extraData)-4; j++ {
if extraData[j] == 0 && extraData[j+1] == 0 && extraData[j+2] == 0 && extraData[j+3] == 1 {
nextStart = j
break
}
}
if nalType == 7 { // SPS
encoder.sps = make([]byte, nextStart-i)
copy(encoder.sps, extraData[i:nextStart])
} else if nalType == 8 { // PPS
encoder.pps = make([]byte, len(extraData)-i)
copy(encoder.pps, extraData[i:])
}
i = nextStart - 1
}
}
fmt.Println("SPS for current encoder: ", encoder.sps)
fmt.Println("\tSPS for current encoder in Base64:", base64.StdEncoding.EncodeToString(encoder.sps))
fmt.Println("PPS for current encoder: ", encoder.pps)
fmt.Println("\tPPS for current encoder in Base64:", base64.StdEncoding.EncodeToString(encoder.pps))
}
}
func (encoder *GeneralEncoder) SetBuffer(buffer buffer.BufferWithGenerator[*astiav.Packet]) {
encoder.buffer = buffer
}
func (encoder *GeneralEncoder) SetEncoderCodecSettings(settings codecSettings) error {
encoder.encoderSettings = settings
return encoder.encoderSettings.ForEach(func(key string, value string) error {
if value == "" {
return nil
}
return encoder.codecFlags.Set(key, value, 0)
})
}
func (encoder *GeneralEncoder) GetCurrentBitrate() (int64, error) {
g, ok := encoder.encoderSettings.(CanGetCurrentBitrate)
if !ok {
return 0, ErrorInterfaceMismatch
}
return g.GetCurrentBitrate()
}

View File

@@ -0,0 +1,103 @@
//go:build cgo_enabled
package transcode
import (
"context"
"github.com/asticode/go-astiav"
"github.com/harshabose/tools/pkg/buffer"
)
type GeneralEncoderBuilder struct {
codecID astiav.CodecID
bufferSize int
pool buffer.Pool[*astiav.Packet]
settings codecSettings
producer CanProduceMediaFrame
}
func NewEncoderBuilder(codecID astiav.CodecID, settings codecSettings, producer CanProduceMediaFrame, bufferSize int, pool buffer.Pool[*astiav.Packet]) *GeneralEncoderBuilder {
return &GeneralEncoderBuilder{
bufferSize: bufferSize,
pool: pool,
codecID: codecID,
settings: settings,
producer: producer,
}
}
func (b *GeneralEncoderBuilder) UpdateBitrate(bps int64) error {
s, ok := b.settings.(CanUpdateBitrate)
if !ok {
return ErrorInterfaceMismatch
}
return s.UpdateBitrate(bps)
}
func (b *GeneralEncoderBuilder) BuildWithProducer(ctx context.Context, producer CanProduceMediaFrame) (Encoder, error) {
b.producer = producer
return b.Build(ctx)
}
func (b *GeneralEncoderBuilder) Build(ctx context.Context) (Encoder, error) {
codec := astiav.FindEncoder(b.codecID)
if codec == nil {
return nil, ErrorNoCodecFound
}
ctx2, cancel := context.WithCancel(ctx)
encoder := &GeneralEncoder{
producer: b.producer,
codec: codec,
codecFlags: astiav.NewDictionary(),
ctx: ctx2,
cancel: cancel,
}
encoder.encoderContext = astiav.AllocCodecContext(codec)
if encoder.encoderContext == nil {
return nil, ErrorAllocateCodecContext
}
canDescribeMediaFrame, ok := encoder.producer.(CanDescribeMediaFrame)
if !ok {
return nil, ErrorInterfaceMismatch
}
if canDescribeMediaFrame.MediaType() == astiav.MediaTypeAudio {
withAudioSetEncoderContextParameters(canDescribeMediaFrame, encoder.encoderContext)
}
if canDescribeMediaFrame.MediaType() == astiav.MediaTypeVideo {
withVideoSetEncoderContextParameter(canDescribeMediaFrame, encoder.encoderContext)
}
if err := encoder.SetEncoderCodecSettings(b.settings); err != nil {
return nil, err
}
if err := WithEncoderBufferSize(b.bufferSize, b.pool)(encoder); err != nil {
return nil, err
}
encoder.encoderContext.SetFlags(astiav.NewCodecContextFlags(astiav.CodecContextFlagGlobalHeader))
if err := encoder.encoderContext.Open(encoder.codec, encoder.codecFlags); err != nil {
return nil, err
}
encoder.findParameterSets(encoder.encoderContext.ExtraData())
return encoder, nil
}
func (b *GeneralEncoderBuilder) GetCurrentBitrate() (int64, error) {
g, ok := b.settings.(CanGetCurrentBitrate)
if !ok {
return 0, ErrorInterfaceMismatch
}
return g.GetCurrentBitrate()
}

View File

@@ -0,0 +1,378 @@
//go:build cgo_enabled
package transcode
import (
"fmt"
"reflect"
"strings"
"github.com/asticode/go-astiav"
"github.com/harshabose/tools/pkg/buffer"
)
type (
EncoderOption = func(encoder Encoder) error
)
type codecSettings interface {
ForEach(func(string, string) error) error
}
type X264Opts struct {
Bitrate string `x264-opts:"bitrate"`
VBVMaxBitrate string `x264-opts:"vbv-maxrate"`
VBVBuffer string `x264-opts:"vbv-bufsize"`
RateTol string `x264-opts:"ratetol"`
SyncLookAhead string `x264-opts:"sync-lookahead"`
AnnexB string `x264-opts:"annexb"`
}
func (x264 *X264Opts) ForEach(fn func(string, string) error) error {
t := reflect.TypeOf(*x264)
v := reflect.ValueOf(*x264)
// Build a single x264opts string
var optParts []string
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
tag := field.Tag.Get("x264-opts")
if tag != "" && v.Field(i).String() != "" {
optParts = append(optParts, tag+"="+v.Field(i).String())
}
}
// Join all options with colons
if len(optParts) > 0 {
x264optsValue := strings.Join(optParts, ":")
if err := fn("x264opts", x264optsValue); err != nil {
return err
}
}
// Also apply any regular x264 parameters
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
tag := field.Tag.Get("x264")
if tag != "" {
if err := fn(tag, v.Field(i).String()); err != nil {
return err
}
}
}
return nil
}
func (x264 *X264Opts) UpdateBitrate(bps int64) error {
x264.Bitrate = fmt.Sprintf("%d", bps/1000)
x264.VBVMaxBitrate = fmt.Sprintf("%d", (bps/1000)+200)
x264.VBVBuffer = fmt.Sprintf("%d", bps/2000)
return nil
}
type X264OpenSettings struct {
*X264Opts
// RateControl string `x264:"rc"` // not sure; fuck
Preset string `x264:"preset"` // exists
Tune string `x264:"tune"` // exists
Refs string `x264:"refs"` // exists
Profile string `x264:"profile"` // exists
Level string `x264:"level"` // exists
Qmin string `x264:"qmin"` // exists
Qmax string `x264:"qmax"` // exists
BFrames string `x264:"bf"` // exists
BAdapt string `x264:"b_strategy"` // exists
NGOP string `x264:"g"` // exists
NGOPMin string `x264:"keyint_min"` // exists
Scenecut string `x264:"sc_threshold"` // exists
IntraRefresh string `x264:"intra-refresh"` // exists
LookAhead string `x264:"rc-lookahead"` // exists
SlicedThreads string `x264:"slice"` // exists
ForceIDR string `x264:"force-idr"` // exists
AQMode string `x264:"aq-mode"` // exists
AQStrength string `x264:"aq-strength"` // exists
MBTree string `x264:"mbtree"` // exists
Threads string `x264:"threads"` // exists
Aud string `x264:"aud"` // exists
}
func (s *X264OpenSettings) ForEach(fn func(key, value string) error) error {
t := reflect.TypeOf(*s)
v := reflect.ValueOf(*s)
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
tag := field.Tag.Get("x264")
if tag != "" {
if err := fn(tag, v.Field(i).String()); err != nil {
return err
}
}
}
return s.X264Opts.ForEach(fn)
}
func (s *X264OpenSettings) UpdateBitrate(bps int64) error {
return s.X264Opts.UpdateBitrate(bps)
}
var DefaultX264Settings = X264OpenSettings{
X264Opts: &X264Opts{
// RateControl: "abr",
Bitrate: "4000",
VBVMaxBitrate: "5000",
VBVBuffer: "8000",
RateTol: "1",
SyncLookAhead: "1",
AnnexB: "1",
},
Preset: "medium",
Tune: "film",
Refs: "6",
Profile: "high",
Level: "auto",
Qmin: "18",
Qmax: "28",
BFrames: "3",
BAdapt: "1",
NGOP: "250",
NGOPMin: "25",
Scenecut: "40",
IntraRefresh: "0",
LookAhead: "40",
SlicedThreads: "0",
ForceIDR: "0",
AQMode: "1",
AQStrength: "1.0",
MBTree: "1",
Threads: "0",
Aud: "0",
}
var LowBandwidthX264Settings = X264OpenSettings{
X264Opts: &X264Opts{
// RateControl: "abr",
Bitrate: "1500",
VBVMaxBitrate: "1800",
VBVBuffer: "3000",
RateTol: "0.25",
SyncLookAhead: "0",
AnnexB: "1",
},
Preset: "veryfast",
Tune: "fastdecode",
Refs: "2",
Profile: "baseline",
Level: "4.1",
Qmin: "23",
Qmax: "35",
BFrames: "0",
BAdapt: "0",
NGOP: "60",
NGOPMin: "30",
Scenecut: "30",
IntraRefresh: "0",
LookAhead: "20",
SlicedThreads: "1",
ForceIDR: "0",
AQMode: "0",
AQStrength: "1.2",
MBTree: "0",
Threads: "0",
Aud: "0",
}
var LowLatencyX264Settings = X264OpenSettings{
X264Opts: &X264Opts{
// RateControl: "abr",
Bitrate: "2500",
VBVMaxBitrate: "12000",
VBVBuffer: "20000",
RateTol: "0.5",
SyncLookAhead: "0",
AnnexB: "1",
},
Preset: "ultrafast",
Tune: "zerolatency",
Refs: "1",
Profile: "baseline",
Level: "4.1",
Qmin: "20",
Qmax: "32",
BFrames: "0",
BAdapt: "0",
NGOP: "30",
NGOPMin: "15",
Scenecut: "0",
IntraRefresh: "1",
LookAhead: "10",
SlicedThreads: "1",
ForceIDR: "1",
AQMode: "0",
AQStrength: "0",
MBTree: "0",
Threads: "0",
Aud: "1",
}
var HighQualityX264Settings = X264OpenSettings{
X264Opts: &X264Opts{
// RateControl: "abr",
Bitrate: "15000",
VBVMaxBitrate: "20000",
VBVBuffer: "30000",
RateTol: "2.0",
SyncLookAhead: "1",
AnnexB: "1",
},
Preset: "slow",
Tune: "film",
Refs: "8",
Profile: "high",
Level: "5.1",
Qmin: "15",
Qmax: "24",
BFrames: "5",
BAdapt: "2",
NGOP: "250",
NGOPMin: "30",
Scenecut: "80",
IntraRefresh: "0",
LookAhead: "60",
SlicedThreads: "0",
ForceIDR: "0",
AQMode: "0",
AQStrength: "1.3",
MBTree: "1",
Threads: "0",
Aud: "0",
}
var WebRTCOptimisedX264Settings = X264OpenSettings{
X264Opts: &X264Opts{
Bitrate: "800", // Keep your current target
VBVMaxBitrate: "900", // Same as target!
VBVBuffer: "300", // 2500/30fps ≈ 83 kbits (single frame)
RateTol: "0.1", // More tolerance
AnnexB: "1", // Already correct
},
Qmin: "26", // Wider range
Qmax: "42", // Much wider range
Level: "3.1", // Better compatibility
Preset: "ultrafast",
Tune: "zerolatency",
Profile: "baseline",
NGOP: "50",
NGOPMin: "25",
IntraRefresh: "1",
SlicedThreads: "1", // TODO: CHECK THIS
// ForceIDR: "1", // TODO: CHECK THIS; MIGHT BE IN CONFLICT WITH IntraRefresh
AQMode: "1", // RE-ENABLED AS zerolatency disables this
AQStrength: "0.5",
Threads: "0",
Aud: "1",
}
func WithX264DefaultOptions(encoder Encoder) error {
return WithCodecSettings(&DefaultX264Settings)(encoder)
}
func WithX264HighQualityOptions(encoder Encoder) error {
return WithCodecSettings(&HighQualityX264Settings)(encoder)
}
func WithX264LowLatencyOptions(encoder Encoder) error {
return WithCodecSettings(&LowLatencyX264Settings)(encoder)
}
func WithWebRTCOptimisedOptions(encoder Encoder) error {
return WithCodecSettings(&WebRTCOptimisedX264Settings)(encoder)
}
func WithCodecSettings(settings codecSettings) EncoderOption {
return func(encoder Encoder) error {
s, ok := encoder.(CanSetEncoderCodecSettings)
if !ok {
return ErrorInterfaceMismatch
}
return s.SetEncoderCodecSettings(settings)
}
}
func WithX264LowBandwidthOptions(encoder Encoder) error {
return WithCodecSettings(&LowBandwidthX264Settings)(encoder)
}
func withAudioSetEncoderContextParameters(filter CanDescribeMediaAudioFrame, eCtx *astiav.CodecContext) {
eCtx.SetTimeBase(filter.TimeBase())
eCtx.SetSampleRate(filter.SampleRate())
eCtx.SetSampleFormat(filter.SampleFormat())
eCtx.SetChannelLayout(filter.ChannelLayout())
eCtx.SetStrictStdCompliance(-2)
}
func withVideoSetEncoderContextParameter(filter CanDescribeMediaVideoFrame, eCtx *astiav.CodecContext) {
eCtx.SetHeight(filter.Height())
eCtx.SetWidth(filter.Width())
eCtx.SetTimeBase(filter.TimeBase())
eCtx.SetPixelFormat(filter.PixelFormat())
eCtx.SetFramerate(filter.FrameRate())
}
func WithEncoderBufferSize(size int, pool buffer.Pool[*astiav.Packet]) EncoderOption {
return func(encoder Encoder) error {
s, ok := encoder.(CanSetBuffer[*astiav.Packet])
if !ok {
return ErrorInterfaceMismatch
}
s.SetBuffer(buffer.CreateChannelBuffer(encoder.Ctx(), size, pool))
return nil
}
}
//
// type VP8Settings struct {
// Deadline string `vp8:"deadline"` // Real-time encoding
// Bitrate string `vp8:"b"` // Target bitrate
// MinRate string `vp8:"minrate"` // Minimum bitrate
// MaxRate string `vp8:"maxrate"` // Maximum bitrate
// BufSize string `vp8:"bufsize"` // Buffer size
// CRF string `vp8:"crf"` // Quality setting
// CPUUsed string `vp8:"cpu-used"` // Speed preset
// }
//
// var DefaultVP8Settings = VP8Settings{
// Deadline: "1", // Real-time
// Bitrate: "2500k", // 2.5 Mbps
// MinRate: "2000k", // Min 2 Mbps
// MaxRate: "3000k", // Max 3 Mbps
// BufSize: "500k", // 500kb buffer
// CRF: "10", // Good quality
// CPUUsed: "8", // Fastest
// }
//
// func (s VP8Settings) ForEach(fn func(key, value string) error) error {
// t := reflect.TypeOf(s)
// v := reflect.ValueOf(s)
//
// for i := 0; i < t.NumField(); i++ {
// field := t.Field(i)
// tag := field.Tag.Get("vp8")
// if tag != "" {
// if err := fn(tag, v.Field(i).String()); err != nil {
// return err
// }
// }
// }
//
// return nil
// }

29
pkg/transcode/errors.go Normal file
View File

@@ -0,0 +1,29 @@
//go:build cgo_enabled
package transcode
import "errors"
var (
ErrorAllocateFormatContext = errors.New("error allocate format context")
ErrorOpenInputContainer = errors.New("error opening container")
ErrorNoStreamFound = errors.New("error no stream found")
ErrorGeneralAllocate = errors.New("error allocating general object")
ErrorNoVideoStreamFound = errors.New("no video stream found")
ErrorInterfaceMismatch = errors.New("interface mismatch")
ErrorNoCodecFound = errors.New("error no codec found")
ErrorAllocateCodecContext = errors.New("error allocating codec context")
ErrorFillCodecContext = errors.New("error filling the codec context")
ErrorNoFilterName = errors.New("error filter name does not exists")
WarnNoFilterContent = errors.New("content is empty. no filtering will be done")
ErrorGraphParse = errors.New("error parsing the filter graph")
ErrorGraphConfigure = errors.New("error configuring the filter graph")
ErrorSrcContextSetParameter = errors.New("error while setting parameters to source context")
ErrorSrcContextInitialise = errors.New("error initialising the source context")
ErrorAllocSrcContext = errors.New("error setting source context")
ErrorAllocSinkContext = errors.New("error setting sink context")
ErrorCodecNoSetting = errors.New("error no settings given")
)

304
pkg/transcode/filter.go Normal file
View File

@@ -0,0 +1,304 @@
//go:build cgo_enabled
package transcode
import (
"context"
"fmt"
"time"
"github.com/asticode/go-astiav"
"github.com/harshabose/tools/pkg/buffer"
)
type GeneralFilter struct {
content string
decoder CanProduceMediaFrame
buffer buffer.BufferWithGenerator[*astiav.Frame]
graph *astiav.FilterGraph
input *astiav.FilterInOut
output *astiav.FilterInOut
srcContext *astiav.BuffersrcFilterContext
sinkContext *astiav.BuffersinkFilterContext
srcContextParams *astiav.BuffersrcFilterContextParameters // NOTE: THIS BECOMES NIL AFTER INITIALISATION
ctx context.Context
cancel context.CancelFunc
}
func CreateGeneralFilter(ctx context.Context, canProduceMediaFrame CanProduceMediaFrame, filterConfig FilterConfig, options ...FilterOption) (*GeneralFilter, error) {
ctx2, cancel := context.WithCancel(ctx)
filter := &GeneralFilter{
graph: astiav.AllocFilterGraph(),
decoder: canProduceMediaFrame,
input: astiav.AllocFilterInOut(),
output: astiav.AllocFilterInOut(),
srcContextParams: astiav.AllocBuffersrcFilterContextParameters(),
ctx: ctx2,
cancel: cancel,
}
// TODO: CHECK IF ALL ATTRIBUTES ARE ALLOCATED PROPERLY
filterSrc := astiav.FindFilterByName(filterConfig.Source.String())
if filterSrc == nil {
return nil, ErrorNoFilterName
}
filterSink := astiav.FindFilterByName(filterConfig.Sink.String())
if filterSink == nil {
return nil, ErrorNoFilterName
}
srcContext, err := filter.graph.NewBuffersrcFilterContext(filterSrc, "in")
if err != nil {
return nil, ErrorAllocSrcContext
}
filter.srcContext = srcContext
sinkContext, err := filter.graph.NewBuffersinkFilterContext(filterSink, "out")
if err != nil {
return nil, ErrorAllocSinkContext
}
filter.sinkContext = sinkContext
canDescribeMediaFrame, ok := canProduceMediaFrame.(CanDescribeMediaFrame)
if !ok {
return nil, ErrorInterfaceMismatch
}
if canDescribeMediaFrame.MediaType() == astiav.MediaTypeVideo {
options = append([]FilterOption{withVideoSetFilterContextParameters(canDescribeMediaFrame)}, options...)
}
if canDescribeMediaFrame.MediaType() == astiav.MediaTypeAudio {
options = append([]FilterOption{withAudioSetFilterContextParameters(canDescribeMediaFrame)}, options...)
}
for _, option := range options {
if err = option(filter); err != nil {
// TODO: SET CONTENT HERE
return nil, err
}
}
if filter.buffer == nil {
filter.buffer = buffer.CreateChannelBuffer(ctx, 256, buffer.CreateFramePool())
}
if err = filter.srcContext.SetParameters(filter.srcContextParams); err != nil {
return nil, ErrorSrcContextSetParameter
}
if err = filter.srcContext.Initialize(astiav.NewDictionary()); err != nil {
return nil, ErrorSrcContextInitialise
}
filter.output.SetName("in")
filter.output.SetFilterContext(filter.srcContext.FilterContext())
filter.output.SetPadIdx(0)
filter.output.SetNext(nil)
filter.input.SetName("out")
filter.input.SetFilterContext(filter.sinkContext.FilterContext())
filter.input.SetPadIdx(0)
filter.input.SetNext(nil)
if filter.content == "" {
fmt.Println(WarnNoFilterContent)
}
if err = filter.graph.Parse(filter.content, filter.input, filter.output); err != nil {
return nil, ErrorGraphParse
}
if err = filter.graph.Configure(); err != nil {
return nil, ErrorGraphConfigure
}
if filter.srcContextParams != nil {
filter.srcContextParams.Free()
}
return filter, nil
}
func (filter *GeneralFilter) Ctx() context.Context {
return filter.ctx
}
func (filter *GeneralFilter) Start() {
go filter.loop()
}
func (filter *GeneralFilter) Stop() {
filter.cancel()
}
func (filter *GeneralFilter) loop() {
defer filter.close()
loop1:
for {
select {
case <-filter.ctx.Done():
return
default:
srcFrame, err := filter.getFrame()
if err != nil {
// fmt.Println("unable to get frame from decoder; err:", err.Error())
continue
}
if err := filter.srcContext.AddFrame(srcFrame, astiav.NewBuffersrcFlags(astiav.BuffersrcFlagKeepRef)); err != nil {
filter.buffer.PutBack(srcFrame)
continue loop1
}
loop2:
for {
sinkFrame := filter.buffer.Generate()
if err = filter.sinkContext.GetFrame(sinkFrame, astiav.NewBuffersinkFlags()); err != nil {
filter.buffer.PutBack(sinkFrame)
break loop2
}
if err := filter.pushFrame(sinkFrame); err != nil {
filter.buffer.PutBack(sinkFrame)
continue loop2
}
}
filter.decoder.PutBack(srcFrame)
}
}
}
func (filter *GeneralFilter) pushFrame(frame *astiav.Frame) error {
ctx, cancel := context.WithTimeout(filter.ctx, 50*time.Millisecond)
defer cancel()
return filter.buffer.Push(ctx, frame)
}
func (filter *GeneralFilter) getFrame() (*astiav.Frame, error) {
ctx, cancel := context.WithTimeout(filter.ctx, 50*time.Millisecond)
defer cancel()
return filter.decoder.GetFrame(ctx)
}
func (filter *GeneralFilter) PutBack(frame *astiav.Frame) {
filter.buffer.PutBack(frame)
}
func (filter *GeneralFilter) GetFrame(ctx context.Context) (*astiav.Frame, error) {
return filter.buffer.Pop(ctx)
}
func (filter *GeneralFilter) close() {
if filter.graph != nil {
filter.graph.Free()
}
if filter.input != nil {
filter.input.Free()
}
if filter.output != nil {
filter.output.Free()
}
}
func (filter *GeneralFilter) SetBuffer(buffer buffer.BufferWithGenerator[*astiav.Frame]) {
filter.buffer = buffer
}
func (filter *GeneralFilter) AddToFilterContent(content string) {
filter.content += content
}
func (filter *GeneralFilter) SetFrameRate(describe CanDescribeFrameRate) {
filter.srcContextParams.SetFramerate(describe.FrameRate())
}
func (filter *GeneralFilter) SetTimeBase(describe CanDescribeTimeBase) {
filter.srcContextParams.SetTimeBase(describe.TimeBase())
}
func (filter *GeneralFilter) SetHeight(describe CanDescribeMediaVideoFrame) {
filter.srcContextParams.SetHeight(describe.Height())
}
func (filter *GeneralFilter) SetWidth(describe CanDescribeMediaVideoFrame) {
filter.srcContextParams.SetWidth(describe.Width())
}
func (filter *GeneralFilter) SetPixelFormat(describe CanDescribeMediaVideoFrame) {
filter.srcContextParams.SetPixelFormat(describe.PixelFormat())
}
func (filter *GeneralFilter) SetSampleAspectRatio(describe CanDescribeMediaVideoFrame) {
filter.srcContextParams.SetSampleAspectRatio(describe.SampleAspectRatio())
}
func (filter *GeneralFilter) SetColorSpace(describe CanDescribeMediaVideoFrame) {
filter.srcContextParams.SetColorSpace(describe.ColorSpace())
}
func (filter *GeneralFilter) SetColorRange(describe CanDescribeMediaVideoFrame) {
filter.srcContextParams.SetColorRange(describe.ColorRange())
}
func (filter *GeneralFilter) SetSampleRate(describe CanDescribeMediaAudioFrame) {
filter.srcContextParams.SetSampleRate(describe.SampleRate())
}
func (filter *GeneralFilter) SetSampleFormat(describe CanDescribeMediaAudioFrame) {
filter.srcContextParams.SetSampleFormat(describe.SampleFormat())
}
func (filter *GeneralFilter) SetChannelLayout(describe CanDescribeMediaAudioFrame) {
filter.srcContextParams.SetChannelLayout(describe.ChannelLayout())
}
func (filter *GeneralFilter) MediaType() astiav.MediaType {
return filter.sinkContext.MediaType()
}
func (filter *GeneralFilter) FrameRate() astiav.Rational {
return filter.sinkContext.FrameRate()
}
func (filter *GeneralFilter) TimeBase() astiav.Rational {
return filter.sinkContext.TimeBase()
}
func (filter *GeneralFilter) Height() int {
return filter.sinkContext.Height()
}
func (filter *GeneralFilter) Width() int {
return filter.sinkContext.Width()
}
func (filter *GeneralFilter) PixelFormat() astiav.PixelFormat {
return filter.sinkContext.PixelFormat()
}
func (filter *GeneralFilter) SampleAspectRatio() astiav.Rational {
return filter.sinkContext.SampleAspectRatio()
}
func (filter *GeneralFilter) ColorSpace() astiav.ColorSpace {
return filter.sinkContext.ColorSpace()
}
func (filter *GeneralFilter) ColorRange() astiav.ColorRange {
return filter.sinkContext.ColorRange()
}
func (filter *GeneralFilter) SampleRate() int {
return filter.sinkContext.SampleRate()
}
func (filter *GeneralFilter) SampleFormat() astiav.SampleFormat {
return filter.sinkContext.SampleFormat()
}
func (filter *GeneralFilter) ChannelLayout() astiav.ChannelLayout {
return filter.sinkContext.ChannelLayout()
}

View File

@@ -0,0 +1,304 @@
//go:build cgo_enabled
package transcode
import (
"fmt"
"strings"
"github.com/asticode/go-astiav"
"github.com/harshabose/tools/pkg/buffer"
)
type (
FilterOption func(Filter) error
Name string
)
func (f Name) String() string {
return string(f)
}
type FilterConfig struct {
Source Name
Sink Name
}
const (
videoBufferFilterName Name = "buffer"
videoBufferSinkFilterName Name = "buffersink"
audioBufferFilterName Name = "abuffer"
audioBufferSinkFilterName Name = "abuffersink"
)
var (
VideoFilters = FilterConfig{
Source: videoBufferFilterName,
Sink: videoBufferSinkFilterName,
}
AudioFilters = FilterConfig{
Source: audioBufferFilterName,
Sink: audioBufferSinkFilterName,
}
)
func WithFilterBuffer(size int, pool buffer.Pool[*astiav.Frame]) FilterOption {
return func(filter Filter) error {
s, ok := filter.(CanSetBuffer[*astiav.Frame])
if !ok {
return ErrorInterfaceMismatch
}
s.SetBuffer(buffer.CreateChannelBuffer(filter.Ctx(), size, pool))
return nil
}
}
func withVideoSetFilterContextParameters(decoder CanDescribeMediaVideoFrame) func(Filter) error {
return func(filter Filter) error {
canSetMediaVideoFrame, ok := filter.(CanSetMediaVideoFrame)
if !ok {
return ErrorInterfaceMismatch
}
canSetMediaVideoFrame.SetFrameRate(decoder)
canSetMediaVideoFrame.SetHeight(decoder)
canSetMediaVideoFrame.SetPixelFormat(decoder)
canSetMediaVideoFrame.SetSampleAspectRatio(decoder)
canSetMediaVideoFrame.SetTimeBase(decoder)
canSetMediaVideoFrame.SetWidth(decoder)
canSetMediaVideoFrame.SetColorSpace(decoder)
canSetMediaVideoFrame.SetColorRange(decoder)
return nil
}
}
func WithVideoScaleFilterContent(width, height uint16) FilterOption {
return func(filter Filter) error {
a, ok := filter.(CanAddToFilterContent)
if !ok {
return ErrorInterfaceMismatch
}
a.AddToFilterContent(fmt.Sprintf("scale=%d:%d,", width, height))
return nil
}
}
func WithVideoPixelFormatFilterContent(pixelFormat astiav.PixelFormat) FilterOption {
return func(filter Filter) error {
a, ok := filter.(CanAddToFilterContent)
if !ok {
return ErrorInterfaceMismatch
}
a.AddToFilterContent(fmt.Sprintf("format=pix_fmts=%s,", pixelFormat))
return nil
}
}
func WithVideoFPSFilterContent(fps uint8) FilterOption {
return func(filter Filter) error {
a, ok := filter.(CanAddToFilterContent)
if !ok {
return ErrorInterfaceMismatch
}
a.AddToFilterContent(fmt.Sprintf("fps=%d,", fps))
return nil
}
}
// +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
func withAudioSetFilterContextParameters(decoder CanDescribeMediaAudioFrame) func(Filter) error {
return func(filter Filter) error {
canSetMediaAudioFrame, ok := filter.(CanSetMediaAudioFrame)
if !ok {
return ErrorInterfaceMismatch
}
canSetMediaAudioFrame.SetChannelLayout(decoder)
canSetMediaAudioFrame.SetSampleFormat(decoder)
canSetMediaAudioFrame.SetSampleRate(decoder)
canSetMediaAudioFrame.SetTimeBase(decoder)
return nil
}
}
func WithAudioSampleFormatChannelLayoutFilter(sampleFormat astiav.SampleFormat, channelLayout astiav.ChannelLayout) FilterOption {
return func(filter Filter) error {
a, ok := filter.(CanAddToFilterContent)
if !ok {
return ErrorInterfaceMismatch
}
a.AddToFilterContent(fmt.Sprintf("aformat=sample_fmts=%s:channel_layouts=%s", sampleFormat.String(), channelLayout.String()) + ",")
return nil
}
}
func WithAudioSampleRateFilter(samplerate uint32) FilterOption {
return func(filter Filter) error {
a, ok := filter.(CanAddToFilterContent)
if !ok {
return ErrorInterfaceMismatch
}
a.AddToFilterContent(fmt.Sprintf("aresample=%d,", samplerate))
return nil
}
}
func WithAudioSamplesPerFrameContent(nsamples uint16) FilterOption {
return func(filter Filter) error {
a, ok := filter.(CanAddToFilterContent)
if !ok {
return ErrorInterfaceMismatch
}
a.AddToFilterContent(fmt.Sprintf("asetnsamples=%d,", nsamples))
return nil
}
}
func WithAudioCompressionContent(threshold int, ratio int, attack float64, release float64) FilterOption {
return func(filter Filter) error {
// NOTE: DYNAMIC RANGE COMPRESSION TO HANDLE SUDDEN VOLUME CHANGES
// Possible values 'acompressor=threshold=-12dB:ratio=2:attack=0.05:release=0.2" // MOST POPULAR VALUES
a, ok := filter.(CanAddToFilterContent)
if !ok {
return ErrorInterfaceMismatch
}
a.AddToFilterContent(fmt.Sprintf("acompressor=threshold=%ddB:ratio=%d:attack=%.2f:release=%.2f,",
threshold, ratio, attack, release))
return nil
}
}
func WithAudioHighPassFilterContent(id string, frequency float32, order uint8) FilterOption {
return func(filter Filter) error {
// NOTE: HIGH-PASS FILTER TO REMOVE WIND NOISE AND TURBULENCE
// NOTE: 120HZ CUTOFF MIGHT PRESERVE VOICE WHILE REMOVING LOW RUMBLE; BUT MORE TESTING IS NEEDED
a, ok := filter.(CanAddToFilterContent)
if !ok {
return ErrorInterfaceMismatch
}
a.AddToFilterContent(fmt.Sprintf("highpass@%s=frequency=%.2f:poles=%d", id, frequency, order))
return nil
}
}
func WithAudioLowPassFilterContent(id string, frequency float32, order uint8) FilterOption {
return func(filter Filter) error {
a, ok := filter.(CanAddToFilterContent)
if !ok {
return ErrorInterfaceMismatch
}
a.AddToFilterContent(fmt.Sprintf("lowpass@%s=frequency=%.2f:poles=%d", id, frequency, order))
return nil
}
}
func WithAudioNotchFilterContent(id string, frequency float32, qFactor float32) FilterOption {
return func(filter Filter) error {
a, ok := filter.(CanAddToFilterContent)
if !ok {
return ErrorInterfaceMismatch
}
a.AddToFilterContent(fmt.Sprintf("bandreject@%s=frequency=%.2f:width_type=q:width=%.2f", id, frequency, qFactor))
return nil
}
}
func WithAudioNotchHarmonicsFilterContent(id string, fundamental float32, harmonics uint8, qFactor float32) FilterOption {
return func(filter Filter) error {
a, ok := filter.(CanAddToFilterContent)
if !ok {
return ErrorInterfaceMismatch
}
var filters = make([]string, 0)
for i := uint8(0); i < harmonics; i++ {
harmonic := fundamental * float32(i+1)
filters = append(filters, fmt.Sprintf("bandreject@%s%d=frequency=%.2f:width_type=q:width=%.2f", id, i, harmonic, qFactor))
}
a.AddToFilterContent(strings.Join(filters, ",") + ",")
return nil
}
}
func WithAudioEqualiserFilter(id string, frequency float32, width float32, gain float32) FilterOption {
return func(filter Filter) error {
// NOTE: EQUALISER CAN BE USED TO ENHANCE SPEECH BANDWIDTH (300 - 3kHz). MORE RESEARCH NEEDS TO DONE
a, ok := filter.(CanAddToFilterContent)
if !ok {
return ErrorInterfaceMismatch
}
a.AddToFilterContent(fmt.Sprintf("equalizer@%s=frequency=%.2f:width_type=h:width=%.2f:gain=%.2f,", id, frequency, width, gain))
return nil
}
}
func WithAudioSilenceGateContent(threshold int, range_ int, attack float64, release float64) FilterOption {
return func(filter Filter) error {
// NOTE: IF EVERYTHING WORKS, WE SHOULD HAVE LIGHT NOISE WHICH CAN BE CONSIDERED AS SILENCE. THIS GATE REMOVES SILENCE
// NOTE: POSSIBLE VALUES 'agate=threshold=-30dB:range=-30dB:attack=0.01:release=0.1" // MOST POPULAR; MORE TESTING IS NEEDED
a, ok := filter.(CanAddToFilterContent)
if !ok {
return ErrorInterfaceMismatch
}
a.AddToFilterContent(fmt.Sprintf("agate=threshold=%ddB:range=%ddB:attack=%.2f:release=%.2f,",
threshold, range_, attack, release))
return nil
}
}
func WithAudioLoudnessNormaliseContent(intensity int, truePeak float64, range_ int) FilterOption {
return func(filter Filter) error {
// NOTE: NORMALISES THE FINAL AUDIO. MUST BE CALLED AT THE END
// NOTE: POSSIBLE VALUES "loudnorm=I=-16:TP=-1.5:LRA=11" // MOST POPULAR
a, ok := filter.(CanAddToFilterContent)
if !ok {
return ErrorInterfaceMismatch
}
a.AddToFilterContent(fmt.Sprintf("loudnorm=I=%d:TP=%.1f:LRA=%d",
intensity, truePeak, range_))
return nil
}
}
func WithFFTBroadBandNoiseFilter(id string, strength float32, rPatch float32, rSearch float32) FilterOption {
return func(filter Filter) error {
// TODO: NEEDS A UPDATOR TO CONTROL NOISE SAMPLING
a, ok := filter.(CanAddToFilterContent)
if !ok {
return ErrorInterfaceMismatch
}
a.AddToFilterContent(fmt.Sprintf(""))
return nil
}
}
func WithMeanBroadBandNoiseFilter(id string, strength float32, rPatch float32, rSearch float32) FilterOption {
return func(filter Filter) error {
a, ok := filter.(CanAddToFilterContent)
if !ok {
return ErrorInterfaceMismatch
}
a.AddToFilterContent(fmt.Sprintf("anlmdn@%s=strength=%.2f:patch=%.2f:research=%.2f", id, strength, rPatch, rSearch))
return nil
}
}

165
pkg/transcode/interfaces.go Normal file
View File

@@ -0,0 +1,165 @@
//go:build cgo_enabled
package transcode
import (
"context"
"github.com/asticode/go-astiav"
"github.com/harshabose/tools/pkg/buffer"
)
type CanSetDemuxerInputOption interface {
SetInputOption(key, value string, flags astiav.DictionaryFlags) error
}
type CanSetDemuxerInputFormat interface {
SetInputFormat(*astiav.InputFormat)
}
type CanSetBuffer[T any] interface {
SetBuffer(buffer buffer.BufferWithGenerator[T])
}
type CanDescribeFrameRate interface {
FrameRate() astiav.Rational
}
type CanDescribeTimeBase interface {
TimeBase() astiav.Rational
}
type CanSetFrameRate interface {
SetFrameRate(CanDescribeFrameRate)
}
type CanSetTimeBase interface {
SetTimeBase(CanDescribeTimeBase)
}
type CanDescribeMediaPacket interface {
MediaType() astiav.MediaType
CodecID() astiav.CodecID
GetCodecParameters() *astiav.CodecParameters
CanDescribeFrameRate
CanDescribeTimeBase
}
type CanProduceMediaPacket interface {
GetPacket(ctx context.Context) (*astiav.Packet, error)
PutBack(*astiav.Packet)
}
type CanProduceMediaFrame interface {
GetFrame(ctx context.Context) (*astiav.Frame, error)
PutBack(*astiav.Frame)
}
type CanDescribeMediaVideoFrame interface {
CanDescribeFrameRate
CanDescribeTimeBase
Height() int
Width() int
PixelFormat() astiav.PixelFormat
SampleAspectRatio() astiav.Rational
ColorSpace() astiav.ColorSpace
ColorRange() astiav.ColorRange
}
type CanSetMediaVideoFrame interface {
CanSetFrameRate
CanSetTimeBase
SetHeight(CanDescribeMediaVideoFrame)
SetWidth(CanDescribeMediaVideoFrame)
SetPixelFormat(CanDescribeMediaVideoFrame)
SetSampleAspectRatio(CanDescribeMediaVideoFrame)
SetColorSpace(CanDescribeMediaVideoFrame)
SetColorRange(CanDescribeMediaVideoFrame)
}
type CanDescribeMediaFrame interface {
MediaType() astiav.MediaType
CanDescribeMediaVideoFrame
CanDescribeMediaAudioFrame
}
type CanSetMediaAudioFrame interface {
CanSetTimeBase
SetSampleRate(CanDescribeMediaAudioFrame)
SetSampleFormat(CanDescribeMediaAudioFrame)
SetChannelLayout(CanDescribeMediaAudioFrame)
}
type CanDescribeMediaAudioFrame interface {
CanDescribeTimeBase
SampleRate() int
SampleFormat() astiav.SampleFormat
ChannelLayout() astiav.ChannelLayout
}
type CanSetMediaPacket interface {
FillContextContent(CanDescribeMediaPacket) error
SetCodec(CanDescribeMediaPacket) error
CanSetFrameRate
CanSetTimeBase
}
type Demuxer interface {
Ctx() context.Context
Start()
Stop()
CanProduceMediaPacket
}
type Decoder interface {
Ctx() context.Context
Start()
Stop()
CanProduceMediaFrame
}
type CanAddToFilterContent interface {
AddToFilterContent(string)
}
type Filter interface {
Ctx() context.Context
Start()
Stop()
CanProduceMediaFrame
}
type CanPauseUnPauseEncoder interface {
PauseEncoding() error
UnPauseEncoding() error
}
type CanGetParameterSets interface {
GetParameterSets() (sps, pps []byte, err error)
}
type Encoder interface {
Ctx() context.Context
Start()
Stop()
CanProduceMediaPacket
}
type CanSetEncoderCodecSettings interface {
SetEncoderCodecSettings(codecSettings) error
}
type CanUpdateBitrate interface {
UpdateBitrate(int64) error
}
type CanGetCurrentBitrate interface {
GetCurrentBitrate() (int64, error)
}
type UpdateBitrateCallBack func(bps int64) error
type CanGetUpdateBitrateCallBack interface {
OnUpdateBitrate() UpdateBitrateCallBack
}

View File

@@ -0,0 +1,315 @@
//go:build cgo_enabled
package transcode
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/asticode/go-astiav"
"github.com/harshabose/tools/pkg/buffer"
)
type MultiConfig struct {
Steps uint8
UpdateConfig
}
func (c MultiConfig) validate() error {
if c.Steps == 0 {
return fmt.Errorf("steps need be more than 0")
}
return c.UpdateConfig.validate()
}
func (c MultiConfig) getBitrates() []int64 {
bitrates := make([]int64, c.Steps)
if c.Steps == 1 {
bitrates[0] = c.MaxBitrate
} else {
step := float64(c.MaxBitrate-c.MinBitrate) / float64(c.Steps-1)
for i := uint8(0); i < c.Steps; i++ {
bitrates[i] = c.MinBitrate + int64(float64(i)*step)
}
}
return bitrates
}
func NewMultiConfig(minBitrate, maxBitrate int64, steps uint8) MultiConfig {
c := MultiConfig{
UpdateConfig: UpdateConfig{
MaxBitrate: maxBitrate,
MinBitrate: minBitrate,
},
Steps: steps,
}
return c
}
type dummyMediaFrameProducer struct {
buffer buffer.BufferWithGenerator[*astiav.Frame]
CanDescribeMediaFrame
}
func newDummyMediaFrameProducer(buffer buffer.BufferWithGenerator[*astiav.Frame], describer CanDescribeMediaFrame) *dummyMediaFrameProducer {
return &dummyMediaFrameProducer{
buffer: buffer,
CanDescribeMediaFrame: describer,
}
}
func (p *dummyMediaFrameProducer) pushFrame(ctx context.Context, frame *astiav.Frame) error {
return p.buffer.Push(ctx, frame)
}
func (p *dummyMediaFrameProducer) GetFrame(ctx context.Context) (*astiav.Frame, error) {
return p.buffer.Pop(ctx)
}
func (p *dummyMediaFrameProducer) Generate() *astiav.Frame {
return p.buffer.Generate()
}
func (p *dummyMediaFrameProducer) PutBack(frame *astiav.Frame) {
p.buffer.PutBack(frame)
}
type splitEncoder struct {
encoder *GeneralEncoder
producer *dummyMediaFrameProducer
}
func newSplitEncoder(encoder *GeneralEncoder, producer *dummyMediaFrameProducer) *splitEncoder {
return &splitEncoder{
encoder: encoder,
producer: producer,
}
}
type MultiUpdateEncoder struct {
encoders []*splitEncoder
active atomic.Pointer[splitEncoder]
config MultiConfig
bitrates []int64
producer CanProduceMediaFrame
ctx context.Context
cancel context.CancelFunc
paused atomic.Bool
resume chan struct{}
pauseMux sync.Mutex
}
func NewMultiUpdateEncoder(ctx context.Context, config MultiConfig, builder *GeneralEncoderBuilder) (*MultiUpdateEncoder, error) {
if err := config.validate(); err != nil {
return nil, err
}
ctx2, cancel := context.WithCancel(ctx)
encoder := &MultiUpdateEncoder{
encoders: make([]*splitEncoder, 0),
config: config,
bitrates: config.getBitrates(),
producer: builder.producer,
ctx: ctx2,
cancel: cancel,
resume: make(chan struct{}),
}
describer, ok := encoder.producer.(CanDescribeMediaFrame)
if !ok {
return nil, ErrorInterfaceMismatch
}
initialBitrate, err := builder.GetCurrentBitrate()
if err != nil {
initialBitrate = encoder.bitrates[0]
}
for _, bitrate := range encoder.bitrates {
// TODO: WARN: 90 size might be tooo high
// TODO: Frame pool could be abstracted away
producer := newDummyMediaFrameProducer(buffer.CreateChannelBuffer(ctx2, 90, buffer.CreateFramePool()), describer)
if err := builder.UpdateBitrate(bitrate); err != nil {
return nil, err
}
e, err := builder.BuildWithProducer(ctx2, producer)
if err != nil {
return nil, err
}
encoder.encoders = append(encoder.encoders, newSplitEncoder(e.(*GeneralEncoder), producer))
}
encoder.switchEncoder(encoder.findBestEncoderIndex(initialBitrate))
return encoder, nil
}
func (u *MultiUpdateEncoder) Ctx() context.Context {
return u.ctx
}
func (u *MultiUpdateEncoder) Start() {
for _, encoder := range u.encoders {
encoder.encoder.Start()
}
go u.loop()
}
func (u *MultiUpdateEncoder) GetPacket(ctx context.Context) (*astiav.Packet, error) {
return u.active.Load().encoder.GetPacket(ctx)
}
func (u *MultiUpdateEncoder) PutBack(packet *astiav.Packet) {
u.active.Load().encoder.PutBack(packet)
}
func (u *MultiUpdateEncoder) Stop() {
u.cancel()
}
func (u *MultiUpdateEncoder) UpdateBitrate(bps int64) error {
if err := u.checkPause(bps); err != nil {
return err
}
bps = u.cutoff(bps)
bestIndex := u.findBestEncoderIndex(bps)
u.switchEncoder(bestIndex)
return nil
}
func (u *MultiUpdateEncoder) findBestEncoderIndex(targetBps int64) int {
bestIndex := 0
for i, bitrate := range u.bitrates {
if bitrate <= targetBps {
bestIndex = i
} else {
break
}
}
return bestIndex
}
func (u *MultiUpdateEncoder) switchEncoder(index int) {
if index < len(u.encoders) {
fmt.Printf("swapping to %d encoder with bitrate %d\n", index, u.bitrates[index])
u.active.Swap(u.encoders[index])
}
}
func (u *MultiUpdateEncoder) cutoff(bps int64) int64 {
if bps > u.config.MaxBitrate {
bps = u.config.MaxBitrate
}
if bps < u.config.MinBitrate {
bps = u.config.MinBitrate
}
return bps
}
func (u *MultiUpdateEncoder) shouldPause(bps int64) bool {
return bps <= u.config.MinBitrate && u.config.CutVideoBelowMinBitrate
}
func (u *MultiUpdateEncoder) checkPause(bps int64) error {
shouldPause := u.shouldPause(bps)
if shouldPause {
fmt.Println("pausing video...")
return u.PauseEncoding()
}
return u.UnPauseEncoding()
}
func (u *MultiUpdateEncoder) PauseEncoding() error {
u.paused.Store(true)
return nil
}
func (u *MultiUpdateEncoder) UnPauseEncoding() error {
u.pauseMux.Lock()
defer u.pauseMux.Unlock()
if u.paused.Swap(false) {
close(u.resume)
u.resume = make(chan struct{})
}
return nil
}
func (u *MultiUpdateEncoder) GetParameterSets() (sps []byte, pps []byte, err error) {
return u.active.Load().encoder.GetParameterSets()
}
func (u *MultiUpdateEncoder) loop() {
defer u.close()
for {
select {
case <-u.ctx.Done():
return
default:
frame, err := u.getFrame()
if err != nil {
continue
}
for _, encoder := range u.encoders {
if err := u.pushFrame(encoder, frame); err != nil {
continue
}
}
u.producer.PutBack(frame)
}
}
}
func (u *MultiUpdateEncoder) getFrame() (*astiav.Frame, error) {
ctx, cancel := context.WithTimeout(u.ctx, 50*time.Millisecond)
defer cancel()
return u.producer.GetFrame(ctx)
}
func (u *MultiUpdateEncoder) pushFrame(encoder *splitEncoder, frame *astiav.Frame) error {
if frame == nil {
return fmt.Errorf("frame is nil from the producer")
}
ctx, cancel := context.WithTimeout(u.ctx, 50*time.Millisecond)
defer cancel()
refFrame := encoder.producer.Generate()
if refFrame == nil {
return fmt.Errorf("failed to generate frame from encoder pool")
}
if err := refFrame.Ref(frame); err != nil {
return fmt.Errorf("erorr while adding ref to frame; err: %s", "refFrame is nil")
}
// PUT IN BUFFER
return encoder.producer.pushFrame(ctx, refFrame)
}
func (u *MultiUpdateEncoder) close() {
}

View File

@@ -0,0 +1,247 @@
//go:build cgo_enabled
package transcode
//
// import (
// "context"
// "errors"
// "fmt"
// "sync"
// "time"
//
// "github.com/aler9/gomavlib"
// "github.com/aler9/gomavlib/pkg/dialects/ardupilotmega"
// "github.com/aler9/gomavlib/pkg/dialects/common"
// "github.com/asticode/go-astiav"
// )
//
// type Propeller string
//
// func (prop Propeller) String() string {
// return string(prop)
// }
//
// const (
// PropellerOne Propeller = "propeller0"
// PropellerTwo Propeller = "propeller1"
// PropellerThree Propeller = "propeller2"
// PropellerFour Propeller = "propeller3"
// PropellerFive Propeller = "propeller4"
// PropellerSix Propeller = "propeller5"
// PropellerSeven Propeller = "propeller6"
// PropellerEight Propeller = "propeller7"
// )
//
// type Updator interface {
// Start(*GeneralFilter)
// }
//
// func WithUpdateFilter(updator Updator) FilterOption {
// return func(filter *GeneralFilter) error {
// filter.updators = append(filter.updators, updator)
// return nil
// }
// }
//
// type notch struct {
// prop Propeller
// harmonics uint8
// frequencies []float32
// nBlades uint8
// }
//
// func createNotch(prop Propeller, fundamental float32, harmonics, nBlades uint8) *notch {
// n := &notch{
// prop: prop,
// harmonics: harmonics,
// frequencies: make([]float32, harmonics),
// nBlades: nBlades,
// }
//
// for i := uint8(0); i < n.harmonics; i++ {
// n.frequencies[i] = fundamental * float32(i+1)
// }
//
// return n
// }
//
// func (notch *notch) update(rpm float32) {
// fundamental := rpm * float32(notch.nBlades) / 60.0
// for i := uint8(0); i < notch.harmonics; i++ {
// notch.frequencies[i] = (notch.frequencies[i] + fundamental*float32(i+1)) / 2.0
// }
// }
//
// type PropNoiseFilterUpdator struct {
// notches []*notch
// node *gomavlib.Node
// interval time.Duration
// mux sync.RWMutex
// flags astiav.FilterCommandFlags
// ctx context.Context
// }
//
// func CreatePropNoiseFilterUpdator(ctx context.Context, mavlinkSerial string, baudrate int, interval time.Duration) (*PropNoiseFilterUpdator, error) {
// updater := &PropNoiseFilterUpdator{
// notches: make([]*notch, 0),
// flags: astiav.NewFilterCommandFlags(astiav.FilterCommandFlagFast, astiav.FilterCommandFlagOne),
// interval: interval,
// ctx: ctx,
// }
//
// config := gomavlib.NodeConf{
// Endpoints: []gomavlib.EndpointConf{
// gomavlib.EndpointSerial{
// Device: mavlinkSerial,
// Baud: baudrate,
// },
// },
// Dialect: ardupilotmega.Dialect,
// OutVersion: gomavlib.V2,
// OutSystemID: 10,
// }
//
// node, err := gomavlib.NewNode(config)
// if err != nil {
// return nil, err
// }
// updater.node = node
//
// return updater, nil
// }
//
// func (update *PropNoiseFilterUpdator) AddNotchFilter(id Propeller, frequency float32, harmonics uint8, nBlades uint8) {
// update.mux.Lock()
//
// // rpm nBlades will have RPM to rpm conversion with number of blades (Nb / 60)
// update.notches = append(update.notches, createNotch(id, frequency, harmonics, nBlades))
//
// update.mux.Unlock()
// }
//
// func (update *PropNoiseFilterUpdator) loop1() {
// ticker := time.NewTicker(update.interval)
// defer ticker.Stop()
//
// for {
// select {
// case <-update.ctx.Done():
// return
// case <-ticker.C:
// update.node.WriteMessageAll(&ardupilotmega.MessageCommandLong{
// TargetSystem: 1,
// TargetComponent: 0,
// Command: common.MAV_CMD_REQUEST_MESSAGE,
// Confirmation: 0,
// Param1: float32((&ardupilotmega.MessageEscTelemetry_1To_4{}).GetID()),
// Param2: 0,
// Param3: 0,
// Param4: 0,
// Param5: 0,
// Param6: 0,
// Param7: 0,
// })
//
// update.node.WriteMessageAll(&ardupilotmega.MessageCommandLong{
// TargetSystem: 1,
// TargetComponent: 0,
// Command: common.MAV_CMD_REQUEST_MESSAGE,
// Confirmation: 0,
// Param1: float32((&ardupilotmega.MessageEscTelemetry_5To_8{}).GetID()),
// Param2: 0,
// Param3: 0,
// Param4: 0,
// Param5: 0,
// Param6: 0,
// Param7: 0,
// })
// }
// }
// }
//
// func (update *PropNoiseFilterUpdator) loop2() {
// eventChan := update.node.Events()
//
// loop:
// for {
// select {
// case <-update.ctx.Done():
// return
// case event, ok := <-eventChan:
// if !ok {
// return
// }
//
// if frm, ok := event.(*gomavlib.EventFrame); ok {
// switch msg := frm.Message().(type) {
// case *ardupilotmega.MessageEscTelemetry_1To_4:
// update.mux.Lock()
//
// length := min(len(update.notches), 4)
// if length <= 0 {
// continue loop
// }
// for i := 0; i < length; i++ {
// update.notches[i].update(float32(msg.Rpm[i]))
// }
//
// update.mux.Unlock()
// case *ardupilotmega.MessageEscTelemetry_5To_8:
// update.mux.Lock()
//
// length := min(len(update.notches)-4, 4)
// if length <= 0 {
// continue loop
// }
// for i := 0; i < length; i++ {
// update.notches[i+4].update(float32(msg.Rpm[i]))
// }
//
// update.mux.Unlock()
// }
// }
// }
// }
// }
//
// func (update *PropNoiseFilterUpdator) loop3(filter *GeneralFilter) {
// ticker := time.NewTicker(update.interval)
// defer ticker.Stop()
//
// for {
// select {
// case <-update.ctx.Done():
// return
// case <-ticker.C:
// if err := update.update(filter); err != nil {
// fmt.Printf("Error updating notch filter: %v\n", err)
// }
// }
// }
// }
//
// func (update *PropNoiseFilterUpdator) Start(filter *GeneralFilter) {
// go update.loop1()
// go update.loop2()
// go update.loop3(filter)
// }
//
// func (update *PropNoiseFilterUpdator) update(filter *GeneralFilter) error {
// if filter == nil {
// return errors.New("filter is nil")
// }
// filter.mux.Lock()
// defer filter.mux.Unlock()
//
// for index, notch := range update.notches {
// target := fmt.Sprintf("%s%d", notch.prop.String(), index)
// for _, frequency := range notch.frequencies {
// if _, err := filter.graph.SendCommand(target, "frequency", fmt.Sprintf("%.2f", frequency), update.flags); err != nil {
// return err
// }
// }
// }
//
// return nil
// }

109
pkg/transcode/transcoder.go Normal file
View File

@@ -0,0 +1,109 @@
//go:build cgo_enabled
package transcode
import (
"context"
"fmt"
"github.com/asticode/go-astiav"
)
type Transcoder struct {
demuxer Demuxer
decoder Decoder
filter Filter
encoder Encoder
}
func CreateTranscoder(options ...TranscoderOption) (*Transcoder, error) {
t := &Transcoder{}
for _, option := range options {
if err := option(t); err != nil {
return nil, err
}
}
return t, nil
}
func NewTranscoder(demuxer Demuxer, decoder Decoder, filter Filter, encoder Encoder) *Transcoder {
return &Transcoder{
demuxer: demuxer,
decoder: decoder,
filter: filter,
encoder: encoder,
}
}
func (t *Transcoder) Start() {
fmt.Println("started encoder")
t.demuxer.Start()
t.decoder.Start()
t.filter.Start()
t.encoder.Start()
}
func (t *Transcoder) Stop() {
t.encoder.Stop()
t.filter.Stop()
t.decoder.Stop()
t.demuxer.Stop()
}
func (t *Transcoder) GetPacket(ctx context.Context) (*astiav.Packet, error) {
return t.encoder.GetPacket(ctx)
}
func (t *Transcoder) PutBack(packet *astiav.Packet) {
t.encoder.PutBack(packet)
}
// Generate method is to satisfy mediapipe.CanGenerate interface. TODO: but I would prefer to integrate with PutBack
func (t *Transcoder) Generate() (*astiav.Packet, error) {
packet, err := t.encoder.GetPacket(t.encoder.Ctx())
if err != nil {
return nil, err
}
return packet, nil
}
func (t *Transcoder) PauseEncoding() error {
p, ok := t.encoder.(CanPauseUnPauseEncoder)
if !ok {
return ErrorInterfaceMismatch
}
return p.PauseEncoding()
}
func (t *Transcoder) UnPauseEncoding() error {
p, ok := t.encoder.(CanPauseUnPauseEncoder)
if !ok {
return ErrorInterfaceMismatch
}
return p.UnPauseEncoding()
}
func (t *Transcoder) GetParameterSets() (sps, pps []byte, err error) {
p, ok := t.encoder.(CanGetParameterSets)
if !ok {
return nil, nil, ErrorInterfaceMismatch
}
return p.GetParameterSets()
}
func (t *Transcoder) UpdateBitrate(bps int64) error {
u, ok := t.encoder.(CanUpdateBitrate)
if !ok {
return ErrorInterfaceMismatch
}
return u.UpdateBitrate(bps)
}
func (t *Transcoder) OnUpdateBitrate() UpdateBitrateCallBack {
return t.UpdateBitrate
}

View File

@@ -0,0 +1,87 @@
//go:build cgo_enabled
package transcode
import (
"context"
"github.com/asticode/go-astiav"
"github.com/harshabose/tools/pkg/buffer"
)
type TranscoderOption = func(*Transcoder) error
func WithGeneralDemuxer(ctx context.Context, containerAddress string, options ...DemuxerOption) TranscoderOption {
return func(transcoder *Transcoder) error {
demuxer, err := CreateGeneralDemuxer(ctx, containerAddress, options...)
if err != nil {
return err
}
transcoder.demuxer = demuxer
return nil
}
}
func WithGeneralDecoder(ctx context.Context, options ...DecoderOption) TranscoderOption {
return func(transcoder *Transcoder) error {
decoder, err := CreateGeneralDecoder(ctx, transcoder.demuxer, options...)
if err != nil {
return err
}
transcoder.decoder = decoder
return nil
}
}
func WithGeneralFilter(ctx context.Context, filterConfig FilterConfig, options ...FilterOption) TranscoderOption {
return func(transcoder *Transcoder) error {
filter, err := CreateGeneralFilter(ctx, transcoder.decoder, filterConfig, options...)
if err != nil {
return err
}
transcoder.filter = filter
return nil
}
}
func WithGeneralEncoder(ctx context.Context, codecID astiav.CodecID, options ...EncoderOption) TranscoderOption {
return func(transcoder *Transcoder) error {
encoder, err := CreateGeneralEncoder(ctx, codecID, transcoder.filter, options...)
if err != nil {
return err
}
transcoder.encoder = encoder
return nil
}
}
func WithBitrateControlEncoder(ctx context.Context, codecID astiav.CodecID, bitrateControlConfig UpdateConfig, settings codecSettings, bufferSize int, pool buffer.Pool[*astiav.Packet]) TranscoderOption {
return func(transcoder *Transcoder) error {
builder := NewEncoderBuilder(codecID, settings, transcoder.filter, bufferSize, pool)
updateEncoder, err := NewUpdateEncoder(ctx, bitrateControlConfig, builder)
if err != nil {
return err
}
transcoder.encoder = updateEncoder
return nil
}
}
func WithMultiEncoderBitrateControl(ctx context.Context, codecID astiav.CodecID, config MultiConfig, settings codecSettings, bufferSize int, pool buffer.Pool[*astiav.Packet]) TranscoderOption {
return func(transcoder *Transcoder) error {
builder := NewEncoderBuilder(codecID, settings, transcoder.filter, bufferSize, pool)
multiEncoder, err := NewMultiUpdateEncoder(ctx, config, builder)
if err != nil {
return err
}
transcoder.encoder = multiEncoder
return nil
}
}

View File

@@ -0,0 +1,268 @@
//go:build cgo_enabled
package transcode
import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"time"
"github.com/asticode/go-astiav"
"github.com/harshabose/tools/pkg/buffer"
)
type UpdateConfig struct {
MaxBitrate, MinBitrate int64
CutVideoBelowMinBitrate bool
}
func (c UpdateConfig) validate() error {
if c.MinBitrate > c.MaxBitrate {
return fmt.Errorf("minimum bitrate is higher than maximum bitrate in the update encoder config")
}
return nil
}
type UpdateEncoder struct {
encoder Encoder
config UpdateConfig
builder *GeneralEncoderBuilder
buffer buffer.BufferWithGenerator[*astiav.Packet]
mux sync.RWMutex
ctx context.Context
paused atomic.Bool
resume chan struct{}
pauseMux sync.Mutex
}
func NewUpdateEncoder(ctx context.Context, config UpdateConfig, builder *GeneralEncoderBuilder) (*UpdateEncoder, error) {
updater := &UpdateEncoder{
config: config,
builder: builder,
resume: make(chan struct{}),
buffer: buffer.CreateChannelBuffer(ctx, 30, buffer.CreatePacketPool()),
ctx: ctx,
}
if err := config.validate(); err != nil {
return nil, err
}
encoder, err := builder.Build(ctx)
if err != nil {
return nil, err
}
updater.encoder = encoder
go updater.loop()
return updater, nil
}
func (u *UpdateEncoder) Ctx() context.Context {
u.mux.Lock()
defer u.mux.Unlock()
return u.encoder.Ctx()
}
func (u *UpdateEncoder) Start() {
u.mux.Lock()
defer u.mux.Unlock()
u.encoder.Start()
}
func (u *UpdateEncoder) GetPacket(ctx context.Context) (*astiav.Packet, error) {
return u.buffer.Pop(ctx)
}
func (u *UpdateEncoder) PutBack(packet *astiav.Packet) {
u.mux.RLock()
defer u.mux.RUnlock()
u.encoder.PutBack(packet)
}
func (u *UpdateEncoder) Stop() {
u.mux.Lock()
defer u.mux.Unlock()
u.encoder.Stop()
}
// UpdateBitrate modifies the encoder's target bitrate to the specified value in bits per second.
// Returns an error if the update fails.
func (u *UpdateEncoder) UpdateBitrate(bps int64) error {
// return nil
if err := u.checkPause(bps); err != nil {
return err
}
bps = u.cutoff(bps)
g, ok := u.encoder.(CanGetCurrentBitrate)
if !ok {
return ErrorInterfaceMismatch
}
current, err := g.GetCurrentBitrate()
if err != nil {
return err
}
_, change := calculateBitrateChange(current, bps)
if change < 5 {
return nil
}
fmt.Printf("got bitrate update request (%d -> %d)\n", current, bps)
start := time.Now()
if err := u.builder.UpdateBitrate(bps); err != nil {
return err
}
newEncoder, err := u.builder.Build(u.ctx)
if err != nil {
return fmt.Errorf("build new encoder: %w", err)
}
newEncoder.Start()
// Wait for the first packet from the new encoder
// firstPacket := <-newEncoder.WaitForPacket()
// newEncoder.PutBack(firstPacket)
u.mux.Lock()
oldEncoder := u.encoder
u.encoder = newEncoder
u.mux.Unlock()
// Print encoder update notification
fmt.Println()
fmt.Println("╔═══════════════════════════════════════╗")
fmt.Println("║ 🎥 ENCODER UPDATED 🎥 ║")
fmt.Printf("║ New Bitrate: %6d kbps ║\n", bps/1000)
fmt.Printf("║ Change: %6.2f ║\n", change)
fmt.Printf("║ Update time: %6d ms ║\n", time.Since(start).Milliseconds())
fmt.Println("╚═══════════════════════════════════════╝")
fmt.Println()
if oldEncoder != nil {
oldEncoder.Stop()
}
return nil
}
func (u *UpdateEncoder) cutoff(bps int64) int64 {
if bps > u.config.MaxBitrate {
bps = u.config.MaxBitrate
}
if bps < u.config.MinBitrate {
bps = u.config.MinBitrate
}
return bps
}
func (u *UpdateEncoder) shouldPause(bps int64) bool {
return bps <= u.config.MinBitrate && u.config.CutVideoBelowMinBitrate
}
func (u *UpdateEncoder) checkPause(bps int64) error {
shouldPause := u.shouldPause(bps)
if shouldPause {
fmt.Println("pausing video...")
return u.PauseEncoding()
}
return u.UnPauseEncoding()
}
func (u *UpdateEncoder) PauseEncoding() error {
u.paused.Store(true)
return nil
}
func (u *UpdateEncoder) UnPauseEncoding() error {
u.pauseMux.Lock()
defer u.pauseMux.Unlock()
if u.paused.Swap(false) {
close(u.resume)
u.resume = make(chan struct{})
}
return nil
}
func (u *UpdateEncoder) GetParameterSets() (sps []byte, pps []byte, err error) {
p, ok := u.encoder.(CanGetParameterSets)
if !ok {
return nil, nil, ErrorInterfaceMismatch
}
return p.GetParameterSets()
}
func calculateBitrateChange(currentBps, newBps int64) (absoluteChange int64, percentageChange float64) {
absoluteChange = newBps - currentBps
if absoluteChange < 0 {
absoluteChange = -absoluteChange
}
if currentBps > 0 {
percentageChange = (float64(absoluteChange) / float64(currentBps)) * 100
}
return absoluteChange, percentageChange
}
func (u *UpdateEncoder) getPacket() (*astiav.Packet, error) {
u.mux.RLock()
defer u.mux.RUnlock()
if u.encoder != nil {
ctx, cancel := context.WithTimeout(u.ctx, 50*time.Millisecond)
defer cancel()
return u.encoder.GetPacket(ctx) // Don't hold lock during blocking call
}
return nil, errors.New("encoder is nil")
}
func (u *UpdateEncoder) pushPacket(p *astiav.Packet) error {
if p == nil {
return nil
}
ctx, cancel := context.WithTimeout(u.ctx, 50*time.Millisecond)
defer cancel()
return u.buffer.Push(ctx, p)
}
func (u *UpdateEncoder) loop() {
for {
select {
case <-u.ctx.Done():
return
default:
p, err := u.getPacket()
if err != nil {
// fmt.Println("error getting packet from encoder; err:", err.Error())
}
if err := u.pushPacket(p); err != nil {
fmt.Println(err.Error())
}
time.Sleep(10 * time.Millisecond)
}
}
}

View File

@@ -0,0 +1,143 @@
//go:build cgo_enabled
package transcode
import (
"reflect"
"strconv"
"strings"
)
type X264AdvancedOptions struct {
// PRIMARY OPTIONS
Bitrate string `x264-opts:"bitrate"`
VBVMaxBitrate string `x264-opts:"vbv-maxrate"`
VBVBuffer string `x264-opts:"vbv-bufsize"`
RateTolerance string `x264-opts:"ratetol"`
MaxGOP string `x264-opts:"keyint"`
MinGOP string `x264-opts:"min-keyint"`
MaxQP string `x264-opts:"qpmax"`
MinQP string `x264-opts:"qpmin"`
MaxQPStep string `x264-opts:"qpstep"`
IntraRefresh string `x264-opts:"intra-refresh"`
ConstrainedIntra string `x264-opts:"constrained-intra"`
// SECONDARY OPTIONS; SOME OF THEM ARE ALREADY SET BY PRESET, PROFILE AND TUNE
SceneCut string `x264-opts:"scenecut"`
BFrames string `x264-opts:"bframes"`
BAdapt string `x264-opts:"b-adapt"`
Refs string `x264-opts:"ref"`
RCLookAhead string `x264-opts:"rc-lookahead"`
AQMode string `x264-opts:"aq-mode"`
NalHrd string `x264-opts:"nal-hrd"`
}
func (o *X264AdvancedOptions) ForEach(f func(key, value string) error) error {
t := reflect.TypeOf(*o)
v := reflect.ValueOf(*o)
// Build a single x264opts string
var optParts []string
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
tag := field.Tag.Get("x264-opts")
if tag != "" && v.Field(i).String() != "" {
optParts = append(optParts, tag+"="+v.Field(i).String())
}
}
// Join all options with colons
if len(optParts) > 0 {
x264optsValue := strings.Join(optParts, ":")
if err := f("x264opts", x264optsValue); err != nil {
return err
}
}
return nil
}
func (o *X264AdvancedOptions) UpdateBitrate(bps int64) error {
kbps := bps / 1000
// Core bitrate settings (strict CBR)
o.Bitrate = strconv.FormatInt(kbps, 10)
o.VBVMaxBitrate = strconv.FormatInt(kbps, 10) // Same as bitrate for CBR
// VBV buffer: 0.5 seconds for low latency
// Formula: buffer_kb = (bitrate_kbps * buffer_duration_seconds)
// Minimum of 100 kb might be needed. TODO: do more research
bufferKb := max(kbps/2, 100) // 0.5 seconds = 1/2 second
o.VBVBuffer = strconv.FormatInt(bufferKb, 10)
return nil
}
func (o *X264AdvancedOptions) GetCurrentBitrate() (int64, error) {
kbps, err := strconv.ParseInt(o.Bitrate, 10, 64)
if err != nil {
return 0, err
}
return kbps * 1000, nil // Convert kbps to bps
}
type X264Options struct {
*X264AdvancedOptions
// PRECOMPILED OPTIONS
Profile string `x264:"profile"`
Level string `x264:"level"`
Preset string `x264:"preset"`
Tune string `x264:"tune"`
}
func (o *X264Options) ForEach(f func(key, value string) error) error {
t := reflect.TypeOf(*o)
v := reflect.ValueOf(*o)
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
tag := field.Tag.Get("x264")
if tag != "" {
if err := f(tag, v.Field(i).String()); err != nil {
return err
}
}
}
return o.X264AdvancedOptions.ForEach(f)
}
func (o *X264Options) UpdateBitrate(bps int64) error {
return o.X264AdvancedOptions.UpdateBitrate(bps)
}
// TODO: WARN: MAKING THIS A POINTER VARIABLE WILL MAKE ALL TRACKS WHICH USE THIS SETTINGS TO SHARE BITRATE
var LowLatencyBitrateControlled = &X264Options{
Profile: "baseline",
Level: "3.1",
Preset: "ultrafast",
Tune: "zerolatency",
X264AdvancedOptions: &X264AdvancedOptions{
Bitrate: "800", // 800kbps
VBVMaxBitrate: "800", // Equal to Bitrate
VBVBuffer: "400", // Half of Bitrate
RateTolerance: "1", // 1% rate tolerance
MaxGOP: "25",
MinGOP: "13",
// MaxQP: "80",
// MinQP: "24",
// MaxQPStep: "80",
IntraRefresh: "0",
ConstrainedIntra: "0",
SceneCut: "0",
BFrames: "0",
BAdapt: "0",
Refs: "1",
RCLookAhead: "0",
AQMode: "1", // Not sure; do more research
NalHrd: "cbr",
},
}

39
rtc_config.go Normal file
View File

@@ -0,0 +1,39 @@
package client
import (
"os"
"github.com/pion/webrtc/v4"
)
func GetRTCConfiguration() webrtc.Configuration {
return webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{os.Getenv("STUN_SERVER_URL")},
},
// {
// URLs: []string{os.Getenv("TURN_UDP_SERVER_URL")},
// Username: os.Getenv("TURN_SERVER_USERNAME"),
// Credential: os.Getenv("TURN_SERVER_PASSWORD"),
// CredentialType: webrtc.ICECredentialTypePassword,
// },
// {
// URLs: []string{os.Getenv("TURN_TCP_SERVER_URL")},
// Username: os.Getenv("TURN_SERVER_USERNAME"),
// Credential: os.Getenv("TURN_SERVER_PASSWORD"),
// CredentialType: webrtc.ICECredentialTypePassword,
// },
// {
// URLs: []string{os.Getenv("TURN_TLS_SERVER_URL")},
// Username: os.Getenv("TURN_SERVER_USERNAME"),
// Credential: os.Getenv("TURN_SERVER_PASSWORD"),
// CredentialType: webrtc.ICECredentialTypePassword,
// },
},
ICETransportPolicy: webrtc.ICETransportPolicyAll,
BundlePolicy: webrtc.BundlePolicyMaxCompat,
RTCPMuxPolicy: webrtc.RTCPMuxPolicyRequire,
SDPSemantics: webrtc.SDPSemanticsUnifiedPlan,
}
}

17
signal.go Normal file
View File

@@ -0,0 +1,17 @@
package client
const (
FieldOffer = "offer"
FieldAnswer = "answer"
FieldSDP = "sdp"
FieldUpdatedAt = "updated-at"
FieldStatus = "status"
FieldStatusPending = "pending"
FieldStatusConnected = "connected"
FieldCreatedAt = "created-at"
)
type BaseSignal interface {
Connect(string, string) error
Close() error
}

10
stream.sdp Normal file
View File

@@ -0,0 +1,10 @@
v=0
o=- 0 0 IN IP4 127.0.0.1
s=test-video
c=IN IP4 127.0.0.1
t=0 0
m=video 4002 RTP/AVP 96
a=rtpmap:96 H264/90000
a=x-dimensions:1920,1080
a=x-framerate:25
a=maxptime:40