From 4a0788ff70dc9582c6fcfd5e6bd851714b86223e Mon Sep 17 00:00:00 2001 From: harshabose Date: Sun, 13 Jul 2025 22:30:33 +0530 Subject: [PATCH] Initial clean commit --- .gitignore | 50 +++ bwestimator.go | 177 +++++++++ client.go | 149 ++++++++ client_cgo.go | 35 ++ client_constants.go | 52 +++ client_options.go | 248 +++++++++++++ config.go | 469 ++++++++++++++++++++++++ config_cgo.go | 18 + file_answer.go | 180 +++++++++ file_offer.go | 181 +++++++++ firebase_answer.go | 130 +++++++ firebase_config.go | 49 +++ firebase_offer.go | 143 ++++++++ go.mod | 76 ++++ go.sum | 171 +++++++++ peerconnection.go | 158 ++++++++ peerconnection_!cgo.go | 6 + peerconnection_cgo.go | 13 + peerconnection_options.go | 71 ++++ peerconnection_options_cgo.go | 10 + pkg/datachannel/datachannel.go | 139 +++++++ pkg/datachannel/options.go | 12 + pkg/mediasink/options.go | 56 +++ pkg/mediasink/sinks.go | 187 ++++++++++ pkg/mediasource/constants.go | 46 +++ pkg/mediasource/options.go | 58 +++ pkg/mediasource/track.go | 73 ++++ pkg/mediasource/tracks.go | 44 +++ pkg/transcode/decoder.go | 229 ++++++++++++ pkg/transcode/decoder_options.go | 63 ++++ pkg/transcode/demuxer.go | 171 +++++++++ pkg/transcode/demuxer_options.go | 98 +++++ pkg/transcode/encoder.go | 226 ++++++++++++ pkg/transcode/encoder_builder.go | 103 ++++++ pkg/transcode/encoder_options.go | 378 +++++++++++++++++++ pkg/transcode/errors.go | 29 ++ pkg/transcode/filter.go | 304 +++++++++++++++ pkg/transcode/filter_options.go | 304 +++++++++++++++ pkg/transcode/interfaces.go | 165 +++++++++ pkg/transcode/multi_encoder.go | 315 ++++++++++++++++ pkg/transcode/notch_updates.go | 247 +++++++++++++ pkg/transcode/transcoder.go | 109 ++++++ pkg/transcode/transcoder_options.go | 87 +++++ pkg/transcode/update_encoder_wrapper.go | 268 ++++++++++++++ pkg/transcode/x264options.go | 143 ++++++++ rtc_config.go | 39 ++ signal.go | 17 + stream.sdp | 10 + 48 files changed, 6306 insertions(+) create mode 100644 .gitignore create mode 100644 bwestimator.go create mode 100644 client.go create mode 100644 client_cgo.go create mode 100644 client_constants.go create mode 100644 client_options.go create mode 100644 config.go create mode 100644 config_cgo.go create mode 100644 file_answer.go create mode 100644 file_offer.go create mode 100644 firebase_answer.go create mode 100644 firebase_config.go create mode 100644 firebase_offer.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 peerconnection.go create mode 100644 peerconnection_!cgo.go create mode 100644 peerconnection_cgo.go create mode 100644 peerconnection_options.go create mode 100644 peerconnection_options_cgo.go create mode 100644 pkg/datachannel/datachannel.go create mode 100644 pkg/datachannel/options.go create mode 100644 pkg/mediasink/options.go create mode 100644 pkg/mediasink/sinks.go create mode 100644 pkg/mediasource/constants.go create mode 100644 pkg/mediasource/options.go create mode 100644 pkg/mediasource/track.go create mode 100644 pkg/mediasource/tracks.go create mode 100644 pkg/transcode/decoder.go create mode 100644 pkg/transcode/decoder_options.go create mode 100644 pkg/transcode/demuxer.go create mode 100644 pkg/transcode/demuxer_options.go create mode 100644 pkg/transcode/encoder.go create mode 100644 pkg/transcode/encoder_builder.go create mode 100644 pkg/transcode/encoder_options.go create mode 100644 pkg/transcode/errors.go create mode 100644 pkg/transcode/filter.go create mode 100644 pkg/transcode/filter_options.go create mode 100644 pkg/transcode/interfaces.go create mode 100644 pkg/transcode/multi_encoder.go create mode 100644 pkg/transcode/notch_updates.go create mode 100644 pkg/transcode/transcoder.go create mode 100644 pkg/transcode/transcoder_options.go create mode 100644 pkg/transcode/update_encoder_wrapper.go create mode 100644 pkg/transcode/x264options.go create mode 100644 rtc_config.go create mode 100644 signal.go create mode 100644 stream.sdp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7a288b3 --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/bwestimator.go b/bwestimator.go new file mode 100644 index 0000000..a15b9c2 --- /dev/null +++ b/bwestimator.go @@ -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") +} diff --git a/client.go b/client.go new file mode 100644 index 0000000..8cf5c4b --- /dev/null +++ b/client.go @@ -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 +} diff --git a/client_cgo.go b/client_cgo.go new file mode 100644 index 0000000..2592e3b --- /dev/null +++ b/client_cgo.go @@ -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 +} diff --git a/client_constants.go b/client_constants.go new file mode 100644 index 0000000..cc8bcc7 --- /dev/null +++ b/client_constants.go @@ -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) +) diff --git a/client_options.go b/client_options.go new file mode 100644 index 0000000..169c4da --- /dev/null +++ b/client_options.go @@ -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) + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..8e2ade9 --- /dev/null +++ b/config.go @@ -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 +} diff --git a/config_cgo.go b/config_cgo.go new file mode 100644 index 0000000..3bf33ae --- /dev/null +++ b/config_cgo.go @@ -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 +// } diff --git a/file_answer.go b/file_answer.go new file mode 100644 index 0000000..a33dee3 --- /dev/null +++ b/file_answer.go @@ -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 +} diff --git a/file_offer.go b/file_offer.go new file mode 100644 index 0000000..3da4a96 --- /dev/null +++ b/file_offer.go @@ -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 +} diff --git a/firebase_answer.go b/firebase_answer.go new file mode 100644 index 0000000..9cc70f5 --- /dev/null +++ b/firebase_answer.go @@ -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() +} diff --git a/firebase_config.go b/firebase_config.go new file mode 100644 index 0000000..e964e8a --- /dev/null +++ b/firebase_config.go @@ -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 +} diff --git a/firebase_offer.go b/firebase_offer.go new file mode 100644 index 0000000..d17dd35 --- /dev/null +++ b/firebase_offer.go @@ -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() +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bc611c7 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c5c85be --- /dev/null +++ b/go.sum @@ -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= diff --git a/peerconnection.go b/peerconnection.go new file mode 100644 index 0000000..dde8bcf --- /dev/null +++ b/peerconnection.go @@ -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() +} diff --git a/peerconnection_!cgo.go b/peerconnection_!cgo.go new file mode 100644 index 0000000..d80a245 --- /dev/null +++ b/peerconnection_!cgo.go @@ -0,0 +1,6 @@ +//go:build !cgo_enabled + +package client + +// BWEController dummy when BWE is disabled +type BWEController struct{} diff --git a/peerconnection_cgo.go b/peerconnection_cgo.go new file mode 100644 index 0000000..8ffa066 --- /dev/null +++ b/peerconnection_cgo.go @@ -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 +} diff --git a/peerconnection_options.go b/peerconnection_options.go new file mode 100644 index 0000000..d5b9f57 --- /dev/null +++ b/peerconnection_options.go @@ -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 + } +} diff --git a/peerconnection_options_cgo.go b/peerconnection_options_cgo.go new file mode 100644 index 0000000..5050d67 --- /dev/null +++ b/peerconnection_options_cgo.go @@ -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 + } +} diff --git a/pkg/datachannel/datachannel.go b/pkg/datachannel/datachannel.go new file mode 100644 index 0000000..f65c863 --- /dev/null +++ b/pkg/datachannel/datachannel.go @@ -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 +} diff --git a/pkg/datachannel/options.go b/pkg/datachannel/options.go new file mode 100644 index 0000000..94e91bd --- /dev/null +++ b/pkg/datachannel/options.go @@ -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 + } +} diff --git a/pkg/mediasink/options.go b/pkg/mediasink/options.go new file mode 100644 index 0000000..c87d50b --- /dev/null +++ b/pkg/mediasink/options.go @@ -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 + } +} diff --git a/pkg/mediasink/sinks.go b/pkg/mediasink/sinks.go new file mode 100644 index 0000000..dd15c00 --- /dev/null +++ b/pkg/mediasink/sinks.go @@ -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 +} diff --git a/pkg/mediasource/constants.go b/pkg/mediasource/constants.go new file mode 100644 index 0000000..b1bdfc3 --- /dev/null +++ b/pkg/mediasource/constants.go @@ -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 +) diff --git a/pkg/mediasource/options.go b/pkg/mediasource/options.go new file mode 100644 index 0000000..c3c75e3 --- /dev/null +++ b/pkg/mediasource/options.go @@ -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 + } +} diff --git a/pkg/mediasource/track.go b/pkg/mediasource/track.go new file mode 100644 index 0000000..577daa4 --- /dev/null +++ b/pkg/mediasource/track.go @@ -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 +} diff --git a/pkg/mediasource/tracks.go b/pkg/mediasource/tracks.go new file mode 100644 index 0000000..aac9f6b --- /dev/null +++ b/pkg/mediasource/tracks.go @@ -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 +} diff --git a/pkg/transcode/decoder.go b/pkg/transcode/decoder.go new file mode 100644 index 0000000..2c8047a --- /dev/null +++ b/pkg/transcode/decoder.go @@ -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() +} diff --git a/pkg/transcode/decoder_options.go b/pkg/transcode/decoder_options.go new file mode 100644 index 0000000..5770566 --- /dev/null +++ b/pkg/transcode/decoder_options.go @@ -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 + } +} diff --git a/pkg/transcode/demuxer.go b/pkg/transcode/demuxer.go new file mode 100644 index 0000000..5c7d93e --- /dev/null +++ b/pkg/transcode/demuxer.go @@ -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() +} diff --git a/pkg/transcode/demuxer_options.go b/pkg/transcode/demuxer_options.go new file mode 100644 index 0000000..cb59e86 --- /dev/null +++ b/pkg/transcode/demuxer_options.go @@ -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 + } +} diff --git a/pkg/transcode/encoder.go b/pkg/transcode/encoder.go new file mode 100644 index 0000000..d7f9dfc --- /dev/null +++ b/pkg/transcode/encoder.go @@ -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() +} diff --git a/pkg/transcode/encoder_builder.go b/pkg/transcode/encoder_builder.go new file mode 100644 index 0000000..d658527 --- /dev/null +++ b/pkg/transcode/encoder_builder.go @@ -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() +} diff --git a/pkg/transcode/encoder_options.go b/pkg/transcode/encoder_options.go new file mode 100644 index 0000000..e62fc32 --- /dev/null +++ b/pkg/transcode/encoder_options.go @@ -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 +// } diff --git a/pkg/transcode/errors.go b/pkg/transcode/errors.go new file mode 100644 index 0000000..5144328 --- /dev/null +++ b/pkg/transcode/errors.go @@ -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") +) diff --git a/pkg/transcode/filter.go b/pkg/transcode/filter.go new file mode 100644 index 0000000..0de4d53 --- /dev/null +++ b/pkg/transcode/filter.go @@ -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() +} diff --git a/pkg/transcode/filter_options.go b/pkg/transcode/filter_options.go new file mode 100644 index 0000000..71fd2e4 --- /dev/null +++ b/pkg/transcode/filter_options.go @@ -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 + } +} diff --git a/pkg/transcode/interfaces.go b/pkg/transcode/interfaces.go new file mode 100644 index 0000000..53fa45c --- /dev/null +++ b/pkg/transcode/interfaces.go @@ -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 +} diff --git a/pkg/transcode/multi_encoder.go b/pkg/transcode/multi_encoder.go new file mode 100644 index 0000000..78dc727 --- /dev/null +++ b/pkg/transcode/multi_encoder.go @@ -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() { + +} diff --git a/pkg/transcode/notch_updates.go b/pkg/transcode/notch_updates.go new file mode 100644 index 0000000..61093f9 --- /dev/null +++ b/pkg/transcode/notch_updates.go @@ -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 := ¬ch{ +// 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 +// } diff --git a/pkg/transcode/transcoder.go b/pkg/transcode/transcoder.go new file mode 100644 index 0000000..8651445 --- /dev/null +++ b/pkg/transcode/transcoder.go @@ -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 +} diff --git a/pkg/transcode/transcoder_options.go b/pkg/transcode/transcoder_options.go new file mode 100644 index 0000000..bfa5f19 --- /dev/null +++ b/pkg/transcode/transcoder_options.go @@ -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 + } +} diff --git a/pkg/transcode/update_encoder_wrapper.go b/pkg/transcode/update_encoder_wrapper.go new file mode 100644 index 0000000..160d5fa --- /dev/null +++ b/pkg/transcode/update_encoder_wrapper.go @@ -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) + } + } +} diff --git a/pkg/transcode/x264options.go b/pkg/transcode/x264options.go new file mode 100644 index 0000000..3ff253d --- /dev/null +++ b/pkg/transcode/x264options.go @@ -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", + }, +} diff --git a/rtc_config.go b/rtc_config.go new file mode 100644 index 0000000..4bcc19b --- /dev/null +++ b/rtc_config.go @@ -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, + } +} diff --git a/signal.go b/signal.go new file mode 100644 index 0000000..4dcb53e --- /dev/null +++ b/signal.go @@ -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 +} diff --git a/stream.sdp b/stream.sdp new file mode 100644 index 0000000..a8dfac3 --- /dev/null +++ b/stream.sdp @@ -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 \ No newline at end of file