snapshot of https://github.com/pion/webrtc.git (main, 2026-09-15; blobs > 131072B omitted)
Some checks failed
REUSE Compliance Check / lint (push) Failing after 0s
Browser E2E / Test (push) Failing after 3s
Test / test (1.24) (push) Failing after 0s
Test / test-i386 (1.25) (push) Failing after 0s
Test / test-i386 (1.24) (push) Failing after 0s
Test / test-windows (1.25) (push) Failing after 0s
Test / test-windows (1.24) (push) Failing after 0s
Test / test (1.25) (push) Failing after 0s
Examples Tests / Test (push) Failing after 3s
Test / test-macos (1.25) (push) Failing after 0s
Test / test-macos (1.24) (push) Failing after 0s
Go mod tidy / tidy (push) Failing after 0s
Test / test-wasm (push) Failing after 0s
CodeQL / analyze (push) Failing after 1s
Fuzz / fuzz (push) Failing after 0s

This commit is contained in:
upstream-mirror
2026-09-15 20:06:07 +05:00
commit 8acb1a3a29
394 changed files with 78746 additions and 0 deletions

70
examples/README.md Normal file
View File

@@ -0,0 +1,70 @@
<h1 align="center">
Examples
</h1>
We've built an extensive collection of examples covering common use-cases. You can modify and extend these examples to get started quickly.
For more full featured examples that use 3rd party libraries see our **[example-webrtc-applications](https://github.com/pion/example-webrtc-applications)** repo.
### Overview
#### Media API
* [Reflect](reflect): The reflect example demonstrates how to have Pion send back to the user exactly what it receives using the same PeerConnection.
* [Play from Disk](play-from-disk): The play-from-disk example demonstrates how to send video to your browser from a file saved to disk.
* [Play from Disk Renegotiation](play-from-disk-renegotiation): The play-from-disk-renegotiation example is an extension of the play-from-disk example, but demonstrates how you can add/remove video tracks from an already negotiated PeerConnection.
* [Insertable Streams](insertable-streams): The insertable-streams example demonstrates how Pion can be used to send E2E encrypted video and decrypt via insertable streams in the browser.
* [Save to Disk](save-to-disk): The save-to-disk example shows how to record your webcam and save the footage to disk on the server side.
* [Broadcast](broadcast): The broadcast example demonstrates how to broadcast a video to multiple peers. A broadcaster uploads the video once and the server forwards it to all other peers.
* [RTP Forwarder](rtp-forwarder): The rtp-forwarder example demonstrates how to forward your audio/video streams using RTP.
* [RTP to WebRTC](rtp-to-webrtc): The rtp-to-webrtc example demonstrates how to take RTP packets sent to a Pion process into your browser.
* [Simulcast](simulcast): The simulcast example demonstrates how to accept and demux 1 Track that contains 3 Simulcast streams. It then returns the media as 3 independent Tracks back to the sender.
* [Swap Tracks](swap-tracks): The swap-tracks example demonstrates deeper usage of the Pion Media API. The server accepts 3 media streams, and then dynamically routes them back as a single stream to the user.
* [RTCP Processing](rtcp-processing) The rtcp-processing example demonstrates Pion's RTCP APIs. This allow access to media statistics and control information.
* [Quick Switch](quick-switch) Use WebRTC to switch quickly between video feeds. Similiar to swap-tracks, but user controls when video is switched and uses static files.
#### Data Channel API
* [Data Channels](data-channels): The data-channels example shows how you can send/recv DataChannel messages from a web browser.
* [Data Channels Detach](data-channels-detach): The data-channels-detach example shows how you can send/recv DataChannel messages using the underlying DataChannel implementation directly. This provides a more idiomatic way of interacting with Data Channels.
* [Data Channels Flow Control](data-channels-flow-control): Example data-channels-flow-control shows how to use the DataChannel API efficiently. You can measure the amount the rate at which the remote peer is receiving data, and structure your application accordingly.
* [ORTC](ortc): Example ortc shows how you an use the ORTC API for DataChannel communication.
* [Pion to Pion](pion-to-pion): Example pion-to-pion is an example of two pion instances communicating directly! It therefore has no corresponding web page.
#### Miscellaneous
* [Custom Logger](custom-logger) The custom-logger demonstrates how the user can override the logging and process messages instead of printing to stdout. It has no corresponding web page.
* [ICE Restart](ice-restart) Example ice-restart demonstrates how a WebRTC connection can roam between networks. This example restarts ICE in a loop and prints the new addresses it uses each time.
* [ICE Single Port](ice-single-port) Example ice-single-port demonstrates how multiple WebRTC connections can be served from a single port. By default Pion listens on a new port for every PeerConnection. Pion can be configured to use a single port for multiple connections.
* [ICE TCP](ice-tcp) Example ice-tcp demonstrates how a WebRTC connection can be made over TCP instead of UDP. By default Pion only does UDP. Pion can be configured to use a TCP port, and this TCP port can be used for many connections.
* [ICE Proxy](ice-proxy) Example ice-proxy demonstrates how to use a proxy for TURN connections.
* [Trickle ICE](trickle-ice) Example trickle-ice example demonstrates Pion WebRTC's Trickle ICE APIs. This is important to use since it allows ICE Gathering and Connecting to happen concurrently.
* [VNet](vnet) Example vnet demonstrates Pion's network virtualisation library. This example connects two PeerConnections over a virtual network and prints statistics about the data traveling over it.
### Usage
We've made it easy to run the browser based examples on your local machine.
1. Build and run the example server:
``` sh
git clone https://github.com/pion/webrtc.git webrtc
cd pion/webrtc/examples
go run examples.go
```
2. Browse to [localhost](http://localhost) to browse through the examples. Note that you can change the port of the server using the ``--address`` flag:
``` sh
go run examples.go --address localhost:8080
go run examples.go --address :8080 # listen on all available interfaces
```
### WebAssembly
Pion WebRTC can be used when compiled to WebAssembly, also known as WASM. In
this case the library will act as a wrapper around the JavaScript WebRTC API.
This allows you to use WebRTC from Go in both server and browser side code with
little to no changes
Some of our examples have support for WebAssembly. The same examples server documented above can be used to run the WebAssembly examples. However, you have to compile them first. This is done as follows:
1. If the example supports WebAssembly it will contain a `main.go` file under the `jsfiddle` folder.
2. Build this `main.go` file as follows:
```
GOOS=js GOARCH=wasm go build -o demo.wasm
```
3. Start the example server. Refer to the [usage](#usage) section for how you can build the example server.
4. Browse to [localhost](http://localhost). The page should now give you the option to run the example using the WebAssembly binary.

View File

@@ -0,0 +1,48 @@
# bandwidth-estimation-from-disk
bandwidth-estimation-from-disk demonstrates how to use Pion's Bandwidth Estimation APIs.
Pion provides multiple Bandwidth Estimators, but they all satisfy one interface. This interface
emits an int for how much bandwidth is available to send. It is then up to the sender to meet that number.
## Instructions
### Create IVF files named `high.ivf` `med.ivf` and `low.ivf`
```
ffmpeg -i $INPUT_FILE -g 30 -b:v .3M -s 320x240 low.ivf
ffmpeg -i $INPUT_FILE -g 30 -b:v 1M -s 858x480 med.ivf
ffmpeg -i $INPUT_FILE -g 30 -b:v 2.5M -s 1280x720 high.ivf
```
### Download bandwidth-estimation-from-disk
```
go install github.com/pion/webrtc/v4/examples/bandwidth-estimation-from-disk@latest
```
### Open bandwidth-estimation-from-disk example page
[jsfiddle.net](https://jsfiddle.net/a1cz42op/) you should see two text-areas, 'Start Session' button and 'Copy browser SessionDescription to clipboard'
### Run bandwidth-estimation-from-disk with your browsers Session Description as stdin
The `output.ivf` you created should be in the same directory as `bandwidth-estimation-from-disk`. In the jsfiddle press 'Copy browser Session Description to clipboard' or copy the base64 string manually.
Now use this value you just copied as the input to `bandwidth-estimation-from-disk`
#### Linux/macOS
Run `echo $BROWSER_SDP | bandwidth-estimation-from-disk`
#### Windows
1. Paste the SessionDescription into a file.
1. Run `bandwidth-estimation-from-disk < my_file`
### Input bandwidth-estimation-from-disk's Session Description into your browser
Copy the text that `bandwidth-estimation-from-disk` just emitted and copy into the second text area in the jsfiddle
### Hit 'Start Session' in jsfiddle, enjoy your video!
A video should start playing in your browser above the input boxes. When `bandwidth-estimation-from-disk` switches quality levels it will print the old and new file like so.
```
Switching from low.ivf to med.ivf
Switching from med.ivf to high.ivf
Switching from high.ivf to med.ivf
```
Congrats, you have used Pion WebRTC! Now start building something cool

View File

@@ -0,0 +1,313 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
// bandwidth-estimation-from-disk demonstrates how to use Pion's Bandwidth Estimation APIs.
package main
import (
"bufio"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/pion/interceptor"
"github.com/pion/interceptor/pkg/cc"
"github.com/pion/interceptor/pkg/gcc"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
"github.com/pion/webrtc/v4/pkg/media/ivfreader"
)
const (
lowFile = "low.ivf"
lowBitrate = 300_000
medFile = "med.ivf"
medBitrate = 1_000_000
highFile = "high.ivf"
highBitrate = 2_500_000
ivfHeaderSize = 32
)
func main() { //nolint:gocognit,cyclop,maintidx
qualityLevels := []struct {
fileName string
bitrate int
}{
{lowFile, lowBitrate},
{medFile, medBitrate},
{highFile, highBitrate},
}
currentQuality := 0
for _, level := range qualityLevels {
_, err := os.Stat(level.fileName)
if os.IsNotExist(err) {
panic(fmt.Sprintf("File %s was not found", level.fileName))
}
}
interceptorRegistry := &interceptor.Registry{}
mediaEngine := &webrtc.MediaEngine{}
if err := mediaEngine.RegisterDefaultCodecs(); err != nil {
panic(err)
}
// Create a Congestion Controller. This analyzes inbound and outbound data and provides
// suggestions on how much we should be sending.
//
// Passing `nil` means we use the default Estimation Algorithm which is Google Congestion Control.
// You can use the other ones that Pion provides, or write your own!
congestionController, err := cc.NewInterceptor(func() (cc.BandwidthEstimator, error) {
return gcc.NewSendSideBWE(gcc.SendSideBWEInitialBitrate(lowBitrate))
})
if err != nil {
panic(err)
}
estimatorChan := make(chan cc.BandwidthEstimator, 1)
congestionController.OnNewPeerConnection(func(id string, estimator cc.BandwidthEstimator) { //nolint: revive
estimatorChan <- estimator
})
interceptorRegistry.Add(congestionController)
if err = webrtc.ConfigureTWCCHeaderExtensionSender(mediaEngine, interceptorRegistry); err != nil {
panic(err)
}
if err = webrtc.RegisterDefaultInterceptors(mediaEngine, interceptorRegistry); err != nil {
panic(err)
}
// Create a new RTCPeerConnection
peerConnection, err := webrtc.NewAPI(
webrtc.WithInterceptorRegistry(interceptorRegistry), webrtc.WithMediaEngine(mediaEngine),
).NewPeerConnection(webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
})
if err != nil {
panic(err)
}
defer func() {
if cErr := peerConnection.Close(); cErr != nil {
fmt.Printf("cannot close peerConnection: %v\n", cErr)
}
}()
// Wait until our Bandwidth Estimator has been created
estimator := <-estimatorChan
// Create a video track
videoTrack, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8}, "video", "pion",
)
if err != nil {
panic(err)
}
rtpSender, err := peerConnection.AddTrack(videoTrack)
if err != nil {
panic(err)
}
// Read incoming RTCP packets
// Before these packets are returned they are processed by interceptors. For things
// like NACK this needs to be called.
go func() {
rtcpBuf := make([]byte, 1500)
for {
if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
return
}
}
}()
// Set the handler for ICE connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
fmt.Printf("Connection State has changed %s \n", connectionState.String())
})
// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
})
// Wait for the offer to be pasted
offer := webrtc.SessionDescription{}
decode(readUntilNewline(), &offer)
// Set the remote SessionDescription
if err = peerConnection.SetRemoteDescription(offer); err != nil {
panic(err)
}
// Create answer
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
}
// Create channel that is blocked until ICE Gathering is complete
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
// Sets the LocalDescription, and starts our UDP listeners
if err = peerConnection.SetLocalDescription(answer); err != nil {
panic(err)
}
// Block until ICE Gathering is complete, disabling trickle ICE
// we do this because we only can exchange one signaling message
// in a production application you should exchange ICE Candidates via OnICECandidate
<-gatherComplete
// Output the answer in base64 so we can paste it in browser
fmt.Println(encode(peerConnection.LocalDescription()))
// Open a IVF file and start reading using our IVFReader
file, err := os.Open(qualityLevels[currentQuality].fileName)
if err != nil {
panic(err)
}
ivf, header, err := ivfreader.NewWith(file)
if err != nil {
panic(err)
}
// Send our video file frame at a time. Pace our sending so we send it at the same speed it should be played back as.
// This isn't required since the video is timestamped, but we will such much higher loss if we send all at once.
//
// It is important to use a time.Ticker instead of time.Sleep because
// * avoids accumulating skew, just calling time.Sleep didn't compensate for the time spent parsing the data
// * works around latency issues with Sleep (see https://github.com/golang/go/issues/44343)
ticker := time.NewTicker(
time.Millisecond * time.Duration((float32(header.TimebaseNumerator)/float32(header.TimebaseDenominator))*1000),
)
defer ticker.Stop()
frame := []byte{}
frameHeader := &ivfreader.IVFFrameHeader{}
currentTimestamp := uint64(0)
switchQualityLevel := func(newQualityLevel int) {
fmt.Printf(
"Switching from %s to %s \n",
qualityLevels[currentQuality].fileName,
qualityLevels[newQualityLevel].fileName,
)
currentQuality = newQualityLevel
ivf.ResetReader(setReaderFile(qualityLevels[currentQuality].fileName))
for {
if frame, frameHeader, err = ivf.ParseNextFrame(); err != nil {
break
} else if frameHeader.Timestamp >= currentTimestamp && frame[0]&0x1 == 0 {
break
}
}
}
for ; true; <-ticker.C {
targetBitrate := estimator.GetTargetBitrate()
switch {
// If current quality level is below target bitrate drop to level below
case currentQuality != 0 && targetBitrate < qualityLevels[currentQuality].bitrate:
switchQualityLevel(currentQuality - 1)
// If next quality level is above target bitrate move to next level
case len(qualityLevels) > (currentQuality+1) && targetBitrate > qualityLevels[currentQuality+1].bitrate:
switchQualityLevel(currentQuality + 1)
// Adjust outbound bandwidth for probing
default:
frame, frameHeader, err = ivf.ParseNextFrame()
}
switch {
// If we have reached the end of the file start again
case errors.Is(err, io.EOF):
ivf.ResetReader(setReaderFile(qualityLevels[currentQuality].fileName))
// No error write the video frame
case err == nil:
currentTimestamp = frameHeader.Timestamp
if err = videoTrack.WriteSample(media.Sample{Data: frame, Duration: time.Second}); err != nil {
panic(err)
}
// Error besides io.EOF that we dont know how to handle
default:
panic(err)
}
}
}
func setReaderFile(filename string) func(_ int64) io.Reader {
return func(_ int64) io.Reader {
file, err := os.Open(filename) // nolint
if err != nil {
panic(err)
}
if _, err = file.Seek(ivfHeaderSize, io.SeekStart); err != nil {
panic(err)
}
return file
}
}
// Read from stdin until we get a newline.
func readUntilNewline() (in string) {
var err error
r := bufio.NewReader(os.Stdin)
for {
in, err = r.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
panic(err)
}
if in = strings.TrimSpace(in); len(in) > 0 {
break
}
}
fmt.Println("")
return
}
// JSON encode + base64 a SessionDescription.
func encode(obj *webrtc.SessionDescription) string {
b, err := json.Marshal(obj)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(b)
}
// Decode a base64 and unmarshal JSON into a SessionDescription.
func decode(in string, obj *webrtc.SessionDescription) {
b, err := base64.StdEncoding.DecodeString(in)
if err != nil {
panic(err)
}
if err = json.Unmarshal(b, obj); err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,40 @@
# broadcast
broadcast is a Pion WebRTC application that demonstrates how to broadcast a video to many peers, while only requiring the broadcaster to upload once.
This could serve as the building block to building conferencing software, and other applications where publishers are bandwidth constrained.
## Instructions
### Download broadcast
```
go install github.com/pion/webrtc/v4/examples/broadcast@latest
```
### Open broadcast example page
[jsfiddle.net](https://jsfiddle.net/us4h58jx/) You should see two buttons `Publish a Broadcast` and `Join a Broadcast`
### Run Broadcast
#### Linux/macOS
Run `broadcast` OR run `main.go` in `github.com/pion/webrtc/examples/broadcast`
### Start a publisher
* Click `Publish a Broadcast`
* Press `Copy browser SDP to clipboard` or copy the `Browser base64 Session Description` string manually
* Run `curl localhost:8080 -d "$BROWSER_OFFER"`. `$BROWSER_OFFER` is the value you copied in the last step.
* The `broadcast` terminal application will respond with an answer, paste this into the second input field in your browser.
* Press `Start Session`
* The connection state will be printed in the terminal and under `logs` in the browser.
### Join the broadcast
* Click `Join a Broadcast`
* Copy the string in the first input labelled `Browser base64 Session Description`
* Run `curl localhost:8080 -d "$BROWSER_OFFER"`. `$BROWSER_OFFER` is the value you copied in the last step.
* The `broadcast` terminal application will respond with an answer, paste this into the second input field in your browser.
* Press `Start Session`
* The connection state will be printed in the terminal and under `logs` in the browser.
You can change the listening port using `-port 8011`
You can `Join the broadcast` as many times as you want. The `broadcast` Golang application is relaying all traffic, so your browser only has to upload once.
Congrats, you have used Pion WebRTC! Now start building something cool

View File

@@ -0,0 +1,8 @@
/*
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
*/
textarea {
width: 500px;
min-height: 75px;
}

View File

@@ -0,0 +1,8 @@
---
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
name: broadcast
description: Example of a broadcast using Pion WebRTC
authors:
- Sean DuBois

View File

@@ -0,0 +1,30 @@
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
<div id="signalingContainer" style="display: none">
Browser base64 Session Description<br />
<textarea id="localSessionDescription" readonly="true"></textarea> <br />
<button onclick="window.copySDP()">
Copy browser SDP to clipboard
</button>
<br />
<br />
Golang base64 Session Description<br />
<textarea id="remoteSessionDescription"></textarea> <br/>
<button onclick="window.startSession()"> Start Session </button><br />
</div>
<br />
Video<br />
<video id="video1" width="160" height="120" autoplay muted></video> <br />
<button class="createSessionButton" onclick="window.createSession(true)"> Publish a Broadcast </button>
<button class="createSessionButton" onclick="window.createSession(false)"> Join a Broadcast </button><br />
<br />
Logs<br />
<div id="logs"></div>

View File

@@ -0,0 +1,82 @@
/* eslint-env browser */
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
const log = msg => {
document.getElementById('logs').innerHTML += msg + '<br>'
}
window.createSession = isPublisher => {
const pc = new RTCPeerConnection({
iceServers: [
{
urls: 'stun:stun.l.google.com:19302'
}
]
})
pc.oniceconnectionstatechange = e => log(pc.iceConnectionState)
pc.onicecandidate = event => {
if (event.candidate === null) {
document.getElementById('localSessionDescription').value = btoa(JSON.stringify(pc.localDescription))
}
}
if (isPublisher) {
navigator.mediaDevices.getUserMedia({ video: true, audio: false })
.then(stream => {
stream.getTracks().forEach(track => pc.addTrack(track, stream))
document.getElementById('video1').srcObject = stream
pc.createOffer()
.then(d => pc.setLocalDescription(d))
.catch(log)
}).catch(log)
} else {
pc.addTransceiver('video')
pc.createOffer()
.then(d => pc.setLocalDescription(d))
.catch(log)
pc.ontrack = function (event) {
const el = document.getElementById('video1')
el.srcObject = event.streams[0]
el.autoplay = true
el.controls = true
}
}
window.startSession = () => {
const sd = document.getElementById('remoteSessionDescription').value
if (sd === '') {
return alert('Session Description must not be empty')
}
try {
pc.setRemoteDescription(JSON.parse(atob(sd)))
} catch (e) {
alert(e)
}
}
window.copySDP = () => {
const browserSDP = document.getElementById('localSessionDescription')
browserSDP.focus()
browserSDP.select()
try {
const successful = document.execCommand('copy')
const msg = successful ? 'successful' : 'unsuccessful'
log('Copying SDP was ' + msg)
} catch (err) {
log('Unable to copy SDP ' + err)
}
}
const btns = document.getElementsByClassName('createSessionButton')
for (let i = 0; i < btns.length; i++) {
btns[i].style = 'display: none'
}
document.getElementById('signalingContainer').style = 'display: block'
}

242
examples/broadcast/main.go Normal file
View File

@@ -0,0 +1,242 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
// broadcast demonstrates how to broadcast a video to many peers, while only requiring the broadcaster to upload once.
package main
import (
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"strconv"
"github.com/pion/interceptor"
"github.com/pion/interceptor/pkg/intervalpli"
"github.com/pion/webrtc/v4"
)
// nolint:gocognit, cyclop
func main() {
port := flag.Int("port", 8080, "http server port")
flag.Parse()
sdpChan := httpSDPServer(*port)
// Everything below is the Pion WebRTC API, thanks for using it ❤️.
offer := webrtc.SessionDescription{}
decode(<-sdpChan, &offer)
fmt.Println("")
peerConnectionConfig := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
}
mediaEngine := &webrtc.MediaEngine{}
if err := mediaEngine.RegisterDefaultCodecs(); err != nil {
panic(err)
}
// Create a InterceptorRegistry. This is the user configurable RTP/RTCP Pipeline.
// This provides NACKs, RTCP Reports and other features. If you use `webrtc.NewPeerConnection`
// this is enabled by default. If you are manually managing You MUST create a InterceptorRegistry
// for each PeerConnection.
interceptorRegistry := &interceptor.Registry{}
// Use the default set of Interceptors
if err := webrtc.RegisterDefaultInterceptors(mediaEngine, interceptorRegistry); err != nil {
panic(err)
}
// Register a intervalpli factory
// This interceptor sends a PLI every 3 seconds. A PLI causes a video keyframe to be generated by the sender.
// This makes our video seekable and more error resilent, but at a cost of lower picture quality and higher bitrates
// A real world application should process incoming RTCP packets from viewers and forward them to senders
intervalPliFactory, err := intervalpli.NewReceiverInterceptor()
if err != nil {
panic(err)
}
interceptorRegistry.Add(intervalPliFactory)
// Create a new RTCPeerConnection
peerConnection, err := webrtc.NewAPI(
webrtc.WithMediaEngine(mediaEngine),
webrtc.WithInterceptorRegistry(interceptorRegistry),
).NewPeerConnection(peerConnectionConfig)
if err != nil {
panic(err)
}
defer func() {
if cErr := peerConnection.Close(); cErr != nil {
fmt.Printf("cannot close peerConnection: %v\n", cErr)
}
}()
// Allow us to receive 1 video track
if _, err = peerConnection.AddTransceiverFromKind(webrtc.RTPCodecTypeVideo); err != nil {
panic(err)
}
localTrackChan := make(chan *webrtc.TrackLocalStaticRTP)
// Set a handler for when a new remote track starts, this just distributes all our packets
// to connected peers
peerConnection.OnTrack(func(remoteTrack *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { //nolint: revive
// Create a local track, all our SFU clients will be fed via this track
localTrack, newTrackErr := webrtc.NewTrackLocalStaticRTP(remoteTrack.Codec().RTPCodecCapability, "video", "pion")
if newTrackErr != nil {
panic(newTrackErr)
}
localTrackChan <- localTrack
rtpBuf := make([]byte, 1400)
for {
i, _, readErr := remoteTrack.Read(rtpBuf)
if readErr != nil {
panic(readErr)
}
// ErrClosedPipe means we don't have any subscribers, this is ok if no peers have connected yet
if _, err = localTrack.Write(rtpBuf[:i]); err != nil && !errors.Is(err, io.ErrClosedPipe) {
panic(err)
}
}
})
// Set the remote SessionDescription
err = peerConnection.SetRemoteDescription(offer)
if err != nil {
panic(err)
}
// Create answer
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
}
// Create channel that is blocked until ICE Gathering is complete
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
// Sets the LocalDescription, and starts our UDP listeners
err = peerConnection.SetLocalDescription(answer)
if err != nil {
panic(err)
}
// Block until ICE Gathering is complete, disabling trickle ICE
// we do this because we only can exchange one signaling message
// in a production application you should exchange ICE Candidates via OnICECandidate
<-gatherComplete
// Get the LocalDescription and take it to base64 so we can paste in browser
fmt.Println(encode(peerConnection.LocalDescription()))
localTrack := <-localTrackChan
for {
fmt.Println("")
fmt.Println("Curl an base64 SDP to start sendonly peer connection")
recvOnlyOffer := webrtc.SessionDescription{}
decode(<-sdpChan, &recvOnlyOffer)
// Create a new PeerConnection
peerConnection, err := webrtc.NewPeerConnection(peerConnectionConfig)
if err != nil {
panic(err)
}
rtpSender, err := peerConnection.AddTrack(localTrack)
if err != nil {
panic(err)
}
// Read incoming RTCP packets
// Before these packets are returned they are processed by interceptors. For things
// like NACK this needs to be called.
go func() {
rtcpBuf := make([]byte, 1500)
for {
if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
return
}
}
}()
// Set the remote SessionDescription
err = peerConnection.SetRemoteDescription(recvOnlyOffer)
if err != nil {
panic(err)
}
// Create answer
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
}
// Create channel that is blocked until ICE Gathering is complete
gatherComplete = webrtc.GatheringCompletePromise(peerConnection)
// Sets the LocalDescription, and starts our UDP listeners
err = peerConnection.SetLocalDescription(answer)
if err != nil {
panic(err)
}
// Block until ICE Gathering is complete, disabling trickle ICE
// we do this because we only can exchange one signaling message
// in a production application you should exchange ICE Candidates via OnICECandidate
<-gatherComplete
// Get the LocalDescription and take it to base64 so we can paste in browser
fmt.Println(encode(peerConnection.LocalDescription()))
}
}
// JSON encode + base64 a SessionDescription.
func encode(obj *webrtc.SessionDescription) string {
b, err := json.Marshal(obj)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(b)
}
// Decode a base64 and unmarshal JSON into a SessionDescription.
func decode(in string, obj *webrtc.SessionDescription) {
b, err := base64.StdEncoding.DecodeString(in)
if err != nil {
panic(err)
}
if err = json.Unmarshal(b, obj); err != nil {
panic(err)
}
}
// httpSDPServer starts a HTTP Server that consumes SDPs.
func httpSDPServer(port int) chan string {
sdpChan := make(chan string)
http.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) {
body, _ := io.ReadAll(req.Body)
fmt.Fprintf(res, "done") //nolint: errcheck
sdpChan <- string(body)
})
go func() {
// nolint: gosec
panic(http.ListenAndServe(":"+strconv.Itoa(port), nil))
}()
return sdpChan
}

View File

@@ -0,0 +1,56 @@
# custom-logger
`custom-logger` is an example demonstrating how to override the default logging behavior of the [Pion WebRTC](https://github.com/pion/webrtc) stack.
By default, Pion logs everything to `stdout`.
This example shows how to inject a **custom `LoggerFactory`** to handle logs from every subsystem (ICE, DTLS, SCTP, DataChannel...).
---
## Features
- Creates a **custom logger** that implements `logging.LeveledLogger`.
- Initializes two peer connections (`offerer` and `answerer`) locally.
- Establishes a WebRTC connection between them.
- Logs events from:
- `ICE` candidate gathering
- `DTLS` handshake
- `SCTP` and `DataChannel` setup
- Prints logs with clear prefixes like `customLogger Debug:`.
Ideal for:
- Integrate with external monitoring systems
- Store logs to files or databases
- Debug complex WebRTC flows in a structured way
---
## How to run
### 1. Install the example
```
go install github.com/pion/webrtc/v4/examples/custom-logger@latest
```
Make sure ```$(go env GOPATH)/bin ``` is in your ```PATH```.
You can add it to your PATH like this (zsh):
```
echo 'export PATH="$PATH:$(go env GOPATH)/bin"' >> ~/.zshrc
source ~/.zshrc
```
### 2.Run
`custom-logger` or `go run main.go`
## Example output
```
Creating logger for ice
Creating logger for dtls
Peer Connection State has changed: connected (answerer)
Peer Connection State has changed: connected (offerer)
customLogger Debug: Adding a new peer-reflexive candidate: 10.8.21.1:51196
```
You should see messages from our customLogger, as two PeerConnections start a session

View File

@@ -0,0 +1,184 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
// custom-logger is an example of how the Pion API provides an customizable logging API
package main
import (
"fmt"
"os"
"github.com/pion/logging"
"github.com/pion/webrtc/v4"
)
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
// customLogger satisfies the interface logging.LeveledLogger
// a logger is created per subsystem in Pion, so you can have custom
// behavior per subsystem (ICE, DTLS, SCTP...)
type customLogger struct{}
// Print all messages except trace.
func (c customLogger) Trace(string) {}
func (c customLogger) Tracef(string, ...any) {}
func (c customLogger) Debug(msg string) { fmt.Printf("customLogger Debug: %s\n", msg) }
func (c customLogger) Debugf(format string, args ...any) {
c.Debug(fmt.Sprintf(format, args...))
}
func (c customLogger) Info(msg string) { fmt.Printf("customLogger Info: %s\n", msg) }
func (c customLogger) Infof(format string, args ...any) {
c.Info(fmt.Sprintf(format, args...))
}
func (c customLogger) Warn(msg string) { fmt.Printf("customLogger Warn: %s\n", msg) }
func (c customLogger) Warnf(format string, args ...any) {
c.Warn(fmt.Sprintf(format, args...))
}
func (c customLogger) Error(msg string) { fmt.Printf("customLogger Error: %s\n", msg) }
func (c customLogger) Errorf(format string, args ...any) {
c.Error(fmt.Sprintf(format, args...))
}
// customLoggerFactory satisfies the interface logging.LoggerFactory
// This allows us to create different loggers per subsystem. So we can
// add custom behavior.
type customLoggerFactory struct{}
func (c customLoggerFactory) NewLogger(subsystem string) logging.LeveledLogger {
fmt.Printf("Creating logger for %s \n", subsystem)
return customLogger{}
}
// nolint: cyclop
func main() {
// Create a new API with a custom logger
// This SettingEngine allows non-standard WebRTC behavior
s := webrtc.SettingEngine{
LoggerFactory: customLoggerFactory{},
}
api := webrtc.NewAPI(webrtc.WithSettingEngine(s))
// Create a new RTCPeerConnection
offerPeerConnection, err := api.NewPeerConnection(webrtc.Configuration{})
if err != nil {
panic(err)
}
defer func() {
if cErr := offerPeerConnection.Close(); cErr != nil {
fmt.Printf("cannot close offerPeerConnection: %v\n", cErr)
}
}()
// We need a DataChannel so we can have ICE Candidates
if _, err = offerPeerConnection.CreateDataChannel("custom-logger", nil); err != nil {
panic(err)
}
// Create a new RTCPeerConnection
answerPeerConnection, err := api.NewPeerConnection(webrtc.Configuration{})
if err != nil {
panic(err)
}
defer func() {
if cErr := answerPeerConnection.Close(); cErr != nil {
fmt.Printf("cannot close answerPeerConnection: %v\n", cErr)
}
}()
// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
offerPeerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s (offerer)\n", state.String())
if state == webrtc.PeerConnectionStateFailed {
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
// It may be reconnected using an ICE Restart.
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
fmt.Println("Peer Connection has gone to failed exiting")
os.Exit(0)
}
if state == webrtc.PeerConnectionStateClosed {
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
fmt.Println("Peer Connection has gone to closed exiting")
os.Exit(0)
}
})
// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
answerPeerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s (answerer)\n", state.String())
if state == webrtc.PeerConnectionStateFailed {
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
// It may be reconnected using an ICE Restart.
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
fmt.Println("Peer Connection has gone to failed exiting")
os.Exit(0)
}
})
// Set ICE Candidate handler. As soon as a PeerConnection has gathered a candidate
// send it to the other peer
answerPeerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate != nil {
if iceErr := offerPeerConnection.AddICECandidate(candidate.ToJSON()); iceErr != nil {
panic(iceErr)
}
}
})
// Set ICE Candidate handler. As soon as a PeerConnection has gathered a candidate
// send it to the other peer
offerPeerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate != nil {
if iceErr := answerPeerConnection.AddICECandidate(candidate.ToJSON()); iceErr != nil {
panic(iceErr)
}
}
})
// Create an offer for the other PeerConnection
offer, err := offerPeerConnection.CreateOffer(nil)
if err != nil {
panic(err)
}
// SetLocalDescription, needed before remote gets offer
if err = offerPeerConnection.SetLocalDescription(offer); err != nil {
panic(err)
}
// Take offer from remote, answerPeerConnection is now able to contact
// the other PeerConnection
if err = answerPeerConnection.SetRemoteDescription(offer); err != nil {
panic(err)
}
// Create an Answer to send back to our originating PeerConnection
answer, err := answerPeerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
}
// Set the answerer's LocalDescription
if err = answerPeerConnection.SetLocalDescription(answer); err != nil {
panic(err)
}
// SetRemoteDescription on original PeerConnection, this finishes our signaling
// bother PeerConnections should be able to communicate with each other now
if err = offerPeerConnection.SetRemoteDescription(answer); err != nil {
panic(err)
}
// Block forever
select {}
}

View File

@@ -0,0 +1,30 @@
# data-channels-detach-create
data-channels-detach is an example that shows how you can detach a data channel. This allows direct access the underlying [pion/datachannel](https://github.com/pion/datachannel). This allows you to interact with the data channel using a more idiomatic API based on the `io.ReadWriteCloser` interface.
The example is meant to be used with data-channels-detach. This demonstrates two Go Pion processes communicating directly.
## Run data-channels-detach-create and make an offer to data-channels-detach via stdin
```
go run data-channels-detach-create/*.go | go run data-channels-detach/*.go
```
## post the answer from data-channels-detach back to data-channels-detach-create
You will see a base64 SDP printed to your console. You now need to communicate this back to `data-channels-detach-create` this can be done via a HTTP endpoint
`curl localhost:8080/sdp -d "BASE_64_SDP"`
## Output
On sucess you will get output like the following
```
Peer Connection State has changed: connecting
(Long base64 SDP that you should POST)
Peer Connection State has changed: connected
New DataChannel 1374394845054
Data channel ''-'1374394845054' open.
Message from DataChannel: kvmWkjYodyQcIlv
Sending aMDnwlTfDYnfoUy
Sending htqQtnbvygZKlmy
Message from DataChannel: CMjZiNtsmIBpCaN
```

View File

@@ -0,0 +1,211 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
// data-channels-detach is an example that shows how you can detach a data channel.
// This allows direct access the underlying [pion/datachannel](https://github.com/pion/datachannel).
// This allows you to interact with the data channel using a more idiomatic API based on
// the `io.ReadWriteCloser` interface.
package main
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
"github.com/pion/randutil"
"github.com/pion/webrtc/v4"
)
const messageSize = 15
func main() {
sdpChan := httpSDPServer(8080)
// Since this behavior diverges from the WebRTC API it has to be
// enabled using a settings engine. Mixing both detached and the
// OnMessage DataChannel API is not supported.
// Create a SettingEngine and enable Detach
s := webrtc.SettingEngine{}
s.DetachDataChannels()
// Create an API object with the engine
api := webrtc.NewAPI(webrtc.WithSettingEngine(s))
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
// Prepare the configuration
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
}
// Create a new RTCPeerConnection using the API object
peerConnection, err := api.NewPeerConnection(config)
if err != nil {
panic(err)
}
defer func() {
if cErr := peerConnection.Close(); cErr != nil {
fmt.Printf("cannot close peerConnection: %v\n", cErr)
}
}()
// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
if state == webrtc.PeerConnectionStateFailed {
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
// It may be reconnected using an ICE Restart.
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
fmt.Println("Peer Connection has gone to failed exiting")
os.Exit(0)
}
if state == webrtc.PeerConnectionStateClosed {
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
fmt.Println("Peer Connection has gone to closed exiting")
os.Exit(0)
}
})
dataChannel, err := peerConnection.CreateDataChannel("", nil)
if err != nil {
panic(err)
}
dataChannel.OnOpen(func() {
fmt.Printf("Data channel '%s'-'%d' open.\n", dataChannel.Label(), dataChannel.ID())
// Detach the data channel
raw, dErr := dataChannel.Detach()
if dErr != nil {
panic(dErr)
}
// Handle reading from the data channel
go ReadLoop(raw)
// Handle writing to the data channel
go WriteLoop(raw)
})
// Create an offer to send to the browser
offer, err := peerConnection.CreateOffer(nil)
if err != nil {
panic(err)
}
// Create channel that is blocked until ICE Gathering is complete
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
// Sets the LocalDescription, and starts our UDP listeners
if err = peerConnection.SetLocalDescription(offer); err != nil {
panic(err)
}
// Block until ICE Gathering is complete, disabling trickle ICE
// we do this because we only can exchange one signaling message
// in a production application you should exchange ICE Candidates via OnICECandidate
<-gatherComplete
// Output the offer in base64 so we can paste it in browser
fmt.Println(encode(peerConnection.LocalDescription()))
// Wait for the answer to be submitted via HTTP
answer := webrtc.SessionDescription{}
decode(<-sdpChan, &answer)
// Set the remote SessionDescription
err = peerConnection.SetRemoteDescription(answer)
if err != nil {
panic(err)
}
// Block forever
select {}
}
// ReadLoop shows how to read from the datachannel directly.
func ReadLoop(d io.Reader) {
for {
buffer := make([]byte, messageSize)
n, err := d.Read(buffer)
if err != nil {
fmt.Println("Datachannel closed; Exit the readloop:", err)
return
}
fmt.Printf("Message from DataChannel: %s\n", string(buffer[:n]))
}
}
// WriteLoop shows how to write to the datachannel directly.
func WriteLoop(d io.Writer) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
message, err := randutil.GenerateCryptoRandomString(
messageSize, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
)
if err != nil {
panic(err)
}
fmt.Printf("Sending %s \n", message)
if _, err := d.Write([]byte(message)); err != nil {
panic(err)
}
}
}
// httpSDPServer starts a HTTP Server that consumes SDPs.
func httpSDPServer(port int) chan string {
sdpChan := make(chan string)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
fmt.Fprintf(w, "done") //nolint: errcheck
sdpChan <- string(body)
})
go func() {
// nolint: gosec
panic(http.ListenAndServe(":"+strconv.Itoa(port), nil))
}()
return sdpChan
}
// JSON encode + base64 a SessionDescription.
func encode(obj *webrtc.SessionDescription) string {
b, err := json.Marshal(obj)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(b)
}
// Decode a base64 and unmarshal JSON into a SessionDescription.
func decode(in string, obj *webrtc.SessionDescription) {
b, err := base64.StdEncoding.DecodeString(in)
if err != nil {
panic(err)
}
if err = json.Unmarshal(b, obj); err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,12 @@
# data-channels-detach
data-channels-detach is an example that shows how you can detach a data channel. This allows direct access the underlying [pion/datachannel](https://github.com/pion/datachannel). This allows you to interact with the data channel using a more idiomatic API based on the `io.ReadWriteCloser` interface.
The example mirrors the data-channels example.
## Install
```
go install github.com/pion/webrtc/v4/examples/data-channels-detach@latest
```
## Usage
The example can be used in the same way as the data-channel example or can be paired with the data-channels-detach-create example. In the latter case; run both example and exchange the offer/answer text by copy-pasting them on the other terminal.

View File

@@ -0,0 +1,8 @@
/*
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
*/
textarea {
width: 500px;
min-height: 75px;
}

View File

@@ -0,0 +1,20 @@
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
Browser base64 Session Description<br />
<textarea id="localSessionDescription" readonly="true"></textarea> <br />
Golang base64 Session Description<br />
<textarea id="remoteSessionDescription"></textarea><br/>
<button onclick="window.startSession()">Start Session</button><br />
<br />
<!--Message<br />
<textarea id="message">This is my DataChannel message!</textarea> <br/>
<button onclick="window.sendMessage()">Send Message</button> <br />-->
<br />
Logs<br />
<div id="logs"></div>

View File

@@ -0,0 +1,231 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build js && wasm
// +build js,wasm
package main
import (
"bufio"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"syscall/js"
"time"
"github.com/pion/randutil"
"github.com/pion/webrtc/v4"
)
const messageSize = 15
func main() {
// Since this behavior diverges from the WebRTC API it has to be
// enabled using a settings engine. Mixing both detached and the
// OnMessage DataChannel API is not supported.
// Create a SettingEngine and enable Detach
s := webrtc.SettingEngine{}
s.DetachDataChannels()
// Create an API object with the engine
api := webrtc.NewAPI(webrtc.WithSettingEngine(s))
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
// Prepare the configuration
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
}
// Create a new RTCPeerConnection using the API object
peerConnection, err := api.NewPeerConnection(config)
if err != nil {
handleError(err)
}
// Create a datachannel with label 'data'
dataChannel, err := peerConnection.CreateDataChannel("data", nil)
if err != nil {
handleError(err)
}
// Set the handler for ICE connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
log(fmt.Sprintf("ICE Connection State has changed: %s\n", connectionState.String()))
})
// Register channel opening handling
dataChannel.OnOpen(func() {
log(fmt.Sprintf("Data channel '%s'-'%d' open.\n", dataChannel.Label(), dataChannel.ID()))
// Detach the data channel
raw, dErr := dataChannel.Detach()
if dErr != nil {
handleError(dErr)
}
// Handle reading from the data channel
go ReadLoop(raw)
// Handle writing to the data channel
go WriteLoop(raw)
})
// Create an offer to send to the browser
offer, err := peerConnection.CreateOffer(nil)
if err != nil {
handleError(err)
}
// Sets the LocalDescription, and starts our UDP listeners
err = peerConnection.SetLocalDescription(offer)
if err != nil {
handleError(err)
}
// Add handlers for setting up the connection.
peerConnection.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
log(fmt.Sprint(state))
})
peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate != nil {
encodedDescr := encode(peerConnection.LocalDescription())
el := getElementByID("localSessionDescription")
el.Set("value", encodedDescr)
}
})
// Set up global callbacks which will be triggered on button clicks.
/*js.Global().Set("sendMessage", js.FuncOf(func(_ js.Value, _ []js.Value) any {
go func() {
el := getElementByID("message")
message := el.Get("value").String()
if message == "" {
js.Global().Call("alert", "Message must not be empty")
return
}
if err := sendChannel.SendText(message); err != nil {
handleError(err)
}
}()
return js.Undefined()
}))*/
js.Global().Set("startSession", js.FuncOf(func(_ js.Value, _ []js.Value) any {
go func() {
el := getElementByID("remoteSessionDescription")
sd := el.Get("value").String()
if sd == "" {
js.Global().Call("alert", "Session Description must not be empty")
return
}
descr := webrtc.SessionDescription{}
decode(sd, &descr)
if err := peerConnection.SetRemoteDescription(descr); err != nil {
handleError(err)
}
}()
return js.Undefined()
}))
// Block forever
select {}
}
// ReadLoop shows how to read from the datachannel directly
func ReadLoop(d io.Reader) {
for {
buffer := make([]byte, messageSize)
n, err := d.Read(buffer)
if err != nil {
log(fmt.Sprintf("Datachannel closed; Exit the readloop: %v", err))
return
}
log(fmt.Sprintf("Message from DataChannel: %s\n", string(buffer[:n])))
}
}
// WriteLoop shows how to write to the datachannel directly
func WriteLoop(d io.Writer) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
message, err := randutil.GenerateCryptoRandomString(messageSize, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
if err != nil {
handleError(err)
}
log(fmt.Sprintf("Sending %s \n", message))
if _, err := d.Write([]byte(message)); err != nil {
handleError(err)
}
}
}
func log(msg string) {
el := getElementByID("logs")
el.Set("innerHTML", el.Get("innerHTML").String()+msg+"<br>")
}
func handleError(err error) {
log("Unexpected error. Check console.")
panic(err)
}
func getElementByID(id string) js.Value {
return js.Global().Get("document").Call("getElementById", id)
}
// Read from stdin until we get a newline
func readUntilNewline() (in string) {
var err error
r := bufio.NewReader(os.Stdin)
for {
in, err = r.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
panic(err)
}
if in = strings.TrimSpace(in); len(in) > 0 {
break
}
}
fmt.Println("")
return
}
// JSON encode + base64 a SessionDescription
func encode(obj *webrtc.SessionDescription) string {
b, err := json.Marshal(obj)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(b)
}
// Decode a base64 and unmarshal JSON into a SessionDescription
func decode(in string, obj *webrtc.SessionDescription) {
b, err := base64.StdEncoding.DecodeString(in)
if err != nil {
panic(err)
}
if err = json.Unmarshal(b, obj); err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,216 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
// data-channels-detach is an example that shows how you can detach a data channel.
// This allows direct access the underlying [pion/datachannel](https://github.com/pion/datachannel).
// This allows you to interact with the data channel using a more idiomatic API based on
// the `io.ReadWriteCloser` interface.
package main
import (
"bufio"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/pion/randutil"
"github.com/pion/webrtc/v4"
)
const messageSize = 15
func main() {
// Since this behavior diverges from the WebRTC API it has to be
// enabled using a settings engine. Mixing both detached and the
// OnMessage DataChannel API is not supported.
// Create a SettingEngine and enable Detach
s := webrtc.SettingEngine{}
s.DetachDataChannels()
// Create an API object with the engine
api := webrtc.NewAPI(webrtc.WithSettingEngine(s))
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
// Prepare the configuration
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
}
// Create a new RTCPeerConnection using the API object
peerConnection, err := api.NewPeerConnection(config)
if err != nil {
panic(err)
}
defer func() {
if cErr := peerConnection.Close(); cErr != nil {
fmt.Printf("cannot close peerConnection: %v\n", cErr)
}
}()
// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
if state == webrtc.PeerConnectionStateFailed {
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
// It may be reconnected using an ICE Restart.
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
fmt.Println("Peer Connection has gone to failed exiting")
os.Exit(0)
}
if state == webrtc.PeerConnectionStateClosed {
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
fmt.Println("Peer Connection has gone to closed exiting")
os.Exit(0)
}
})
// Register data channel creation handling
peerConnection.OnDataChannel(func(dataChannel *webrtc.DataChannel) {
fmt.Printf("New DataChannel %s %d\n", dataChannel.Label(), dataChannel.ID())
// Register channel opening handling
dataChannel.OnOpen(func() {
fmt.Printf("Data channel '%s'-'%d' open.\n", dataChannel.Label(), dataChannel.ID())
// Detach the data channel
raw, dErr := dataChannel.Detach()
if dErr != nil {
panic(dErr)
}
// Handle reading from the data channel
go ReadLoop(raw)
// Handle writing to the data channel
go WriteLoop(raw)
})
})
// Wait for the offer to be pasted
offer := webrtc.SessionDescription{}
decode(readUntilNewline(), &offer)
// Set the remote SessionDescription
err = peerConnection.SetRemoteDescription(offer)
if err != nil {
panic(err)
}
// Create answer
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
}
// Create channel that is blocked until ICE Gathering is complete
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
// Sets the LocalDescription, and starts our UDP listeners
err = peerConnection.SetLocalDescription(answer)
if err != nil {
panic(err)
}
// Block until ICE Gathering is complete, disabling trickle ICE
// we do this because we only can exchange one signaling message
// in a production application you should exchange ICE Candidates via OnICECandidate
<-gatherComplete
// Output the answer in base64 so we can paste it in browser
fmt.Println(encode(peerConnection.LocalDescription()))
// Block forever
select {}
}
// ReadLoop shows how to read from the datachannel directly.
func ReadLoop(d io.Reader) {
for {
buffer := make([]byte, messageSize)
n, err := d.Read(buffer)
if err != nil {
fmt.Println("Datachannel closed; Exit the readloop:", err)
return
}
fmt.Printf("Message from DataChannel: %s\n", string(buffer[:n]))
}
}
// WriteLoop shows how to write to the datachannel directly.
func WriteLoop(d io.Writer) {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
message, err := randutil.GenerateCryptoRandomString(
messageSize, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
)
if err != nil {
panic(err)
}
fmt.Printf("Sending %s \n", message)
if _, err := d.Write([]byte(message)); err != nil {
panic(err)
}
}
}
// Read from stdin until we get a newline.
func readUntilNewline() (in string) {
var err error
r := bufio.NewReader(os.Stdin)
for {
in, err = r.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
panic(err)
}
if in = strings.TrimSpace(in); len(in) > 0 {
break
}
}
fmt.Println("")
return
}
// JSON encode + base64 a SessionDescription.
func encode(obj *webrtc.SessionDescription) string {
b, err := json.Marshal(obj)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(b)
}
// Decode a base64 and unmarshal JSON into a SessionDescription.
func decode(in string, obj *webrtc.SessionDescription) {
b, err := base64.StdEncoding.DecodeString(in)
if err != nil {
panic(err)
}
if err = json.Unmarshal(b, obj); err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,63 @@
# data-channels-flow-control
This example demonstrates how to use the following property / methods.
* func (d *DataChannel) BufferedAmount() uint64
* func (d *DataChannel) SetBufferedAmountLowThreshold(th uint64)
* func (d *DataChannel) BufferedAmountLowThreshold() uint64
* func (d *DataChannel) OnBufferedAmountLow(f func())
These methods are equivalent to that of JavaScript WebRTC API.
See https://developer.mozilla.org/en-US/docs/Web/API/RTCDataChannel for more details.
## When do we need it?
Send or SendText methods are called on DataChannel to send data to the connected peer.
The methods return immediately, but it does not mean the data was actually sent onto
the wire. Instead, it is queued in a buffer until it actually gets sent out to the wire.
When you have a large amount of data to send, it is an application's responsibility to
control the buffered amount in order not to indefinitely grow the buffer size to eventually
exhaust the memory.
The rate you wish to send data might be much higher than the rate the data channel can
actually send to the peer over the Internet. The above properties/methods help your
application to pace the amount of data to be pushed into the data channel.
## How to run the example code
The demo code (main.go) implements two endpoints (offerPC and answerPC) in it.
```
signaling messages
+----------------------------------------+
| |
v v
+---------------+ +---------------+
| | data | |
| offerPC |----------------------->| answerPC |
|:PeerConnection| |:PeerConnection|
+---------------+ +---------------+
```
First offerPC and answerPC will exchange signaling message to establish a peer-to-peer
connection, and data channel (label: "data").
Once the data channel is successfully opened, offerPC will start sending a series of
1024-byte packets to answerPC as fast as it can, until you kill the process by Ctrl-c.
Here's how to run the code.
At the root of the example, `pion/webrtc/examples/data-channels-flow-control/`:
```
$ go run main.go
2019/08/31 14:56:41 OnOpen: data-824635025728. Start sending a series of 1024-byte packets as fast as it can
2019/08/31 14:56:41 OnOpen: data-824637171120. Start receiving data
2019/08/31 14:56:42 Throughput: 179.118 Mbps
2019/08/31 14:56:43 Throughput: 203.545 Mbps
2019/08/31 14:56:44 Throughput: 211.516 Mbps
2019/08/31 14:56:45 Throughput: 216.292 Mbps
2019/08/31 14:56:46 Throughput: 217.961 Mbps
2019/08/31 14:56:47 Throughput: 218.342 Mbps
:
```

View File

@@ -0,0 +1,227 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
// data-channels-flow-control demonstrates how to use the DataChannel congestion control APIs
package main
import (
"encoding/json"
"fmt"
"log"
"os"
"sync/atomic"
"time"
"github.com/pion/webrtc/v4"
)
const (
bufferedAmountLowThreshold uint64 = 512 * 1024 // 512 KB
maxBufferedAmount uint64 = 1024 * 1024 // 1 MB
)
func check(err error) {
if err != nil {
panic(err)
}
}
func setRemoteDescription(pc *webrtc.PeerConnection, sdp []byte) {
var desc webrtc.SessionDescription
err := json.Unmarshal(sdp, &desc)
check(err)
// Apply the desc as the remote description
err = pc.SetRemoteDescription(desc)
check(err)
}
func createOfferer() *webrtc.PeerConnection {
// Prepare the configuration
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{},
}
// Create a new PeerConnection
pc, err := webrtc.NewPeerConnection(config)
check(err)
buf := make([]byte, 1024)
ordered := false
maxRetransmits := uint16(0)
options := &webrtc.DataChannelInit{
Ordered: &ordered,
MaxRetransmits: &maxRetransmits,
}
sendMoreCh := make(chan struct{}, 1)
// Create a datachannel with label 'data'
dataChannel, err := pc.CreateDataChannel("data", options)
check(err)
// Register channel opening handling
dataChannel.OnOpen(func() {
log.Printf(
"OnOpen: %s-%d. Start sending a series of 1024-byte packets as fast as it can\n",
dataChannel.Label(), dataChannel.ID(),
)
for {
err2 := dataChannel.Send(buf)
check(err2)
if dataChannel.BufferedAmount() > maxBufferedAmount {
// Wait until the bufferedAmount becomes lower than the threshold
<-sendMoreCh
}
}
})
// Set bufferedAmountLowThreshold so that we can get notified when
// we can send more
dataChannel.SetBufferedAmountLowThreshold(bufferedAmountLowThreshold)
// This callback is made when the current bufferedAmount becomes lower than the threshold
dataChannel.OnBufferedAmountLow(func() {
select {
case sendMoreCh <- struct{}{}:
default:
}
})
return pc
}
func createAnswerer() *webrtc.PeerConnection {
// Prepare the configuration
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{},
}
// Create a new PeerConnection
pc, err := webrtc.NewPeerConnection(config)
check(err)
pc.OnDataChannel(func(dataChannel *webrtc.DataChannel) {
var totalBytesReceived uint64
// Register channel opening handling
dataChannel.OnOpen(func() {
log.Printf("OnOpen: %s-%d. Start receiving data", dataChannel.Label(), dataChannel.ID())
since := time.Now()
// Start printing out the observed throughput
ticker := time.NewTicker(1000 * time.Millisecond)
defer ticker.Stop()
for range ticker.C {
bps := float64(atomic.LoadUint64(&totalBytesReceived)*8) / time.Since(since).Seconds()
log.Printf("Throughput: %.03f Mbps", bps/1024/1024)
}
})
// Register the OnMessage to handle incoming messages
dataChannel.OnMessage(func(dcMsg webrtc.DataChannelMessage) {
n := len(dcMsg.Data)
atomic.AddUint64(&totalBytesReceived, uint64(n))
})
})
return pc
}
func main() {
offerPC := createOfferer()
defer func() {
if err := offerPC.Close(); err != nil {
fmt.Printf("cannot close offerPC: %v\n", err)
}
}()
answerPC := createAnswerer()
defer func() {
if err := answerPC.Close(); err != nil {
fmt.Printf("cannot close answerPC: %v\n", err)
}
}()
// Set ICE Candidate handler. As soon as a PeerConnection has gathered a candidate
// send it to the other peer
answerPC.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate != nil {
check(offerPC.AddICECandidate(candidate.ToJSON()))
}
})
// Set ICE Candidate handler. As soon as a PeerConnection has gathered a candidate
// send it to the other peer
offerPC.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate != nil {
check(answerPC.AddICECandidate(candidate.ToJSON()))
}
})
// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
offerPC.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s (offerer)\n", state.String())
if state == webrtc.PeerConnectionStateFailed {
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
// It may be reconnected using an ICE Restart.
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
fmt.Println("Peer Connection has gone to failed exiting")
os.Exit(0)
}
if state == webrtc.PeerConnectionStateClosed {
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
fmt.Println("Peer Connection has gone to closed exiting")
os.Exit(0)
}
})
// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
answerPC.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s (answerer)\n", state.String())
if state == webrtc.PeerConnectionStateFailed {
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
// It may be reconnected using an ICE Restart.
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
fmt.Println("Peer Connection has gone to failed exiting")
os.Exit(0)
}
if state == webrtc.PeerConnectionStateClosed {
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
fmt.Println("Peer Connection has gone to closed exiting")
os.Exit(0)
}
})
// Now, create an offer
offer, err := offerPC.CreateOffer(nil)
check(err)
check(offerPC.SetLocalDescription(offer))
desc, err := json.Marshal(offer)
check(err)
setRemoteDescription(answerPC, desc)
answer, err := answerPC.CreateAnswer(nil)
check(err)
check(answerPC.SetLocalDescription(answer))
desc2, err := json.Marshal(answer)
check(err)
setRemoteDescription(offerPC, desc2)
// Block forever
select {}
}

View File

@@ -0,0 +1,23 @@
# WebRTC DataChannel Example in Go
This is a minimal example of a **WebRTC DataChannel** using **Go (Pion)** as the signaling server.
## Features
- Go server for signaling
- Browser-based DataChannel
- ICE candidate exchange
- Real-time messaging between browser and Go server
## Usage
1. Run the server:
```
go run main.go
```
2. Open browser at http://localhost:8080
3. Send messages via DataChannel and see them in terminal & browser logs.

View File

@@ -0,0 +1,88 @@
<!DOCTYPE html>
<html>
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
<head>
<meta charset="utf-8">
<title>DataChannel Test</title>
</head>
<body>
<h2>📡 WebRTC DataChannel Test</h2>
<input id="msg" placeholder="Message">
<button id="sendBtn" disabled onclick="sendMsg()">Send</button>
<pre id="log"></pre>
<script>
const pc = new RTCPeerConnection({iceServers:[{urls:"stun:stun.l.google.com:19302"}]});
const channel = pc.createDataChannel("chat");
// Connection state monitoring
pc.onconnectionstatechange = () => log(`🔄 Connection state: ${pc.connectionState}`);
pc.oniceconnectionstatechange = () => log(`🧊 ICE state: ${pc.iceConnectionState}`);
pc.onsignalingstatechange = () => log(`📞 Signaling state: ${pc.signalingState}`);
channel.onopen = () => {
log("✅ DataChannel opened");
document.getElementById("sendBtn").disabled = false;
}
channel.onmessage = e => log(`📩 Server: ${e.data}`);
pc.onicecandidate = event => {
if(event.candidate){
fetch("/candidate", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(event.candidate),
});
}
};
async function start(){
try {
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const res = await fetch("/offer", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify(offer),
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
const answer = await res.json();
await pc.setRemoteDescription(answer);
} catch (err) {
log(`❌ Connection failed: ${err.message}`);
console.error("Connection error:", err);
}
}
function sendMsg(){
if(channel.readyState !== "open"){
log("❌ Channel not open yet");
return;
}
const msg = document.getElementById("msg").value;
if (msg.trim()) {
channel.send(msg);
log(`You: ${msg}`);
document.getElementById("msg").value = "";
}
}
function log(msg){
document.getElementById("log").textContent+=msg+"\n";
}
start();
</script>
</body>
</html>

View File

@@ -0,0 +1,148 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
// simple-datachannel is a simple datachannel demo that auto connects.
package main
import (
"encoding/json"
"fmt"
"net/http"
"github.com/pion/webrtc/v4"
)
func main() {
var pc *webrtc.PeerConnection
setupOfferHandler(&pc)
setupCandidateHandler(&pc)
setupStaticHandler()
fmt.Println("🚀 Signaling server started on http://localhost:8080")
//nolint:gosec
if err := http.ListenAndServe(":8080", nil); err != nil {
fmt.Printf("Failed to start server: %v\n", err)
}
}
func setupOfferHandler(pc **webrtc.PeerConnection) {
http.HandleFunc("/offer", func(responseWriter http.ResponseWriter, r *http.Request) {
var offer webrtc.SessionDescription
if err := json.NewDecoder(r.Body).Decode(&offer); err != nil {
http.Error(responseWriter, err.Error(), http.StatusBadRequest)
return
}
// PeerConnection with enhanced configuration for better browser compatibility
var err error
*pc, err = webrtc.NewPeerConnection(webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{URLs: []string{"stun:stun.l.google.com:19302"}},
},
BundlePolicy: webrtc.BundlePolicyBalanced,
RTCPMuxPolicy: webrtc.RTCPMuxPolicyRequire,
})
if err != nil {
http.Error(responseWriter, err.Error(), http.StatusInternalServerError)
return
}
setupICECandidateHandler(*pc)
setupDataChannelHandler(*pc)
if err := processOffer(*pc, offer, responseWriter); err != nil {
http.Error(responseWriter, err.Error(), http.StatusInternalServerError)
return
}
})
}
func setupICECandidateHandler(pc *webrtc.PeerConnection) {
pc.OnICECandidate(func(c *webrtc.ICECandidate) {
if c != nil {
fmt.Printf("🌐 New ICE candidate: %s\n", c.Address)
}
})
}
func setupDataChannelHandler(pc *webrtc.PeerConnection) {
pc.OnDataChannel(func(d *webrtc.DataChannel) {
d.OnOpen(func() {
fmt.Println("✅ DataChannel opened (Server)")
if sendErr := d.SendText("Hello from Go server 👋"); sendErr != nil {
fmt.Printf("Failed to send text: %v\n", sendErr)
}
})
d.OnMessage(func(msg webrtc.DataChannelMessage) {
fmt.Printf("📩 Received: %s\n", string(msg.Data))
})
})
}
func processOffer(
pc *webrtc.PeerConnection,
offer webrtc.SessionDescription,
responseWriter http.ResponseWriter,
) error {
// Set remote description
if err := pc.SetRemoteDescription(offer); err != nil {
return err
}
// Create answer
answer, err := pc.CreateAnswer(nil)
if err != nil {
return err
}
// Set local description
if err := pc.SetLocalDescription(answer); err != nil {
return err
}
// Wait for ICE gathering to complete before sending answer
gatherComplete := webrtc.GatheringCompletePromise(pc)
<-gatherComplete
finalAnswer := pc.LocalDescription()
if finalAnswer == nil {
//nolint:err113
return fmt.Errorf("local description is nil after ICE gathering")
}
responseWriter.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(responseWriter).Encode(*finalAnswer); err != nil {
fmt.Printf("Failed to encode answer: %v\n", err)
}
return nil
}
func setupCandidateHandler(pc **webrtc.PeerConnection) {
http.HandleFunc("/candidate", func(responseWriter http.ResponseWriter, r *http.Request) {
var candidate webrtc.ICECandidateInit
if err := json.NewDecoder(r.Body).Decode(&candidate); err != nil {
http.Error(responseWriter, err.Error(), http.StatusBadRequest)
return
}
if *pc != nil {
if err := (*pc).AddICECandidate(candidate); err != nil {
fmt.Println("Failed to add candidate", err)
}
}
})
}
func setupStaticHandler() {
// demo.html
http.HandleFunc("/", func(responseWriter http.ResponseWriter, r *http.Request) {
http.ServeFile(responseWriter, r, "./demo.html")
})
}

View File

@@ -0,0 +1,63 @@
# whip-whep-like
This example demonstrates a WHIP/WHEP-like implementation using Pion WebRTC with DataChannel support for real-time chat.
**Note:** This is similar to but not exactly WHIP/WHEP, as the official WHIP/WHEP specifications focus on media streaming only and do not include DataChannel support. This example extends the WHIP/WHEP pattern to demonstrate peer-to-peer chat functionality with automatic username assignment and message broadcasting.
Key features:
- **Real-time chat** with WebRTC DataChannels
- **Automatic username generation** - Each user gets a unique random username (e.g., SneakyBear46)
- **Message broadcasting** - All connected users receive messages from everyone else
- **WHIP/WHEP-like signaling** - Simple HTTP-based signaling for easy integration
Further details about WHIP+WHEP and the WebRTC DataChannel implementation are below the instructions.
## Instructions
### Download the example
This example requires you to clone the repo since it is serving static HTML.
```
git clone https://github.com/pion/webrtc.git
cd webrtc/examples/data-channels-whip-whep-like
```
### Run the server
Execute `go run *.go`
### Connect and chat
1. Open [http://localhost:8080](http://localhost:8080) in your browser
2. Click "Publish" or "Subscribe" to establish a DataChannel connection
3. You'll be assigned a random username (e.g., "SneakyBear46")
4. Type a message and click "Send Message" to broadcast to all connected users
5. Open multiple tabs/windows to test multi-user chat
Congrats, you have used Pion WebRTC! Now start building something cool
## Why WHIP/WHEP for signaling?
This example uses a WHIP/WHEP-like signaling approach where an Offer is uploaded via HTTP and the server responds with an Answer. This simple API contract makes it easy to integrate WebRTC into web applications.
**Difference from standard WHIP/WHEP:** The official WHIP/WHEP specifications are designed for media streaming (audio/video) only. This example extends that pattern to include DataChannel support for real-time chat functionality.
## Implementation details
### Username generation
Each connected user is automatically assigned a unique username combining:
- An adjective (e.g., Sneaky, Brave, Quick)
- An animal noun (e.g., Bear, Fox, Eagle)
- A random number (0-999)
Congrats, you have used Pion WebRTC! Now start building something cool
## Why WHIP/WHEP?
WHIP/WHEP mandates that a Offer is uploaded via HTTP. The server responds with a Answer. With this strong API contract WebRTC support can be added to tools like OBS.
For more info on WHIP/WHEP specification, feel free to read some of these great resources:
- https://webrtchacks.com/webrtc-cracks-the-whip-on-obs/
- https://datatracker.ietf.org/doc/draft-ietf-wish-whip/
- https://datatracker.ietf.org/doc/draft-ietf-wish-whep/
- https://bloggeek.me/whip-whep-webrtc-live-streaming

View File

@@ -0,0 +1,97 @@
<html>
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
<head>
<title>whip-whep</title>
</head>
<body>
<button onclick="window.doWHIP()">Publish</button>
<button onclick="window.doWHEP()">Subscribe</button>
<br />
Message<br />
<textarea id="message">This is my DataChannel message!</textarea> <br/>
<button onclick="window.sendMessage()">Send Message</button> <br />
<h3> Logs </h3>
<div id="logs"></div>
<h3> ICE Connection States </h3>
<div id="iceConnectionStates"></div> <br />
</body>
<script>
const log = msg => {
document.getElementById('logs').innerHTML += msg + '<br>'
}
let peerConnection = new RTCPeerConnection()
const sendChannel = peerConnection.createDataChannel('foo')
sendChannel.onclose = () => console.log('sendChannel has closed')
sendChannel.onopen = () => console.log('sendChannel has opened')
sendChannel.onmessage = e => log(`From ${e.data}`)
peerConnection.oniceconnectionstatechange = () => {
let el = document.createElement('p')
el.appendChild(document.createTextNode(peerConnection.iceConnectionState))
document.getElementById('iceConnectionStates').appendChild(el);
}
window.doWHEP = () => {
peerConnection.createOffer().then(offer => {
peerConnection.setLocalDescription(offer)
fetch(`/whep`, {
method: 'POST',
body: offer.sdp,
headers: {
Authorization: `Bearer none`,
'Content-Type': 'application/sdp'
}
}).then(r => r.text())
.then(answer => {
peerConnection.setRemoteDescription({
sdp: answer,
type: 'answer'
})
})
})
}
window.doWHIP = () => {
peerConnection.createOffer().then(offer => {
peerConnection.setLocalDescription(offer)
fetch(`/whip`, {
method: 'POST',
body: offer.sdp,
headers: {
Authorization: `Bearer none`,
'Content-Type': 'application/sdp'
}
}).then(r => r.text())
.then(answer => {
peerConnection.setRemoteDescription({
sdp: answer,
type: 'answer'
})
})
})
}
window.sendMessage = () => {
const message = document.getElementById('message').value
if (message === '') {
return alert('Message must not be empty')
}
sendChannel.send(message)
}
</script>
</html>

View File

@@ -0,0 +1,257 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
// whip-whep demonstrates how to use the WHIP/WHEP specifications to exchange SPD descriptions
// and stream media to a WebRTC client in the browser or OBS.
package main
import (
"fmt"
"io"
"math/rand"
"net/http"
"sync"
"github.com/pion/webrtc/v4"
)
// nolint: gochecknoglobals
var (
peerConnectionConfiguration = webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
}
// Broadcast hub to forward messages between all connected clients.
broadcastHub = &Hub{
connections: make(map[*webrtc.DataChannel]bool),
usernames: make(map[*webrtc.DataChannel]string),
mu: sync.RWMutex{},
}
)
// Hub manages all connected DataChannels for broadcasting.
type Hub struct {
connections map[*webrtc.DataChannel]bool
usernames map[*webrtc.DataChannel]string
mu sync.RWMutex
}
// nolint: gochecknoglobals
var (
adjectives = []string{
"Quick", "Swift", "Bright", "Bold", "Calm", "Cool", "Fast", "Happy",
"Lucky", "Shy", "Sneaky", "Wise", "Brave", "Clever", "Kind", "Proud",
}
nouns = []string{
"Fox", "Eagle", "Lion", "Tiger", "Wolf", "Dragon", "Hawk", "Bear",
"Shark", "Falcon", "Leopard", "Panther", "Phoenix", "Raven", "Crow", "Owl",
}
)
// Register adds a DataChannel to the broadcast hub and assigns a random username.
func (h *Hub) Register(channel *webrtc.DataChannel) string {
h.mu.Lock()
defer h.mu.Unlock()
h.connections[channel] = true
username := h.generateUniqueUsername()
h.usernames[channel] = username
return username
}
// Unregister removes a DataChannel from the broadcast hub.
func (h *Hub) Unregister(channel *webrtc.DataChannel) {
h.mu.Lock()
defer h.mu.Unlock()
delete(h.connections, channel)
delete(h.usernames, channel)
}
// generateUniqueUsername generates a unique username by combining an adjective and a noun.
// It checks existing usernames and regenerates until it finds a unique one.
// Must be called while holding h.mu.Lock().
// nolint: gosec
func (h *Hub) generateUniqueUsername() string {
var username string
for {
adjective := adjectives[rand.Intn(len(adjectives))]
noun := nouns[rand.Intn(len(nouns))]
number := rand.Intn(1000)
username = fmt.Sprintf("%s%s%d", adjective, noun, number)
// Check if this username already exists by iterating over map values directly
exists := false
for _, existingUsername := range h.usernames {
if existingUsername == username {
exists = true
break
}
}
if !exists {
break
}
}
return username
}
// GetUsername returns the username for a DataChannel.
func (h *Hub) GetUsername(channel *webrtc.DataChannel) string {
h.mu.RLock()
defer h.mu.RUnlock()
return h.usernames[channel]
}
// Broadcast sends a message to all registered DataChannels including the sender.
func (h *Hub) Broadcast(message string, sender *webrtc.DataChannel) {
h.mu.RLock()
defer h.mu.RUnlock()
// Get the sender's username
senderUsername := h.usernames[sender]
formattedMessage := fmt.Sprintf("%s: %s", senderUsername, message)
for channel := range h.connections {
// Check if channel is still open
if channel.ReadyState() != webrtc.DataChannelStateOpen {
continue
}
// Send message in goroutine to avoid blocking
go func(ch *webrtc.DataChannel, msg string) {
if err := ch.SendText(msg); err != nil {
fmt.Printf("Failed to send broadcast message: %v\n", err)
}
}(channel, formattedMessage)
}
}
// Count returns the number of connected clients.
func (h *Hub) Count() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.connections)
}
// nolint:gocognit
func main() {
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
http.Handle("/", http.FileServer(http.Dir(".")))
http.HandleFunc("/whep", whepHandler)
http.HandleFunc("/whip", whipHandler)
fmt.Println("Open http://localhost:8080 to access this demo")
panic(http.ListenAndServe(":8080", nil)) // nolint: gosec
}
func whipHandler(res http.ResponseWriter, req *http.Request) {
// Read the offer from HTTP Request
offer, err := io.ReadAll(req.Body)
if err != nil {
panic(err)
}
// Create a new RTCPeerConnection
peerConnection, err := webrtc.NewPeerConnection(peerConnectionConfiguration)
if err != nil {
panic(err)
}
// Send answer via HTTP Response
writeAnswer(res, peerConnection, offer, "/whip")
}
func whepHandler(res http.ResponseWriter, req *http.Request) {
// Read the offer from HTTP Request
offer, err := io.ReadAll(req.Body)
if err != nil {
panic(err)
}
// Create a new RTCPeerConnection
peerConnection, err := webrtc.NewPeerConnection(peerConnectionConfiguration)
if err != nil {
panic(err)
}
// Send answer via HTTP Response
writeAnswer(res, peerConnection, offer, "/whep")
}
func writeAnswer(res http.ResponseWriter, peerConnection *webrtc.PeerConnection, offer []byte, path string) {
// Set the handler for ICE connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
fmt.Printf("ICE Connection State has changed: %s\n", connectionState.String())
if connectionState == webrtc.ICEConnectionStateFailed ||
connectionState == webrtc.ICEConnectionStateClosed {
_ = peerConnection.Close()
}
})
peerConnection.OnDataChannel(func(dataChannel *webrtc.DataChannel) {
fmt.Printf("New DataChannel %s %d\n", dataChannel.Label(), dataChannel.ID())
dataChannel.OnOpen(func() {
// register this channel in the broadcast hub and get assigned username
username := broadcastHub.Register(dataChannel)
fmt.Printf("Data channel '%s'-'%d' opened. Username: %s, Total clients: %d\n",
dataChannel.Label(), dataChannel.ID(), username, broadcastHub.Count())
})
dataChannel.OnClose(func() {
fmt.Printf("Data channel '%s'-'%d' closed\n", dataChannel.Label(), dataChannel.ID())
// unregister this channel from the broadcast hub
broadcastHub.Unregister(dataChannel)
})
dataChannel.OnMessage(func(msg webrtc.DataChannelMessage) {
message := string(msg.Data)
fmt.Printf("Message from DataChannel '%s': '%s'\n", dataChannel.Label(), message)
// broadcast the message to all other connected clients
broadcastHub.Broadcast(message, dataChannel)
})
})
if err := peerConnection.SetRemoteDescription(webrtc.SessionDescription{
Type: webrtc.SDPTypeOffer, SDP: string(offer),
}); err != nil {
panic(err)
}
// Create channel that is blocked until ICE Gathering is complete
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
// Create answer
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
} else if err = peerConnection.SetLocalDescription(answer); err != nil {
panic(err)
}
// Block until ICE Gathering is complete, disabling trickle ICE
// we do this because we only can exchange one signaling message
// in a production application you should exchange ICE Candidates via OnICECandidate
<-gatherComplete
// WHIP+WHEP expects a Location header and a HTTP Status Code of 201
res.Header().Add("Location", path)
res.WriteHeader(http.StatusCreated)
// Write Answer with Candidates as HTTP Response
fmt.Fprint(res, peerConnection.LocalDescription().SDP) //nolint: errcheck
}

View File

@@ -0,0 +1,80 @@
# data-channels
data-channels is Pion's sample WebRTC app that lets you send and receive DataChannel messages from a web browser.
## Brief Overview
This example will result in messages being sent between a browser and a self-hosted data-channels server. The connection is made by grabbing the browser's generated session description, or SDP, and passing it into the server. The server uses the browser's SDP to then return a new SDP from the server based on the browser's SDP. The server's SDP then gets passed back into the browser which confirms the handshake and forms a connection!
Once the connection is established, messages will automatically be sent from the data-channels server to the browser every 5 seconds. The browser has a button that lets you send a message back to the server when you click on it.
## Instructions
### 1. Download the data-channels server
```
go install github.com/pion/webrtc/v4/examples/data-channels@latest
```
### 2. Open JSFiddle
[Open this JSFiddle example page.](https://jsfiddle.net/e41tgovp/)
The top of the JSFiddle example page contains a text box containing your browser's session description (SDP).
Press `Copy browser SDP to clipboard` or copy the base64 string manually.
### 3. Send the browser's SDP to the server
Depending on your OS:
#### Linux/macOS (including WSL)
In the following command, replace `$BROWSER_SDP` with the copied string.
Run `echo $BROWSER_SDP | data-channels`.
#### Windows
1. Paste the copied string into a file.
2. Run `data-channels < my_file`.
### 4. Send the server's SDP back to the browser
The server will automatically print out a base64 string. Copy it and paste it into the second textbox in the JSFiddle page.
### 5. Start the session!
Under Start Session you should see 'Checking' as it starts connecting. If everything worked you should see `New DataChannel foo 1`.
Pion WebRTC will send random messages every 5 seconds that will appear in your browser.
### 6. Send a message from the browser to the server!
You can put whatever you want in the `Message` text area, and when you hit `Send Message` it should appear in your terminal!
## Example finished!
Congrats, you have used Pion WebRTC! Now start building something cool :)
## Architecture Overview
```mermaid
flowchart TB
Browser--Copy Offer from TextArea-->Pion
Pion--Copy Text Print to Console-->Browser
subgraph Pion[Go Peer]
p1[Create PeerConnection]
p2[OnConnectionState Handler]
p3[Print Connection State]
p2-->p3
p4[OnDataChannel Handler]
p5[OnDataChannel Open]
p6[Send Random Message every 5 seconds to DataChannel]
p4-->p5-->p6
p7[OnDataChannel Message]
p8[Log Incoming Message to Console]
p4-->p7-->p8
p9[Read Session Description from Standard Input]
p10[SetRemoteDescription with Session Description from Standard Input]
p11[Create Answer]
p12[Block until ICE Gathering is Complete]
p13[Print Answer with ICE Candidatens included to Standard Output]
end
subgraph Browser[Browser Peer]
b1[Create PeerConnection]
b2[Create DataChannel 'foo']
b3[OnDataChannel Message]
b4[Log Incoming Message to Console]
b3-->b4
b5[Create Offer]
b6[SetLocalDescription with Offer]
b7[Print Offer with ICE Candidates included]
end
```

View File

@@ -0,0 +1,8 @@
/*
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
*/
textarea {
width: 500px;
min-height: 75px;
}

View File

@@ -0,0 +1,8 @@
---
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
name: data-channels
description: Example of using Pion WebRTC to communicate with a web browser using bi-direction DataChannels
authors:
- Sean DuBois

View File

@@ -0,0 +1,25 @@
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
Browser base64 Session Description<br />
<textarea id="localSessionDescription" readonly="true"></textarea> <br />
<button onclick="window.copySDP()">
Copy browser SDP to clipboard
</button>
<br />
<br />
Golang base64 Session Description<br />
<textarea id="remoteSessionDescription"></textarea><br/>
<button onclick="window.startSession()">Start Session</button><br />
<br />
Message<br />
<textarea id="message">This is my DataChannel message!</textarea> <br/>
<button onclick="window.sendMessage()">Send Message</button> <br />
<br />
Logs<br />
<div id="logs"></div>

View File

@@ -0,0 +1,67 @@
/* eslint-env browser */
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
const pc = new RTCPeerConnection({
iceServers: [
{
urls: 'stun:stun.l.google.com:19302'
}
]
})
const log = msg => {
document.getElementById('logs').innerHTML += msg + '<br>'
}
const sendChannel = pc.createDataChannel('foo')
sendChannel.onclose = () => console.log('sendChannel has closed')
sendChannel.onopen = () => console.log('sendChannel has opened')
sendChannel.onmessage = e => log(`Message from DataChannel '${sendChannel.label}' payload '${e.data}'`)
pc.oniceconnectionstatechange = e => log(pc.iceConnectionState)
pc.onicecandidate = event => {
if (event.candidate === null) {
document.getElementById('localSessionDescription').value = btoa(JSON.stringify(pc.localDescription))
}
}
pc.onnegotiationneeded = e =>
pc.createOffer().then(d => pc.setLocalDescription(d)).catch(log)
window.sendMessage = () => {
const message = document.getElementById('message').value
if (message === '') {
return alert('Message must not be empty')
}
sendChannel.send(message)
}
window.startSession = () => {
const sd = document.getElementById('remoteSessionDescription').value
if (sd === '') {
return alert('Session Description must not be empty')
}
try {
pc.setRemoteDescription(JSON.parse(atob(sd)))
} catch (e) {
alert(e)
}
}
window.copySDP = () => {
const browserSDP = document.getElementById('localSessionDescription')
browserSDP.focus()
browserSDP.select()
try {
const successful = document.execCommand('copy')
const msg = successful ? 'successful' : 'unsuccessful'
log('Copying SDP was ' + msg)
} catch (err) {
log('Unable to copy SDP ' + err)
}
}

View File

@@ -0,0 +1,202 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build js && wasm
// +build js,wasm
package main
import (
"bufio"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"syscall/js"
"github.com/pion/webrtc/v4"
)
func main() {
// Configure and create a new PeerConnection.
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
}
pc, err := webrtc.NewPeerConnection(config)
if err != nil {
handleError(err)
}
// Create DataChannel.
sendChannel, err := pc.CreateDataChannel("foo", nil)
if err != nil {
handleError(err)
}
sendChannel.OnClose(func() {
fmt.Println("sendChannel has closed")
})
sendChannel.OnClosing(func() {
fmt.Println("sendChannel is closing")
})
sendChannel.OnError(func(err error) {
fmt.Println("sendChannel error", err)
})
sendChannel.OnOpen(func() {
fmt.Println("sendChannel has opened")
candidatePair, err := pc.SCTP().Transport().ICETransport().GetSelectedCandidatePair()
fmt.Println(candidatePair)
fmt.Println(err)
})
sendChannel.OnMessage(func(msg webrtc.DataChannelMessage) {
log(fmt.Sprintf("Message from DataChannel %s payload %s", sendChannel.Label(), string(msg.Data)))
})
// Create offer
offer, err := pc.CreateOffer(nil)
if err != nil {
handleError(err)
}
if err := pc.SetLocalDescription(offer); err != nil {
handleError(err)
}
// Add handlers for setting up the connection.
pc.OnICEConnectionStateChange(func(state webrtc.ICEConnectionState) {
log(fmt.Sprint(state))
})
pc.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate != nil {
encodedDescr := encode(pc.LocalDescription())
el := getElementByID("localSessionDescription")
el.Set("value", encodedDescr)
}
})
// Set up global callbacks which will be triggered on button clicks.
js.Global().Set("sendMessage", js.FuncOf(func(_ js.Value, _ []js.Value) any {
go func() {
el := getElementByID("message")
message := el.Get("value").String()
if message == "" {
js.Global().Call("alert", "Message must not be empty")
return
}
if err := sendChannel.SendText(message); err != nil {
handleError(err)
}
}()
return js.Undefined()
}))
js.Global().Set("startSession", js.FuncOf(func(_ js.Value, _ []js.Value) any {
go func() {
el := getElementByID("remoteSessionDescription")
sd := el.Get("value").String()
if sd == "" {
js.Global().Call("alert", "Session Description must not be empty")
return
}
descr := webrtc.SessionDescription{}
decode(sd, &descr)
if err := pc.SetRemoteDescription(descr); err != nil {
handleError(err)
}
}()
return js.Undefined()
}))
js.Global().Set("copySDP", js.FuncOf(func(_ js.Value, _ []js.Value) any {
go func() {
defer func() {
if e := recover(); e != nil {
switch e := e.(type) {
case error:
handleError(e)
default:
handleError(fmt.Errorf("recovered with non-error value: (%T) %s", e, e))
}
}
}()
browserSDP := getElementByID("localSessionDescription")
browserSDP.Call("focus")
browserSDP.Call("select")
copyStatus := js.Global().Get("document").Call("execCommand", "copy")
if copyStatus.Bool() {
log("Copying SDP was successful")
} else {
log("Copying SDP was unsuccessful")
}
}()
return js.Undefined()
}))
// Stay alive
select {}
}
func log(msg string) {
el := getElementByID("logs")
el.Set("innerHTML", el.Get("innerHTML").String()+msg+"<br>")
}
func handleError(err error) {
log("Unexpected error. Check console.")
panic(err)
}
func getElementByID(id string) js.Value {
return js.Global().Get("document").Call("getElementById", id)
}
// Read from stdin until we get a newline
func readUntilNewline() (in string) {
var err error
r := bufio.NewReader(os.Stdin)
for {
in, err = r.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
panic(err)
}
if in = strings.TrimSpace(in); len(in) > 0 {
break
}
}
fmt.Println("")
return
}
// JSON encode + base64 a SessionDescription
func encode(obj *webrtc.SessionDescription) string {
b, err := json.Marshal(obj)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(b)
}
// Decode a base64 and unmarshal JSON into a SessionDescription
func decode(in string, obj *webrtc.SessionDescription) {
b, err := base64.StdEncoding.DecodeString(in)
if err != nil {
panic(err)
}
if err = json.Unmarshal(b, obj); err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,178 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
// data-channels is a Pion WebRTC application that shows how you can send/recv DataChannel messages from a web browser
package main
import (
"bufio"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/pion/randutil"
"github.com/pion/webrtc/v4"
)
// nolint:cyclop
func main() {
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
// Prepare the configuration
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
}
// Create a new RTCPeerConnection
peerConnection, err := webrtc.NewPeerConnection(config)
if err != nil {
panic(err)
}
defer func() {
if cErr := peerConnection.Close(); cErr != nil {
fmt.Printf("cannot close peerConnection: %v\n", cErr)
}
}()
// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
if state == webrtc.PeerConnectionStateFailed {
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
// It may be reconnected using an ICE Restart.
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
fmt.Println("Peer Connection has gone to failed exiting")
os.Exit(0)
}
if state == webrtc.PeerConnectionStateClosed {
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
fmt.Println("Peer Connection has gone to closed exiting")
os.Exit(0)
}
})
// Register data channel creation handling
peerConnection.OnDataChannel(func(dataChannel *webrtc.DataChannel) {
fmt.Printf("New DataChannel %s %d\n", dataChannel.Label(), dataChannel.ID())
// Register channel opening handling
dataChannel.OnOpen(func() {
fmt.Printf(
"Data channel '%s'-'%d' open. Random messages will now be sent to any connected DataChannels every 5 seconds\n",
dataChannel.Label(), dataChannel.ID(),
)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
message, sendErr := randutil.GenerateCryptoRandomString(15, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
if sendErr != nil {
panic(sendErr)
}
// Send the message as text
fmt.Printf("Sending '%s'\n", message)
if sendErr = dataChannel.SendText(message); sendErr != nil {
panic(sendErr)
}
}
})
// Register text message handling
dataChannel.OnMessage(func(msg webrtc.DataChannelMessage) {
fmt.Printf("Message from DataChannel '%s': '%s'\n", dataChannel.Label(), string(msg.Data))
})
})
// Wait for the offer to be pasted
offer := webrtc.SessionDescription{}
decode(readUntilNewline(), &offer)
// Set the remote SessionDescription
err = peerConnection.SetRemoteDescription(offer)
if err != nil {
panic(err)
}
// Create an answer
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
}
// Create channel that is blocked until ICE Gathering is complete
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
// Sets the LocalDescription, and starts our UDP listeners
err = peerConnection.SetLocalDescription(answer)
if err != nil {
panic(err)
}
// Block until ICE Gathering is complete, disabling trickle ICE
// we do this because we only can exchange one signaling message
// in a production application you should exchange ICE Candidates via OnICECandidate
<-gatherComplete
// Output the answer in base64 so we can paste it in browser
fmt.Println(encode(peerConnection.LocalDescription()))
// Block forever
select {}
}
// Read from stdin until we get a newline.
func readUntilNewline() (in string) {
var err error
r := bufio.NewReader(os.Stdin)
for {
in, err = r.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
panic(err)
}
if in = strings.TrimSpace(in); len(in) > 0 {
break
}
}
fmt.Println("")
return
}
// JSON encode + base64 a SessionDescription.
func encode(obj *webrtc.SessionDescription) string {
b, err := json.Marshal(obj)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(b)
}
// Decode a base64 and unmarshal JSON into a SessionDescription.
func decode(in string, obj *webrtc.SessionDescription) {
b, err := base64.StdEncoding.DecodeString(in)
if err != nil {
panic(err)
}
if err = json.Unmarshal(b, obj); err != nil {
panic(err)
}
}

38
examples/example.html Normal file
View File

@@ -0,0 +1,38 @@
<html>
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<link href="https://fonts.googleapis.com/css?family=Quicksand:400,500,700" rel="stylesheet">
<title>{{ .Title }} | Pion</title>
<style>
body {
font-family: "Quicksand", sans-serif;
font-weight: 400;
margin: 4em 10%;
}
</style>
<link rel="stylesheet" type="text/css" href="demo.css">
</head>
<body>
<h1>{{ .Title }}</h1>
<p><a href="/">< Home</a></p>
<div>
{{ template "demo.html" }}
</div>
</body>
{{ if .JS }}
<script src="demo.js"></script>
{{ else }}
<script src="/wasm_exec.js"></script>
<script>
const go = new Go();
WebAssembly.instantiateStreaming(fetch("demo.wasm"), go.importObject).then((result) => {
go.run(result.instance);
});
</script>
{{ end }}
</html>

152
examples/examples.go Normal file
View File

@@ -0,0 +1,152 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
// HTTP server that demonstrates Pion WebRTC examples
package main
import (
"encoding/json"
"flag"
"go/build"
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"strings"
)
// Examples represents the examples loaded from examples.json.
type Examples []*Example
// Example represents an example loaded from examples.json.
type Example struct {
Title string `json:"title"`
Link string `json:"link"`
Description string `json:"description"`
Type string `json:"type"`
IsJS bool
IsWASM bool
}
func main() {
addr := flag.String("address", ":80", "Address to host the HTTP server on.")
flag.Parse()
log.Println("Listening on", *addr)
err := serve(*addr)
if err != nil {
log.Fatalf("Failed to serve: %v", err)
}
}
func serve(addr string) error {
// Load the examples
examples := getExamples()
// Load the templates
homeTemplate := template.Must(template.ParseFiles("index.html"))
// Serve the required pages
// DIY 'mux' to avoid additional dependencies
http.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) {
url := req.URL.Path
if url == "/wasm_exec.js" {
http.FileServer(http.Dir(filepath.Join(build.Default.GOROOT, "lib/wasm/"))).ServeHTTP(res, req)
return
}
// Split up the URL. Expected parts:
// 1: Base url
// 2: "example"
// 3: Example type: js or wasm
// 4: Example folder, e.g.: data-channels
// 5: Static file as part of the example
parts := strings.Split(url, "/")
if len(parts) > 4 &&
parts[1] == "example" {
exampleType := parts[2]
exampleLink := parts[3]
for _, example := range *examples {
if example.Link != exampleLink {
continue
}
fiddle := filepath.Join(exampleLink, "jsfiddle")
if len(parts[4]) != 0 {
http.StripPrefix(
"/example/"+exampleType+"/"+exampleLink+"/",
http.FileServer(http.Dir(fiddle)),
).ServeHTTP(res, req)
return
}
temp := template.Must(template.ParseFiles("example.html"))
_, err := temp.ParseFiles(filepath.Join(fiddle, "demo.html"))
if err != nil {
panic(err)
}
data := struct {
*Example
JS bool
}{
example,
exampleType == "js",
}
err = temp.Execute(res, data)
if err != nil {
panic(err)
}
return
}
}
// Serve the main page
err := homeTemplate.Execute(res, examples)
if err != nil {
panic(err)
}
})
// Start the server
// nolint: gosec
return http.ListenAndServe(addr, nil)
}
// getExamples loads the examples from the examples.json file.
func getExamples() *Examples {
file, err := os.Open("./examples.json")
if err != nil {
panic(err)
}
defer func() {
closeErr := file.Close()
if closeErr != nil {
panic(closeErr)
}
}()
var examples Examples
err = json.NewDecoder(file).Decode(&examples)
if err != nil {
panic(err)
}
for _, example := range examples {
fiddle := filepath.Join(example.Link, "jsfiddle")
js := filepath.Join(fiddle, "demo.js")
if _, err := os.Stat(js); !os.IsNotExist(err) {
example.IsJS = true
}
wasm := filepath.Join(fiddle, "demo.wasm")
if _, err := os.Stat(wasm); !os.IsNotExist(err) {
example.IsWASM = true
}
}
return &examples
}

146
examples/examples.json Normal file
View File

@@ -0,0 +1,146 @@
[
{
"title": "Data Channels",
"link": "data-channels",
"description": "The data-channels example shows how you can send/recv DataChannel messages from a web browser.",
"type": "browser"
},
{
"title": "Data Channels Detach",
"link": "data-channels-detach",
"description": "The data-channels-detach is an example that shows how you can detach a data channel.",
"type": "browser"
},
{
"title": "Data Channels Flow Control",
"link": "data-channels-flow-control",
"description": "The data-channels-detach data-channels-flow-control shows how to use the DataChannel API efficiently. You can measure the amount the rate at which the remote peer is receiving data, and structure your application accordingly",
"type": "browser"
},
{
"title": "SCTP Interleaving Inspector",
"link": "sctp-interleaving",
"description": "The sctp-interleaving example inspects plaintext SCTP packets between DTLS and SCTP so you can see whether I-DATA interleaving was advertised and observed.",
"type": "browser"
},
{
"title": "Reflect",
"link": "reflect",
"description": "The reflect example demonstrates how to have Pion send back to the user exactly what it receives using the same PeerConnection.",
"type": "browser"
},
{
"title": "Repacketize",
"link": "repacketize",
"description": "The repacketize example demonstrates how many video codecs can be received, depacketized and packetized by Pion over RTP.",
"type": "browser"
},
{
"title": "Pion to Pion",
"link": "#",
"description": "Example pion-to-pion is an example of two pion instances communicating directly! It therefore has no corresponding web page.",
"type": "browser"
},
{
"title": "Play from Disk",
"link": "play-from-disk",
"description": "The play-from-disk example demonstrates how to send video to your browser from a file saved to disk.",
"type": "browser"
},
{
"title": "Play from Disk Renegotiation",
"link": "play-from-disk",
"description": "The play-from-disk-renegotiation example is an extension of the play-from-disk example, but demonstrates how you can add/remove video tracks from an already negotiated PeerConnection.",
"type": "browser"
},
{
"title": "Play from Disk Audio Control",
"link": "play-from-disk-playlist-control",
"description": "The play-from-disk-playlist-control example demonstrates how to play an opus playlist from a file saved to disk, and control the playlist playback from the browser.",
"type": "browser"
},
{
"title": "Insertable Streams",
"link": "insertable-streams",
"description": "The insertable-streams example demonstrates how Pion can be used to send E2E encrypted video and decrypt via insertable streams in the browser.",
"type": "browser"
},
{
"title": "Save to Disk",
"link": "save-to-disk",
"description": "The save-to-disk example shows how to record your webcam and save the footage to disk on the server side.",
"type": "browser"
},
{
"title": "Broadcast",
"link": "broadcast",
"description": "The broadcast example demonstrates how to broadcast a video to multiple peers. A broadcaster uploads the video once and the server forwards it to all other peers.",
"type": "browser"
},
{
"title": "RTP Forwarder",
"link": "rtp-forwarder",
"description": "The rtp-forwarder example demonstrates how to forward your audio/video streams using RTP.",
"type": "browser"
},
{
"title": "RTP to WebRTC",
"link": "rtp-to-webrtc",
"description": "The rtp-to-webrtc example demonstrates how to take RTP packets sent to a Pion process into your browser.",
"type": "browser"
},
{
"title": "Custom Logger",
"link": "#",
"description": "Example custom-logger demonstrates how the user can override the logging and process messages instead of printing to stdout. It has no corresponding web page.",
"type": "browser"
},
{
"title": "Simulcast",
"link": "simulcast",
"description": "Example simulcast demonstrates how to accept and demux 1 Track that contains 3 Simulcast streams. It then returns the media as 3 independent Tracks back to the sender.",
"type": "browser"
},
{
"title": "ICE Restart",
"link": "#",
"description": "Example ice-restart demonstrates how a WebRTC connection can roam between networks. This example restarts ICE in a loop and prints the new addresses it uses each time.",
"type": "browser"
},
{
"title": "ICE Single Port",
"link": "#",
"description": "Example ice-single-port demonstrates how multiple WebRTC connections can be served from a single port. By default Pion listens on a new port for every PeerConnection. Pion can be configured to use a single port for multiple connections.",
"type": "browser"
},
{
"title": "ICE TCP",
"link": "#",
"description": "Example ice-tcp demonstrates how a WebRTC connection can be made over TCP instead of UDP. By default Pion only does UDP. Pion can be configured to use a TCP port, and this TCP port can be used for many connections.",
"type": "browser"
},
{
"title": "Swap Tracks",
"link": "swap-tracks",
"description": "The swap-tracks example demonstrates deeper usage of the Pion Media API. The server accepts 3 media streams, and then dynamically routes them back as a single stream to the user.",
"type": "browser"
},
{
"title": "VNet",
"link": "#",
"description": "The vnet example demonstrates Pion's network virtualisation library. This example connects two PeerConnections over a virtual network and prints statistics about the data traveling over it.",
"type": "browser"
},
{
"title": "rtcp-processing",
"link": "rtcp-processing",
"description": "The rtcp-processing example demonstrates Pion's RTCP APIs. This allow access to media statistics and control information.",
"type": "browser"
},
{
"title": "trickle-ice",
"link": "#",
"description": "The trickle-ice example demonstrates Pion WebRTC's Trickle ICE APIs.",
"type": "browser"
}
]

View File

@@ -0,0 +1,26 @@
# ICE Proxy
`ice-proxy` demonstrates Pion WebRTC's capabilities for utilizing a proxy in WebRTC connections.
This proxy functionality is particularly useful when direct peer-to-peer communication is restricted, such as in environments with strict firewalls. It primarily leverages TURN (Traversal Using Relays around NAT) with TCP connections to enable communication with the outside world.
## Instructions
### Download ice-proxy
The example is self-contained and requires no input.
```bash
go install github.com/pion/webrtc/v4/examples/ice-proxy@latest
```
### Run ice-proxy
```bash
ice-proxy
```
Upon execution, four distinct entities will be launched:
* `TURN Server`: This server facilitates relaying media traffic when direct communication between agents is not possible, simulating a scenario where peers are behind restrictive NATs.
* `Proxy HTTP Server`: A straightforward HTTP proxy designed to forward all TCP traffic to a specified target.
* `Offering Agent`: In a typical WebRTC setup, this would be a web browser. In this example, it's a simplified Pion client that initiates the WebRTC connection. This agent attempts direct communication with the answering agent.
* `Answering Agent`: This typically represents a web server. In this demonstration, it's configured to use the TURN server, simulating a scenario where the agent is not directly reachable. This agent exclusively uses a relay connection via the TURN server, with a proxy acting as an intermediary between the agent and the TURN server.

View File

@@ -0,0 +1,116 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
package main
import (
"encoding/json"
"log"
"net/http"
"github.com/pion/webrtc/v4"
)
// nolint:cyclop
func setupAnsweringAgent() {
// Create and start a simple HTTP proxy server.
proxyURL := newHTTPProxy()
// Create a proxy dialer that will use the created HTTP proxy.
proxyDialer := newProxyDialer(proxyURL)
var settingEngine webrtc.SettingEngine
// Set the ICEProxyDialer to use the proxy for TURN+TCP connections.
settingEngine.SetICEProxyDialer(proxyDialer)
api := webrtc.NewAPI(webrtc.WithSettingEngine(settingEngine))
peerConnection, err := api.NewPeerConnection(webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{turnServerURL},
Username: turnUsername,
Credential: turnPassword,
},
},
// ICETransportPolicyRelay forces the connection to go through a TURN server.
// This is required for the proxy to be used.
ICETransportPolicy: webrtc.ICETransportPolicyRelay,
})
if err != nil {
panic(err)
}
// Log peer connection and ICE connection state changes.
peerConnection.OnConnectionStateChange(func(pcs webrtc.PeerConnectionState) {
log.Printf("[Answerer] Peer Connection State has changed: %s", pcs.String())
})
peerConnection.OnICEConnectionStateChange(func(ics webrtc.ICEConnectionState) {
log.Printf("[Answerer] ICE Connection State has changed: %s", ics.String())
})
// Register a handler for when a data channel is created by the remote peer.
peerConnection.OnDataChannel(func(d *webrtc.DataChannel) {
icePair, err := d.Transport().Transport().ICETransport().GetSelectedCandidatePair()
if err != nil {
panic(err)
}
// Log the chosen ICE candidate pair.
log.Printf("[Answerer] New DataChannel %s, ICE pair: (%s)<->(%s)",
d.Label(), icePair.Local.String(), icePair.Remote.String())
// Register a handler to echo messages back to the sender.
d.OnMessage(func(msg webrtc.DataChannelMessage) {
if err := d.SendText(string(msg.Data)); err != nil {
panic(err)
}
})
})
// HTTP handler that accepts an offer, creates an answer,
// and sends it back to the offering agent.
http.HandleFunc("/sdp", func(rw http.ResponseWriter, r *http.Request) {
var sdp webrtc.SessionDescription
if err := json.NewDecoder(r.Body).Decode(&sdp); err != nil {
panic(err)
}
if err := peerConnection.SetRemoteDescription(sdp); err != nil {
panic(err)
}
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
}
if err = peerConnection.SetLocalDescription(answer); err != nil {
panic(err)
}
// Block until ICE Gathering is complete, disabling trickle ICE
// we do this because we only can exchange one signaling message
// in a production application you should exchange ICE Candidates via OnICECandidate
<-gatherComplete
resp, err := json.Marshal(*peerConnection.LocalDescription())
if err != nil {
panic(err)
}
if _, err := rw.Write(resp); err != nil {
panic(err)
}
})
// Start an HTTP server to handle the SDP exchange from the offering agent.
go func() {
// The HTTP server is not gracefully shutdown in this example.
// nolint:gosec
err := http.ListenAndServe("localhost:8080", nil)
if err != nil {
panic(err)
}
}()
}

View File

@@ -0,0 +1,28 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
// ice-proxy demonstrates Pion WebRTC's proxy abilities.
package main
const (
turnServerAddr = "localhost:17342"
turnServerURL = "turn:" + turnServerAddr + "?transport=tcp"
turnUsername = "turn_username"
turnPassword = "turn_password"
)
func main() {
// Setup TURN server.
turnServer := newTURNServer()
defer turnServer.Close() // nolint:errcheck
// Setup answering agent with proxy and TURN.
setupAnsweringAgent()
// Setup offering agent with only direct communication.
setupOfferingAgent()
// Block forever
select {}
}

View File

@@ -0,0 +1,99 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
package main
import (
"bytes"
"encoding/json"
"log"
"net/http"
"time"
"github.com/pion/webrtc/v4"
)
// nolint:cyclop
func setupOfferingAgent() {
var settingEngine webrtc.SettingEngine
// Allow loopback candidates.
settingEngine.SetIncludeLoopbackCandidate(true)
api := webrtc.NewAPI(webrtc.WithSettingEngine(settingEngine))
// Create a new RTCPeerConnection.
peerConnection, err := api.NewPeerConnection(webrtc.Configuration{})
if err != nil {
panic(err)
}
// Log peer connection and ICE connection state changes.
peerConnection.OnConnectionStateChange(func(pcs webrtc.PeerConnectionState) {
log.Printf("[Offerer] Peer Connection State has changed: %s", pcs.String())
})
peerConnection.OnICEConnectionStateChange(func(ics webrtc.ICEConnectionState) {
log.Printf("[Offerer] ICE Connection State has changed: %s", ics.String())
})
// Create a data channel for measuring round-trip time.
dc, err := peerConnection.CreateDataChannel("data-channel", nil)
if err != nil {
panic(err)
}
dc.OnOpen(func() {
// Send the current time every 3 seconds.
for range time.Tick(3 * time.Second) {
if sendErr := dc.SendText(time.Now().Format(time.RFC3339Nano)); sendErr != nil {
panic(sendErr)
}
}
})
dc.OnMessage(func(msg webrtc.DataChannelMessage) {
// Receive the echoed time from the remote agent and calculate the round-trip time.
sendTime, parseErr := time.Parse(time.RFC3339Nano, string(msg.Data))
if parseErr != nil {
panic(parseErr)
}
log.Printf("[Offerer] Data channel round-trip time: %s", time.Since(sendTime))
})
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
// Create an offer to send to the answering agent.
offer, err := peerConnection.CreateOffer(nil)
if err != nil {
panic(err)
}
if err = peerConnection.SetLocalDescription(offer); err != nil {
panic(err)
}
// Block until ICE Gathering is complete, disabling trickle ICE.
// We do this because we only can exchange one signaling message.
// In a production application you should exchange ICE Candidates via OnICECandidate.
<-gatherComplete
offerJSON, err := json.Marshal(*peerConnection.LocalDescription())
if err != nil {
panic(err)
}
// Send offer to the answering agent.
// nolint:noctx
resp, err := http.Post("http://localhost:8080/sdp", "application/json", bytes.NewBuffer(offerJSON))
if err != nil {
panic(err)
}
defer resp.Body.Close() // nolint:errcheck
// Receive answer and set remote description.
var answer webrtc.SessionDescription
if err = json.NewDecoder(resp.Body).Decode(&answer); err != nil {
panic(err)
}
if err = peerConnection.SetRemoteDescription(answer); err != nil {
panic(err)
}
}

124
examples/ice-proxy/proxy.go Normal file
View File

@@ -0,0 +1,124 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
package main
import (
"bufio"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"golang.org/x/net/proxy"
)
var _ proxy.Dialer = &proxyDialer{}
type proxyDialer struct {
proxyAddr string
}
func newProxyDialer(u *url.URL) proxy.Dialer {
if u.Scheme != "http" {
panic("unsupported proxy scheme")
}
return &proxyDialer{
proxyAddr: u.Host,
}
}
func (d *proxyDialer) Dial(network, addr string) (net.Conn, error) {
if network != "tcp" && network != "tcp4" && network != "tcp6" {
panic("unsupported proxy network type")
}
conn, err := net.Dial(network, d.proxyAddr) // nolint: noctx
if err != nil {
panic(err)
}
// Create a CONNECT request to the proxy with target address.
req := &http.Request{
Method: http.MethodConnect,
URL: &url.URL{Host: addr},
Header: http.Header{
"Proxy-Connection": []string{"Keep-Alive"},
},
}
err = req.Write(conn)
if err != nil {
panic(err)
}
resp, err := http.ReadResponse(bufio.NewReader(conn), req)
if err != nil {
panic(err)
}
defer func() {
if err := resp.Body.Close(); err != nil {
log.Printf("close response body: %v", err)
}
}()
if resp.StatusCode != http.StatusOK {
panic("unexpected proxy status code: " + resp.Status)
}
return conn, nil
}
func newHTTPProxy() *url.URL {
listener, err := net.Listen("tcp", "localhost:0") // nolint: noctx
if err != nil {
panic(err)
}
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
go proxyHandleConn(conn)
}
}()
return &url.URL{
Scheme: "http",
Host: fmt.Sprintf("localhost:%d", listener.Addr().(*net.TCPAddr).Port), // nolint:forcetypeassert
}
}
func proxyHandleConn(clientConn net.Conn) {
// Read the request from the client
req, err := http.ReadRequest(bufio.NewReader(clientConn))
if err != nil {
panic(err)
}
if req.Method != http.MethodConnect {
panic("unexpected request method: " + req.Method)
}
// Establish a connection to the target server
targetConn, err := net.Dial("tcp", req.URL.Host) // nolint:noctx,gosec
if err != nil {
panic(err)
}
// Answer to the client with a 200 OK response
if _, err := clientConn.Write([]byte("HTTP/1.1 200 OK\r\n\r\n")); err != nil {
panic(err)
}
// Copy data between client and target
go io.Copy(clientConn, targetConn) // nolint: errcheck
go io.Copy(targetConn, clientConn) // nolint: errcheck
}

View File

@@ -0,0 +1,39 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
package main
import (
"net"
"github.com/pion/turn/v5"
)
func newTURNServer() *turn.Server {
tcpListener, err := net.Listen("tcp4", turnServerAddr) // nolint: noctx
if err != nil {
panic(err)
}
server, err := turn.NewServer(turn.ServerConfig{
AuthHandler: func(ra *turn.RequestAttributes) (string, []byte, bool) {
// Accept any request with provided username and password.
return turnUsername, turn.GenerateAuthKey(turnUsername, ra.Realm, turnPassword), true
},
ListenerConfigs: []turn.ListenerConfig{
{
Listener: tcpListener,
RelayAddressGenerator: &turn.RelayAddressGeneratorNone{
Address: "localhost",
},
},
},
})
if err != nil {
panic(err)
}
return server
}

View File

@@ -0,0 +1,26 @@
# ice-restart
ice-restart demonstrates Pion WebRTC's ICE Restart abilities.
## Instructions
### Download ice-restart
This example requires you to clone the repo since it is serving static HTML.
```
git clone https://github.com/pion/webrtc.git
cd webrtc/examples/ice-restart
```
### Run ice-restart
Execute `go run *.go`
### Open the Web UI
Open [http://localhost:8080](http://localhost:8080). This will automatically start a PeerConnection. This page will now prints stats about the PeerConnection
and allow you to do an ICE Restart at anytime.
* `ICE Restart` is the button that causes a new offer to be made with `iceRestart: true`.
* `ICE Connection States` will contain all the connection states the PeerConnection moves through.
* `ICE Selected Pairs` will print the selected pair every 3 seconds. Note how the uFrag/uPwd/Port change everytime you start the Restart process.
* `Inbound DataChannel Messages` containing the current time sent by the Pion process every 3 seconds.
Congrats, you have used Pion WebRTC! Now start building something cool

View File

@@ -0,0 +1,86 @@
<html>
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
<head>
<title>ice-restart</title>
</head>
<body>
<button onclick="window.doSignaling(true)"> ICE Restart </button><br />
<h3> ICE Connection States </h3>
<div id="iceConnectionStates"></div> <br />
<h3> ICE Selected Pairs </h3>
<div id="iceSelectedPairs"></div> <br />
<h3> Inbound DataChannel Messages </h3>
<div id="inboundDataChannelMessages"></div>
</body>
<script>
let pc = new RTCPeerConnection({
iceServers: [
{
urls: 'stun:stun.l.google.com:19302'
}
]
})
let dc = pc.createDataChannel('data')
dc.onopen = () => {
setInterval(function() {
let el = document.createElement('template')
let selectedPair = pc.sctp.transport.iceTransport.getSelectedCandidatePair()
el.innerHTML = `<div>
<ul>
<li> <i> Local</i> - ${selectedPair.local.candidate}</li>
<li> <i> Remote</i> - ${selectedPair.remote.candidate} </li>
</ul>
</div>`
document.getElementById('iceSelectedPairs').appendChild(el.content.firstChild);
}, 3000);
}
dc.onmessage = event => {
let el = document.createElement('p')
el.appendChild(document.createTextNode(event.data))
document.getElementById('inboundDataChannelMessages').appendChild(el);
}
pc.oniceconnectionstatechange = () => {
let el = document.createElement('p')
el.appendChild(document.createTextNode(pc.iceConnectionState))
document.getElementById('iceConnectionStates').appendChild(el);
}
window.doSignaling = iceRestart => {
pc.createOffer({iceRestart})
.then(offer => {
pc.setLocalDescription(offer)
return fetch(`/doSignaling`, {
method: 'post',
headers: {
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json'
},
body: JSON.stringify(offer)
})
})
.then(res => res.json())
.then(res => pc.setRemoteDescription(res))
.catch(alert)
}
window.doSignaling(false)
</script>
</html>

View File

@@ -0,0 +1,87 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
// ice-restart demonstrates Pion WebRTC's ICE Restart abilities.
package main
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/pion/webrtc/v4"
)
var peerConnection *webrtc.PeerConnection //nolint
// nolint: cyclop
func doSignaling(res http.ResponseWriter, req *http.Request) {
var err error
if peerConnection == nil {
if peerConnection, err = webrtc.NewPeerConnection(webrtc.Configuration{}); err != nil {
panic(err)
}
// Set the handler for ICE connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
fmt.Printf("ICE Connection State has changed: %s\n", connectionState.String())
})
// Send the current time via a DataChannel to the remote peer every 3 seconds
peerConnection.OnDataChannel(func(d *webrtc.DataChannel) {
d.OnOpen(func() {
for range time.Tick(time.Second * 3) {
if err = d.SendText(time.Now().String()); err != nil {
panic(err)
}
}
})
})
}
var offer webrtc.SessionDescription
if err = json.NewDecoder(req.Body).Decode(&offer); err != nil {
panic(err)
}
if err = peerConnection.SetRemoteDescription(offer); err != nil {
panic(err)
}
// Create channel that is blocked until ICE Gathering is complete
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
} else if err = peerConnection.SetLocalDescription(answer); err != nil {
panic(err)
}
// Block until ICE Gathering is complete, disabling trickle ICE
// we do this because we only can exchange one signaling message
// in a production application you should exchange ICE Candidates via OnICECandidate
<-gatherComplete
response, err := json.Marshal(*peerConnection.LocalDescription())
if err != nil {
panic(err)
}
res.Header().Set("Content-Type", "application/json")
if _, err := res.Write(response); err != nil {
panic(err)
}
}
func main() {
http.Handle("/", http.FileServer(http.Dir(".")))
http.HandleFunc("/doSignaling", doSignaling)
fmt.Println("Open http://localhost:8080 to access this demo")
// nolint: gosec
panic(http.ListenAndServe(":8080", nil))
}

View File

@@ -0,0 +1,26 @@
# ice-single-port
ice-single-port demonstrates Pion WebRTC's ability to serve many PeerConnections on a single port.
Pion WebRTC has no global state, so by default ports can't be shared between two PeerConnections.
Using the SettingEngine, a developer can manually share state between many PeerConnections to allow
multiple PeerConnections to use the same port.
## Instructions
### Download ice-single-port
This example requires you to clone the repo since it is serving static HTML.
```
git clone https://github.com/pion/webrtc.git
cd webrtc/examples/ice-single-port
```
### Run ice-single-port
Execute `go run *.go`
### Open the Web UI
Open [http://localhost:8080](http://localhost:8080). This will automatically open 10 PeerConnections. This page will print
a Local/Remote line for each PeerConnection. Note that all 10 PeerConnections have different ports for their Local port.
However for the remote they all will be using port 8443.
Congrats, you have used Pion WebRTC! Now start building something cool.

View File

@@ -0,0 +1,56 @@
<html>
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
<head>
<title>ice-single-port</title>
</head>
<body>
<h3> ICE Selected Pairs </h3>
<div id="iceSelectedPairs"></div> <br />
</body>
<script>
let createPeerConnection = () => {
let pc = new RTCPeerConnection()
let dc = pc.createDataChannel('data')
dc.onopen = () => {
let el = document.createElement('template')
let selectedPair = pc.sctp.transport.iceTransport.getSelectedCandidatePair()
el.innerHTML = `<div>
<ul>
<li> <i> Local</i> - ${selectedPair.local.candidate}</li>
<li> <i> Remote</i> - ${selectedPair.remote.candidate} </li>
</ul>
</div>`
document.getElementById('iceSelectedPairs').appendChild(el.content.firstChild);
}
pc.createOffer()
.then(offer => {
pc.setLocalDescription(offer)
return fetch(`/doSignaling`, {
method: 'post',
headers: {
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json'
},
body: JSON.stringify(offer)
})
})
.then(res => res.json())
.then(res => pc.setRemoteDescription(res))
.catch(alert)
}
for (i = 0; i < 10; i++) {
createPeerConnection()
}
</script>
</html>

View File

@@ -0,0 +1,104 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
// ice-single-port demonstrates Pion WebRTC's ability to serve many PeerConnections on a single port.
package main
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/pion/ice/v4"
"github.com/pion/webrtc/v4"
)
var api *webrtc.API //nolint
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
func doSignaling(res http.ResponseWriter, req *http.Request) {
peerConnection, err := api.NewPeerConnection(webrtc.Configuration{})
if err != nil {
panic(err)
}
// Set the handler for ICE connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
fmt.Printf("ICE Connection State has changed: %s\n", connectionState.String())
})
// Send the current time via a DataChannel to the remote peer every 3 seconds
peerConnection.OnDataChannel(func(d *webrtc.DataChannel) {
d.OnOpen(func() {
for range time.Tick(time.Second * 3) {
if err = d.SendText(time.Now().String()); err != nil {
panic(err)
}
}
})
})
var offer webrtc.SessionDescription
if err = json.NewDecoder(req.Body).Decode(&offer); err != nil {
panic(err)
}
if err = peerConnection.SetRemoteDescription(offer); err != nil {
panic(err)
}
// Create channel that is blocked until ICE Gathering is complete
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
} else if err = peerConnection.SetLocalDescription(answer); err != nil {
panic(err)
}
// Block until ICE Gathering is complete, disabling trickle ICE
// we do this because we only can exchange one signaling message
// in a production application you should exchange ICE Candidates via OnICECandidate
<-gatherComplete
response, err := json.Marshal(*peerConnection.LocalDescription())
if err != nil {
panic(err)
}
res.Header().Set("Content-Type", "application/json")
if _, err := res.Write(response); err != nil {
panic(err)
}
}
func main() {
// Create a SettingEngine, this allows non-standard WebRTC behavior
settingEngine := webrtc.SettingEngine{}
// Configure our SettingEngine to use our UDPMux. By default a PeerConnection has
// no global state. The API+SettingEngine allows the user to share state between them.
// In this case we are sharing our listening port across many.
// Listen on UDP Port 8443, will be used for all WebRTC traffic
mux, err := ice.NewMultiUDPMuxFromPort(8443)
if err != nil {
panic(err)
}
fmt.Printf("Listening for WebRTC traffic at %d\n", 8443)
settingEngine.SetICEUDPMux(mux)
// Create a new API using our SettingEngine
api = webrtc.NewAPI(webrtc.WithSettingEngine(settingEngine))
http.Handle("/", http.FileServer(http.Dir(".")))
http.HandleFunc("/doSignaling", doSignaling)
fmt.Println("Open http://localhost:8080 to access this demo")
// nolint: gosec
panic(http.ListenAndServe(":8080", nil))
}

View File

@@ -0,0 +1,20 @@
# ice-tcp
ice-tcp demonstrates Pion WebRTC's ICE TCP abilities.
## Instructions
### Download ice-tcp
This example requires you to clone the repo since it is serving static HTML.
```
git clone https://github.com/pion/webrtc.git
cd webrtc/examples/ice-tcp
```
### Run ice-tcp
Execute `go run *.go`
### Open the Web UI
Open [http://localhost:8080](http://localhost:8080). This will automatically start a PeerConnection. This page will now prints stats about the PeerConnection. The UDP candidates will be filtered out from the SDP.
Congrats, you have used Pion WebRTC! Now start building something cool

View File

@@ -0,0 +1,57 @@
<html>
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
<head>
<title>ice-tcp</title>
</head>
<body>
<h1>ICE TCP</h1>
<h3> ICE Connection States </h3>
<div id="iceConnectionStates"></div> <br />
<h3> Inbound DataChannel Messages </h3>
<div id="inboundDataChannelMessages"></div>
</body>
<script>
let pc = new RTCPeerConnection()
let dc = pc.createDataChannel('data')
dc.onmessage = event => {
let el = document.createElement('p')
el.appendChild(document.createTextNode(event.data))
document.getElementById('inboundDataChannelMessages').appendChild(el);
}
pc.oniceconnectionstatechange = () => {
let el = document.createElement('p')
el.appendChild(document.createTextNode(pc.iceConnectionState))
document.getElementById('iceConnectionStates').appendChild(el);
}
pc.createOffer()
.then(offer => {
pc.setLocalDescription(offer)
return fetch(`/doSignaling`, {
method: 'post',
headers: {
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json'
},
body: JSON.stringify(offer)
})
})
.then(res => res.json())
.then(res => {
pc.setRemoteDescription(res)
})
.catch(alert)
</script>
</html>

115
examples/ice-tcp/main.go Normal file
View File

@@ -0,0 +1,115 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
// ice-tcp demonstrates Pion WebRTC's ICE TCP abilities.
package main
import (
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"time"
"github.com/pion/webrtc/v4"
)
var api *webrtc.API //nolint
func doSignaling(res http.ResponseWriter, req *http.Request) { //nolint:cyclop
peerConnection, err := api.NewPeerConnection(webrtc.Configuration{})
if err != nil {
panic(err)
}
// Set the handler for ICE connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
fmt.Printf("ICE Connection State has changed: %s\n", connectionState.String())
})
// Send the current time via a DataChannel to the remote peer every 3 seconds
peerConnection.OnDataChannel(func(d *webrtc.DataChannel) {
d.OnOpen(func() {
for range time.Tick(time.Second * 3) {
if err = d.SendText(time.Now().String()); err != nil {
if errors.Is(err, io.ErrClosedPipe) {
return
}
panic(err)
}
}
})
})
var offer webrtc.SessionDescription
if err = json.NewDecoder(req.Body).Decode(&offer); err != nil {
panic(err)
}
if err = peerConnection.SetRemoteDescription(offer); err != nil {
panic(err)
}
// Create channel that is blocked until ICE Gathering is complete
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
} else if err = peerConnection.SetLocalDescription(answer); err != nil {
panic(err)
}
// Block until ICE Gathering is complete, disabling trickle ICE
// we do this because we only can exchange one signaling message
// in a production application you should exchange ICE Candidates via OnICECandidate
<-gatherComplete
response, err := json.Marshal(*peerConnection.LocalDescription())
if err != nil {
panic(err)
}
res.Header().Set("Content-Type", "application/json")
if _, err := res.Write(response); err != nil {
panic(err)
}
}
//nolint:cyclop
func main() {
settingEngine := webrtc.SettingEngine{}
// Enable support only for TCP ICE candidates.
settingEngine.SetNetworkTypes([]webrtc.NetworkType{
webrtc.NetworkTypeTCP4,
webrtc.NetworkTypeTCP6,
})
tcpListener, err := net.ListenTCP("tcp", &net.TCPAddr{
IP: net.IP{0, 0, 0, 0},
Port: 8443,
})
if err != nil {
panic(err)
}
fmt.Printf("Listening for ICE TCP at %s\n", tcpListener.Addr())
tcpMux := webrtc.NewICETCPMux(nil, tcpListener, 8)
settingEngine.SetICETCPMux(tcpMux)
api = webrtc.NewAPI(webrtc.WithSettingEngine(settingEngine))
http.Handle("/", http.FileServer(http.Dir(".")))
http.HandleFunc("/doSignaling", doSignaling)
fmt.Println("Open http://localhost:8080 to access this demo")
// nolint: gosec
panic(http.ListenAndServe(":8080", nil))
}

70
examples/index.html Normal file
View File

@@ -0,0 +1,70 @@
<html>
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<link href="https://fonts.googleapis.com/css?family=Quicksand:400,500,700" rel="stylesheet">
<title>WebRTC examples! | Pion</title>
</head>
<style>
body {
font-family: "Quicksand", sans-serif;
font-weight: 400;
margin: 4em 10%;
}
.container {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.card {
box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2);
transition: 0.3s;
margin: 20px auto;
flex-grow: 1;
max-width: 500px;
}
.card:hover {
box-shadow: 0 8px 16px 0 rgba(0,0,0,0.2);
}
.content {
line-height: 1.2em;
padding: 0 16px;
}
.header {
color:#ffffff;
background-image: linear-gradient(225deg, #eb6562 0%, #E53935 100%);
padding: 6px 16px 3px 16px;
}
h3 {
margin: 0;
}
</style>
<body>
<h1>Pion WebRTC examples</h1>
<div class="container">
{{range .}}
<div class="card">
<div class="header">
<h3>{{ .Title }}</h3>
</div>
<div class="content">
<p>{{ .Description }}</p>
{{ if .IsJS}}<p><a href="/example/js/{{ .Link }}/">Run JavaScript</a></p>{{ end }}
{{ if .IsWASM}}<p><a href="/example/wasm/{{ .Link }}/">Run WASM</a></p>{{ end }}
</div>
</div>
{{else}}
<li><strong>No examples found!</strong></li>
{{end}}
</div>
</body>
</html>

View File

@@ -0,0 +1,41 @@
# insertable-streams
insertable-streams demonstrates how to use insertable streams with Pion.
This example modifies the video with a single-byte XOR cipher before sending, and then
decrypts in Javascript.
insertable-streams allows the browser to process encoded video. You could implement
E2E encryption, add metadata or insert a completely different video feed!
## Instructions
### Create IVF named `output.ivf` that contains a VP8 track
```
ffmpeg -i $INPUT_FILE -g 30 output.ivf
```
### Download insertable-streams
```
go install github.com/pion/webrtc/v4/examples/insertable-streams@latest
```
### Open insertable-streams example page
[jsfiddle.net](https://jsfiddle.net/t5xoaryc/) you should see two text-areas and a 'Start Session' button. You will also have a 'Decrypt' checkbox.
When unchecked the browser will not decrypt the incoming video stream, so it will stop playing or display certificates.
### Run insertable-streams with your browsers SessionDescription as stdin
The `output.ivf` you created should be in the same directory as `insertable-streams`. In the jsfiddle the top textarea is your browser, copy that and:
#### Linux/macOS
Run `echo $BROWSER_SDP | insertable-streams`
#### Windows
1. Paste the SessionDescription into a file.
1. Run `insertable-streams < my_file`
### Input insertable-streams's SessionDescription into your browser
Copy the text that `insertable-streams` just emitted and copy into second text area
### Hit 'Start Session' in jsfiddle, enjoy your video!
A video should start playing in your browser above the input boxes. `insertable-streams` will exit when the file reaches the end.
To stop decrypting the stream uncheck the box and the video will not be viewable.
Congrats, you have used Pion WebRTC! Now start building something cool

View File

@@ -0,0 +1,8 @@
/*
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
*/
textarea {
width: 500px;
min-height: 75px;
}

View File

@@ -0,0 +1,8 @@
---
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
name: play-from-disk
description: play-from-disk demonstrates how to send video to your browser from a file saved to disk.
authors:
- Sean DuBois

View File

@@ -0,0 +1,22 @@
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
<div id="no-support-banner" style="background-color: red">
<h1> Browser does not support insertable streams </h1>
</div>
Browser base64 Session Description<br />
<textarea id="localSessionDescription" readonly="true"></textarea> <br />
Golang base64 Session Description<br />
<textarea id="remoteSessionDescription"> </textarea> <br/>
<button onclick="window.startSession()"> Start Session </button> Decrypt Video <input type="checkbox" checked="checked" onclick="window.toggleDecryption()"/> <br />
<br />
Video<br />
<video id="remote-video" playsinline autoplay controls style="width: 640; height: 480"></video> <br />
Logs<br />
<div id="div"></div>

View File

@@ -0,0 +1,98 @@
/* eslint-env browser */
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
// cipherKey that video is encrypted with
const cipherKey = 0xAA
const pc = new RTCPeerConnection({ encodedInsertableStreams: true, forceEncodedVideoInsertableStreams: true })
const log = msg => {
document.getElementById('div').innerHTML += msg + '<br>'
}
// Offer to receive 1 video
const transceiver = pc.addTransceiver('video')
// The API has seen two iterations, support both
// In the future this will just be `createEncodedStreams`
const receiverStreams = getInsertableStream(transceiver)
// boolean controlled by checkbox to enable/disable encryption
let applyDecryption = true
window.toggleDecryption = () => {
applyDecryption = !applyDecryption
}
// Loop that is called for each video frame
const reader = receiverStreams.readable.getReader()
const writer = receiverStreams.writable.getWriter()
reader.read().then(function processVideo ({ done, value }) {
const decrypted = new DataView(value.data)
if (applyDecryption) {
for (let i = 0; i < decrypted.buffer.byteLength; i++) {
decrypted.setInt8(i, decrypted.getInt8(i) ^ cipherKey)
}
}
value.data = decrypted.buffer
writer.write(value)
return reader.read().then(processVideo)
})
// Fire when remote video arrives
pc.ontrack = function (event) {
document.getElementById('remote-video').srcObject = event.streams[0]
document.getElementById('remote-video').style = ''
}
// Populate SDP field when finished gathering
pc.oniceconnectionstatechange = e => log(pc.iceConnectionState)
pc.onicecandidate = event => {
if (event.candidate === null) {
document.getElementById('localSessionDescription').value = btoa(JSON.stringify(pc.localDescription))
}
}
pc.createOffer().then(d => pc.setLocalDescription(d)).catch(log)
window.startSession = () => {
const sd = document.getElementById('remoteSessionDescription').value
if (sd === '') {
return alert('Session Description must not be empty')
}
try {
pc.setRemoteDescription(JSON.parse(atob(sd)))
} catch (e) {
alert(e)
}
}
// DOM code to show banner if insertable streams not supported
let insertableStreamsSupported = true
const updateSupportBanner = () => {
const el = document.getElementById('no-support-banner')
if (insertableStreamsSupported && el) {
el.style = 'display: none'
}
}
document.addEventListener('DOMContentLoaded', updateSupportBanner)
// Shim to support both versions of API
function getInsertableStream (transceiver) {
let insertableStreams = null
if (transceiver.receiver.createEncodedVideoStreams) {
insertableStreams = transceiver.receiver.createEncodedVideoStreams()
} else if (transceiver.receiver.createEncodedStreams) {
insertableStreams = transceiver.receiver.createEncodedStreams()
}
if (!insertableStreams) {
insertableStreamsSupported = false
updateSupportBanner()
throw new Error('Insertable Streams are not supported')
}
return insertableStreams
}

View File

@@ -0,0 +1,224 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
// insertable-streams demonstrates how to use insertable streams with Pion
package main
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
"github.com/pion/webrtc/v4/pkg/media/ivfreader"
)
const cipherKey = 0xAA
// nolint:gocognit, cyclop
func main() {
peerConnection, err := webrtc.NewPeerConnection(webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
})
if err != nil {
panic(err)
}
defer func() {
if cErr := peerConnection.Close(); cErr != nil {
fmt.Printf("cannot close peerConnection: %v\n", cErr)
}
}()
// Create a video track
videoTrack, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8}, "video", "pion",
)
if err != nil {
panic(err)
}
rtpSender, err := peerConnection.AddTrack(videoTrack)
if err != nil {
panic(err)
}
// Read incoming RTCP packets
// Before these packets are returned they are processed by interceptors. For things
// like NACK this needs to be called.
go func() {
rtcpBuf := make([]byte, 1500)
for {
if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
return
}
}
}()
iceConnectedCtx, iceConnectedCtxCancel := context.WithCancel(context.Background())
go func() {
// Open a IVF file and start reading using our IVFReader
file, ivfErr := os.Open("output.ivf")
if ivfErr != nil {
panic(ivfErr)
}
ivf, header, ivfErr := ivfreader.NewWith(file)
if ivfErr != nil {
panic(ivfErr)
}
// Wait for connection established
<-iceConnectedCtx.Done()
// Send our video file frame at a time. Pace our sending so we send it at the same speed it should be played back as.
// This isn't required since the video is timestamped, but we will such much higher loss if we send all at once.
//
// It is important to use a time.Ticker instead of time.Sleep because
// * avoids accumulating skew, just calling time.Sleep didn't compensate for the time spent parsing the data
// * works around latency issues with Sleep (see https://github.com/golang/go/issues/44343)
ticker := time.NewTicker(
time.Millisecond * time.Duration((float32(header.TimebaseNumerator)/float32(header.TimebaseDenominator))*1000),
)
defer ticker.Stop()
for range ticker.C {
frame, _, ivfErr := ivf.ParseNextFrame()
if errors.Is(ivfErr, io.EOF) {
fmt.Printf("All frames parsed and sent")
os.Exit(0)
}
if ivfErr != nil {
panic(ivfErr)
}
// Encrypt video using XOR Cipher
for i := range frame {
frame[i] ^= cipherKey
}
if ivfErr = videoTrack.WriteSample(media.Sample{Data: frame, Duration: time.Second}); ivfErr != nil {
panic(ivfErr)
}
}
}()
// Set the handler for ICE connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
fmt.Printf("Connection State has changed %s \n", connectionState.String())
if connectionState == webrtc.ICEConnectionStateConnected {
iceConnectedCtxCancel()
}
})
// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
if state == webrtc.PeerConnectionStateFailed {
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
// It may be reconnected using an ICE Restart.
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
fmt.Println("Peer Connection has gone to failed exiting")
os.Exit(0)
}
if state == webrtc.PeerConnectionStateClosed {
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
fmt.Println("Peer Connection has gone to closed exiting")
os.Exit(0)
}
})
// Wait for the offer to be pasted
offer := webrtc.SessionDescription{}
decode(readUntilNewline(), &offer)
// Set the remote SessionDescription
if err = peerConnection.SetRemoteDescription(offer); err != nil {
panic(err)
}
// Create answer
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
}
// Create channel that is blocked until ICE Gathering is complete
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
// Sets the LocalDescription, and starts our UDP listeners
if err = peerConnection.SetLocalDescription(answer); err != nil {
panic(err)
}
// Block until ICE Gathering is complete, disabling trickle ICE
// we do this because we only can exchange one signaling message
// in a production application you should exchange ICE Candidates via OnICECandidate
<-gatherComplete
// Output the answer in base64 so we can paste it in browser
fmt.Println(encode(peerConnection.LocalDescription()))
// Block forever
select {}
}
// Read from stdin until we get a newline.
func readUntilNewline() (in string) {
var err error
r := bufio.NewReader(os.Stdin)
for {
in, err = r.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
panic(err)
}
if in = strings.TrimSpace(in); len(in) > 0 {
break
}
}
fmt.Println("")
return
}
// JSON encode + base64 a SessionDescription.
func encode(obj *webrtc.SessionDescription) string {
b, err := json.Marshal(obj)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(b)
}
// Decode a base64 and unmarshal JSON into a SessionDescription.
func decode(in string, obj *webrtc.SessionDescription) {
b, err := base64.StdEncoding.DecodeString(in)
if err != nil {
panic(err)
}
if err = json.Unmarshal(b, obj); err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,46 @@
# ortc-media
ortc demonstrates Pion WebRTC's [ORTC](https://ortc.org/) capabilities. Instead of using the Session Description Protocol
to configure and communicate ORTC provides APIs. Users then can implement signaling with whatever protocol they wish.
ORTC can then be used to implement WebRTC. A ORTC implementation can parse/emit Session Description and act as a WebRTC
implementation.
In this example we have defined a simple JSON based signaling protocol.
## Instructions
### Create IVF named `output.ivf` that contains a VP8/VP9/AV1 track
```
ffmpeg -i $INPUT_FILE -g 30 -b:v 2M output.ivf
```
**Note**: In the `ffmpeg` command which produces the .ivf file, the argument `-b:v 2M` specifies the video bitrate to be 2 megabits per second. We provide this default value to produce decent video quality, but if you experience problems with this configuration (such as dropped frames etc.), you can decrease this. See the [ffmpeg documentation](https://ffmpeg.org/ffmpeg.html#Options) for more information on the format of the value.
### Download ortc-media
```
go install github.com/pion/webrtc/v4/examples/ortc-media@latest
```
### Run first client as offerer
`ortc-media -offer` this will emit a base64 message. Copy this message to your clipboard.
## Run the second client as answerer
Run the second client. This should be launched with the message you copied in the previous step as stdin.
`echo BASE64_MESSAGE_YOU_COPIED | ortc-media`
This will emit another base64 message. Copy this new message.
## Send base64 message to first client via CURL
* Run `curl localhost:8080 -d "BASE64_MESSAGE_YOU_COPIED"`. `BASE64_MESSAGE_YOU_COPIED` is the value you copied in the last step.
### Enjoy
The client that accepts media will print when it gets the first media packet. The SSRC will be different every run.
```
Got RTP Packet with SSRC 3097857772
```
Media packets will continue to flow until the end of the file has been reached.
Congrats, you have used Pion WebRTC! Now start building something cool

314
examples/ortc-media/main.go Normal file
View File

@@ -0,0 +1,314 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
// ortc demonstrates Pion WebRTC's ORTC capabilities.
package main
import (
"bufio"
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
"github.com/pion/webrtc/v4/pkg/media/ivfreader"
)
const (
videoFileName = "output.ivf"
)
// nolint:cyclop
func main() {
isOffer := flag.Bool("offer", false, "Act as the offerer if set")
port := flag.Int("port", 8080, "http server port")
flag.Parse()
// Everything below is the Pion WebRTC (ORTC) API! Thanks for using it ❤️.
// Prepare ICE gathering options
iceOptions := webrtc.ICEGatherOptions{
ICEServers: []webrtc.ICEServer{
{URLs: []string{"stun:stun.l.google.com:19302"}},
},
}
// Use default Codecs
mediaEngine := &webrtc.MediaEngine{}
if err := mediaEngine.RegisterDefaultCodecs(); err != nil {
panic(err)
}
// Create an API object
api := webrtc.NewAPI(webrtc.WithMediaEngine(mediaEngine))
// Create the ICE gatherer
gatherer, err := api.NewICEGatherer(iceOptions)
if err != nil {
panic(err)
}
// Construct the ICE transport
ice := api.NewICETransport(gatherer)
// Construct the DTLS transport
dtls, err := api.NewDTLSTransport(ice, nil)
if err != nil {
panic(err)
}
// Create a RTPSender or RTPReceiver
var (
rtpReceiver *webrtc.RTPReceiver
rtpSendParameters webrtc.RTPSendParameters
)
if *isOffer { //nolint:nestif
// Open the video file
file, fileErr := os.Open(videoFileName)
if fileErr != nil {
panic(fileErr)
}
// Read the header of the video file
ivf, header, fileErr := ivfreader.NewWith(file)
if fileErr != nil {
panic(fileErr)
}
trackLocal := fourCCToTrack(header.FourCC)
// Create RTPSender to send our video file
rtpSender, fileErr := api.NewRTPSender(trackLocal, dtls)
if fileErr != nil {
panic(fileErr)
}
rtpSendParameters = rtpSender.GetParameters()
if fileErr = rtpSender.Send(rtpSendParameters); fileErr != nil {
panic(fileErr)
}
go writeFileToTrack(ivf, header, trackLocal)
} else {
if rtpReceiver, err = api.NewRTPReceiver(webrtc.RTPCodecTypeVideo, dtls); err != nil {
panic(err)
}
}
gatherFinished := make(chan struct{})
gatherer.OnLocalCandidate(func(candidate *webrtc.ICECandidate) {
if candidate == nil {
close(gatherFinished)
}
})
// Gather candidates
if err = gatherer.Gather(); err != nil {
panic(err)
}
<-gatherFinished
iceCandidates, err := gatherer.GetLocalCandidates()
if err != nil {
panic(err)
}
iceParams, err := gatherer.GetLocalParameters()
if err != nil {
panic(err)
}
dtlsParams, err := dtls.GetLocalParameters()
if err != nil {
panic(err)
}
signal := Signal{
ICECandidates: iceCandidates,
ICEParameters: iceParams,
DTLSParameters: dtlsParams,
RTPSendParameters: rtpSendParameters,
}
iceRole := webrtc.ICERoleControlled
// Exchange the information
fmt.Println(encode(&signal))
remoteSignal := Signal{}
if *isOffer {
signalingChan := httpSDPServer(*port)
decode(<-signalingChan, &remoteSignal)
iceRole = webrtc.ICERoleControlling
} else {
decode(readUntilNewline(), &remoteSignal)
}
if err = ice.SetRemoteCandidates(remoteSignal.ICECandidates); err != nil {
panic(err)
}
// Start the ICE transport
if err = ice.Start(nil, remoteSignal.ICEParameters, &iceRole); err != nil {
panic(err)
}
// Start the DTLS transport
if err = dtls.Start(remoteSignal.DTLSParameters); err != nil {
panic(err)
}
if !*isOffer {
if err = rtpReceiver.Receive(webrtc.RTPReceiveParameters{
Encodings: []webrtc.RTPDecodingParameters{
{
RTPCodingParameters: remoteSignal.RTPSendParameters.Encodings[0].RTPCodingParameters,
},
},
}); err != nil {
panic(err)
}
remoteTrack := rtpReceiver.Track()
pkt, _, err := remoteTrack.ReadRTP()
if err != nil {
panic(err)
}
fmt.Printf("Got RTP Packet with SSRC %d \n", pkt.SSRC)
}
select {}
}
// Given a FourCC value return a Track.
func fourCCToTrack(fourCC string) *webrtc.TrackLocalStaticSample {
// Determine video codec
var trackCodec string
switch fourCC {
case "AV01":
trackCodec = webrtc.MimeTypeAV1
case "VP90":
trackCodec = webrtc.MimeTypeVP9
case "VP80":
trackCodec = webrtc.MimeTypeVP8
default:
panic(fmt.Sprintf("Unable to handle FourCC %s", fourCC))
}
// Create a video Track with the codec of the file
trackLocal, err := webrtc.NewTrackLocalStaticSample(webrtc.RTPCodecCapability{MimeType: trackCodec}, "video", "pion")
if err != nil {
panic(err)
}
return trackLocal
}
// Write a file to Track.
func writeFileToTrack(ivf *ivfreader.IVFReader, header *ivfreader.IVFFileHeader, track *webrtc.TrackLocalStaticSample) {
ticker := time.NewTicker(
time.Millisecond * time.Duration((float32(header.TimebaseNumerator)/float32(header.TimebaseDenominator))*1000),
)
defer ticker.Stop()
for ; true; <-ticker.C {
frame, _, err := ivf.ParseNextFrame()
if errors.Is(err, io.EOF) {
fmt.Printf("All video frames parsed and sent")
os.Exit(0) //nolint: gocritic
}
if err != nil {
panic(err)
}
if err = track.WriteSample(media.Sample{Data: frame, Duration: time.Second}); err != nil {
panic(err)
}
}
}
// Signal is used to exchange signaling info.
// This is not part of the ORTC spec. You are free
// to exchange this information any way you want.
type Signal struct {
ICECandidates []webrtc.ICECandidate `json:"iceCandidates"`
ICEParameters webrtc.ICEParameters `json:"iceParameters"`
DTLSParameters webrtc.DTLSParameters `json:"dtlsParameters"`
RTPSendParameters webrtc.RTPSendParameters `json:"rtpSendParameters"`
}
// Read from stdin until we get a newline.
func readUntilNewline() (in string) {
var err error
r := bufio.NewReader(os.Stdin)
for {
in, err = r.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
panic(err)
}
if in = strings.TrimSpace(in); len(in) > 0 {
break
}
}
fmt.Println("")
return
}
// JSON encode + base64 a SessionDescription.
func encode(obj *Signal) string {
b, err := json.Marshal(obj)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(b)
}
// Decode a base64 and unmarshal JSON into a SessionDescription.
func decode(in string, obj *Signal) {
b, err := base64.StdEncoding.DecodeString(in)
if err != nil {
panic(err)
}
if err = json.Unmarshal(b, obj); err != nil {
panic(err)
}
}
// httpSDPServer starts a HTTP Server that consumes SDPs.
func httpSDPServer(port int) chan string {
sdpChan := make(chan string)
http.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) {
body, _ := io.ReadAll(req.Body)
fmt.Fprintf(res, "done") //nolint: errcheck
sdpChan <- string(body)
})
go func() {
// nolint: gosec
panic(http.ListenAndServe(":"+strconv.Itoa(port), nil))
}()
return sdpChan
}

34
examples/ortc/README.md Normal file
View File

@@ -0,0 +1,34 @@
# ortc
ortc demonstrates Pion WebRTC's [ORTC](https://ortc.org/) capabilities. Instead of using the Session Description Protocol
to configure and communicate ORTC provides APIs. Users then can implement signaling with whatever protocol they wish.
ORTC can then be used to implement WebRTC. A ORTC implementation can parse/emit Session Description and act as a WebRTC
implementation.
In this example we have defined a simple JSON based signaling protocol.
## Instructions
### Download ortc
```
go install github.com/pion/webrtc/v4/examples/ortc@latest
```
### Run first client as offerer
`ortc -offer` this will emit a base64 message. Copy this message to your clipboard.
## Run the second client as answerer
Run the second client. This should be launched with the message you copied in the previous step as stdin.
`echo $BASE64_MESSAGE_YOU_COPIED | ortc`
This will emit another base64 message. Copy this new message.
## Send base64 message to first client via CURL
* Run `curl localhost:8080 -d "BASE64_MESSAGE_YOU_COPIED"`. `BASE64_MESSAGE_YOU_COPIED` is the value you copied in the last step.
### Enjoy
If everything worked you will see `Data channel 'Foo'-'' open.` in each terminal.
Each client will send random messages every 5 seconds that will appear in the terminal
Congrats, you have used Pion WebRTC! Now start building something cool

263
examples/ortc/main.go Normal file
View File

@@ -0,0 +1,263 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
// ortc demonstrates Pion WebRTC's ORTC capabilities.
package main
import (
"bufio"
"encoding/base64"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/pion/randutil"
"github.com/pion/webrtc/v4"
)
// nolint:cyclop
func main() {
isOffer := flag.Bool("offer", false, "Act as the offerer if set")
port := flag.Int("port", 8080, "http server port")
flag.Parse()
// Everything below is the Pion WebRTC (ORTC) API! Thanks for using it ❤️.
// Prepare ICE gathering options
iceOptions := webrtc.ICEGatherOptions{
ICEServers: []webrtc.ICEServer{
{URLs: []string{"stun:stun.l.google.com:19302"}},
},
}
// Create an API object
api := webrtc.NewAPI()
// Create the ICE gatherer
gatherer, err := api.NewICEGatherer(iceOptions)
if err != nil {
panic(err)
}
// Construct the ICE transport
ice := api.NewICETransport(gatherer)
// Construct the DTLS transport
dtls, err := api.NewDTLSTransport(ice, nil)
if err != nil {
panic(err)
}
// Construct the SCTP transport
sctp := api.NewSCTPTransport(dtls)
// Handle incoming data channels
sctp.OnDataChannel(func(channel *webrtc.DataChannel) {
fmt.Printf("New DataChannel %s %d\n", channel.Label(), channel.ID())
// Register the handlers
channel.OnOpen(handleOnOpen(channel))
channel.OnMessage(func(msg webrtc.DataChannelMessage) {
fmt.Printf("Message from DataChannel '%s': '%s'\n", channel.Label(), string(msg.Data))
})
})
gatherFinished := make(chan struct{})
gatherer.OnLocalCandidate(func(candidate *webrtc.ICECandidate) {
if candidate == nil {
close(gatherFinished)
}
})
// Gather candidates
if err = gatherer.Gather(); err != nil {
panic(err)
}
<-gatherFinished
iceCandidates, err := gatherer.GetLocalCandidates()
if err != nil {
panic(err)
}
iceParams, err := gatherer.GetLocalParameters()
if err != nil {
panic(err)
}
dtlsParams, err := dtls.GetLocalParameters()
if err != nil {
panic(err)
}
sctpCapabilities := sctp.GetCapabilities()
s := Signal{
ICECandidates: iceCandidates,
ICEParameters: iceParams,
DTLSParameters: dtlsParams,
SCTPCapabilities: sctpCapabilities,
}
iceRole := webrtc.ICERoleControlled
// Exchange the information
fmt.Println(encode(s))
remoteSignal := Signal{}
if *isOffer {
signalingChan := httpSDPServer(*port)
decode(<-signalingChan, &remoteSignal)
iceRole = webrtc.ICERoleControlling
} else {
decode(readUntilNewline(), &remoteSignal)
}
if err = ice.SetRemoteCandidates(remoteSignal.ICECandidates); err != nil {
panic(err)
}
// Start the ICE transport
err = ice.Start(nil, remoteSignal.ICEParameters, &iceRole)
if err != nil {
panic(err)
}
// Start the DTLS transport
if err = dtls.Start(remoteSignal.DTLSParameters); err != nil {
panic(err)
}
// Start the SCTP transport
if err = sctp.Start(remoteSignal.SCTPCapabilities); err != nil {
panic(err)
}
// Construct the data channel as the offerer
if *isOffer {
var id uint16 = 1
dcParams := &webrtc.DataChannelParameters{
Label: "Foo",
ID: &id,
}
var channel *webrtc.DataChannel
channel, err = api.NewDataChannel(sctp, dcParams)
if err != nil {
panic(err)
}
// Register the handlers
// channel.OnOpen(handleOnOpen(channel)) // TODO: OnOpen on handle ChannelAck
go handleOnOpen(channel)() // Temporary alternative
channel.OnMessage(func(msg webrtc.DataChannelMessage) {
fmt.Printf("Message from DataChannel '%s': '%s'\n", channel.Label(), string(msg.Data))
})
}
select {}
}
// Signal is used to exchange signaling info.
// This is not part of the ORTC spec. You are free
// to exchange this information any way you want.
type Signal struct {
ICECandidates []webrtc.ICECandidate `json:"iceCandidates"`
ICEParameters webrtc.ICEParameters `json:"iceParameters"`
DTLSParameters webrtc.DTLSParameters `json:"dtlsParameters"`
SCTPCapabilities webrtc.SCTPCapabilities `json:"sctpCapabilities"`
}
func handleOnOpen(channel *webrtc.DataChannel) func() {
return func() {
fmt.Printf(
"Data channel '%s'-'%d' open. Random messages will now be sent to any connected DataChannels every 5 seconds\n",
channel.Label(), channel.ID(),
)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
message, err := randutil.GenerateCryptoRandomString(15, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
if err != nil {
panic(err)
}
fmt.Printf("Sending %s \n", message)
if err := channel.SendText(message); err != nil {
panic(err)
}
}
}
}
// Read from stdin until we get a newline.
func readUntilNewline() (in string) {
var err error
r := bufio.NewReader(os.Stdin)
for {
in, err = r.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
panic(err)
}
if in = strings.TrimSpace(in); len(in) > 0 {
break
}
}
fmt.Println("")
return
}
// JSON encode + base64 a SessionDescription.
func encode(obj Signal) string {
b, err := json.Marshal(obj)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(b)
}
// Decode a base64 and unmarshal JSON into a SessionDescription.
func decode(in string, obj *Signal) {
b, err := base64.StdEncoding.DecodeString(in)
if err != nil {
panic(err)
}
if err = json.Unmarshal(b, obj); err != nil {
panic(err)
}
}
// httpSDPServer starts a HTTP Server that consumes SDPs.
func httpSDPServer(port int) chan string {
sdpChan := make(chan string)
http.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) {
body, _ := io.ReadAll(req.Body)
fmt.Fprintf(res, "done") //nolint: errcheck
sdpChan <- string(body)
})
go func() {
// nolint: gosec
panic(http.ListenAndServe(":"+strconv.Itoa(port), nil))
}()
return sdpChan
}

View File

@@ -0,0 +1,26 @@
# pion-to-pion
pion-to-pion is an example of two pion instances communicating directly!
The SDP offer and answer are exchanged automatically over HTTP.
The `answer` side acts like a HTTP server and should therefore be ran first.
## Instructions
First run `answer`:
```sh
go install github.com/pion/webrtc/v4/examples/pion-to-pion/answer@latest
answer
```
Next, run `offer`:
```sh
go install github.com/pion/webrtc/v4/examples/pion-to-pion/offer@latest
offer
```
You should see them connect and start to exchange messages.
## You can use Docker-compose to start this example:
```sh
docker-compose up -d
```
Now, you can see message exchanging, using `docker logs`.

View File

@@ -0,0 +1,10 @@
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
FROM golang:1.27
RUN go install github.com/pion/webrtc/v4/examples/pion-to-pion/answer@latest
CMD ["answer"]
EXPOSE 50000

View File

@@ -0,0 +1,213 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
// pion-to-pion is an example of two pion instances communicating directly!
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"sync"
"time"
"github.com/pion/randutil"
"github.com/pion/webrtc/v4"
)
func signalCandidate(addr string, candidate *webrtc.ICECandidate) error {
payload := []byte(candidate.ToJSON().Candidate)
resp, err := http.Post( // nolint:noctx
fmt.Sprintf("http://%s/candidate", addr),
"application/json; charset=utf-8",
bytes.NewReader(payload),
)
if err != nil {
return err
}
return resp.Body.Close()
}
// nolint:gocognit, cyclop
func main() {
offerAddr := flag.String("offer-address", "127.0.0.1:50000", "Address that the Offer HTTP server is hosted on.")
answerAddr := flag.String("answer-address", ":60000", "Address that the Answer HTTP server is hosted on.")
flag.Parse()
var candidatesMux sync.Mutex
pendingCandidates := make([]*webrtc.ICECandidate, 0)
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
// Prepare the configuration
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
}
// Create a new RTCPeerConnection
peerConnection, err := webrtc.NewPeerConnection(config)
if err != nil {
panic(err)
}
defer func() {
if closeErr := peerConnection.Close(); closeErr != nil {
fmt.Printf("cannot close peerConnection: %v\n", closeErr)
}
}()
// When an ICE candidate is available send to the other Pion instance
// the other Pion instance will add this candidate by calling AddICECandidate
peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate == nil {
return
}
candidatesMux.Lock()
defer candidatesMux.Unlock()
desc := peerConnection.RemoteDescription()
if desc == nil {
pendingCandidates = append(pendingCandidates, candidate)
} else if onICECandidateErr := signalCandidate(*offerAddr, candidate); onICECandidateErr != nil {
panic(onICECandidateErr)
}
})
// A HTTP handler that allows the other Pion instance to send us ICE candidates
// This allows us to add ICE candidates faster, we don't have to wait for STUN or TURN
// candidates which may be slower
http.HandleFunc("/candidate", func(res http.ResponseWriter, req *http.Request) { // nolint: revive
candidate, candidateErr := io.ReadAll(req.Body)
if candidateErr != nil {
panic(candidateErr)
}
if candidateErr := peerConnection.AddICECandidate(
webrtc.ICECandidateInit{Candidate: string(candidate)},
); candidateErr != nil {
panic(candidateErr)
}
})
// A HTTP handler that processes a SessionDescription given to us from the other Pion process
http.HandleFunc("/sdp", func(res http.ResponseWriter, req *http.Request) { // nolint: revive
sdp := webrtc.SessionDescription{}
if decodeErr := json.NewDecoder(req.Body).Decode(&sdp); decodeErr != nil {
panic(decodeErr)
}
if setErr := peerConnection.SetRemoteDescription(sdp); setErr != nil {
panic(setErr)
}
// Create an answer to send to the other process
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
}
// Send our answer to the HTTP server listening in the other process
payload, err := json.Marshal(answer)
if err != nil {
panic(err)
}
resp, err := http.Post( // nolint:noctx
fmt.Sprintf("http://%s/sdp", *offerAddr),
"application/json; charset=utf-8",
bytes.NewReader(payload),
) // nolint:noctx
if err != nil {
panic(err)
} else if closeErr := resp.Body.Close(); closeErr != nil {
panic(closeErr)
}
// Sets the LocalDescription, and starts our UDP listeners
err = peerConnection.SetLocalDescription(answer)
if err != nil {
panic(err)
}
candidatesMux.Lock()
defer candidatesMux.Unlock()
for _, c := range pendingCandidates {
if onICECandidateErr := signalCandidate(*offerAddr, c); onICECandidateErr != nil {
panic(onICECandidateErr)
}
}
})
// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
if state == webrtc.PeerConnectionStateFailed {
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
// It may be reconnected using an ICE Restart.
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
fmt.Println("Peer Connection has gone to failed exiting")
os.Exit(0)
}
if state == webrtc.PeerConnectionStateClosed {
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
fmt.Println("Peer Connection has gone to closed exiting")
os.Exit(0)
}
})
// Register data channel creation handling
peerConnection.OnDataChannel(func(dataChannel *webrtc.DataChannel) {
fmt.Printf("New DataChannel %s %d\n", dataChannel.Label(), dataChannel.ID())
setupDataChannel(dataChannel)
})
// Start HTTP server that accepts requests from the offer process to exchange SDP and Candidates
// nolint: gosec
go func() { panic(http.ListenAndServe(*answerAddr, nil)) }()
// Block forever
select {}
}
func setupDataChannel(dataChannel *webrtc.DataChannel) {
// Register channel opening handling
dataChannel.OnOpen(func() {
fmt.Printf(
"Data channel '%s'-'%d' open. Random messages will now be sent to any connected DataChannels every 5 seconds\n",
dataChannel.Label(), dataChannel.ID(),
)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
message, sendTextErr := randutil.GenerateCryptoRandomString(
15, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
)
if sendTextErr != nil {
panic(sendTextErr)
}
// Send the message as text
fmt.Printf("Sending '%s'\n", message)
if sendTextErr = dataChannel.SendText(message); sendTextErr != nil {
panic(sendTextErr)
}
}
})
// Register text message handling
dataChannel.OnMessage(func(msg webrtc.DataChannelMessage) {
fmt.Printf("Message from DataChannel '%s': '%s'\n", dataChannel.Label(), string(msg.Data))
})
}

View File

@@ -0,0 +1,15 @@
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
version: '3'
services:
answer:
container_name: answer
build: ./answer
command: answer -offer-address offer:50000
offer:
container_name: offer
depends_on:
- answer
build: ./offer
command: offer -answer-address answer:60000

View File

@@ -0,0 +1,8 @@
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
FROM golang:1.27
RUN go install github.com/pion/webrtc/v4/examples/pion-to-pion/offer@latest
CMD ["offer"]

View File

@@ -0,0 +1,215 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
// pion-to-pion is an example of two pion instances communicating directly!
package main
import (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"sync"
"time"
"github.com/pion/randutil"
"github.com/pion/webrtc/v4"
)
func signalCandidate(addr string, candidate *webrtc.ICECandidate) error {
payload := []byte(candidate.ToJSON().Candidate)
resp, err := http.Post( // nolint:noctx
fmt.Sprintf("http://%s/candidate", addr),
"application/json; charset=utf-8",
bytes.NewReader(payload),
)
if err != nil {
return err
}
return resp.Body.Close()
}
// nolint:gocognit, cyclop
func main() {
offerAddr := flag.String("offer-address", ":50000", "Address that the Offer HTTP server is hosted on.")
answerAddr := flag.String("answer-address", "127.0.0.1:60000", "Address that the Answer HTTP server is hosted on.")
flag.Parse()
var candidatesMux sync.Mutex
pendingCandidates := make([]*webrtc.ICECandidate, 0)
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
// Prepare the configuration
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
}
// Create a new RTCPeerConnection
peerConnection, err := webrtc.NewPeerConnection(config)
if err != nil {
panic(err)
}
defer func() {
if closeErr := peerConnection.Close(); closeErr != nil {
fmt.Printf("cannot close peerConnection: %v\n", closeErr)
}
}()
// When an ICE candidate is available send to the other Pion instance
// the other Pion instance will add this candidate by calling AddICECandidate
peerConnection.OnICECandidate(func(candidate *webrtc.ICECandidate) {
if candidate == nil {
return
}
candidatesMux.Lock()
defer candidatesMux.Unlock()
desc := peerConnection.RemoteDescription()
if desc == nil {
pendingCandidates = append(pendingCandidates, candidate)
} else if onICECandidateErr := signalCandidate(*answerAddr, candidate); onICECandidateErr != nil {
panic(onICECandidateErr)
}
})
// A HTTP handler that allows the other Pion instance to send us ICE candidates
// This allows us to add ICE candidates faster, we don't have to wait for STUN or TURN
// candidates which may be slower
http.HandleFunc("/candidate", func(res http.ResponseWriter, req *http.Request) { // nolint: revive
candidate, candidateErr := io.ReadAll(req.Body)
if candidateErr != nil {
panic(candidateErr)
}
if candidateErr := peerConnection.AddICECandidate(
webrtc.ICECandidateInit{Candidate: string(candidate)},
); candidateErr != nil {
panic(candidateErr)
}
})
// A HTTP handler that processes a SessionDescription given to us from the other Pion process
http.HandleFunc("/sdp", func(res http.ResponseWriter, req *http.Request) { // nolint: revive
sdp := webrtc.SessionDescription{}
if decodeErr := json.NewDecoder(req.Body).Decode(&sdp); decodeErr != nil {
panic(decodeErr)
}
if setErr := peerConnection.SetRemoteDescription(sdp); setErr != nil {
panic(setErr)
}
candidatesMux.Lock()
defer candidatesMux.Unlock()
for _, c := range pendingCandidates {
if onICECandidateErr := signalCandidate(*answerAddr, c); onICECandidateErr != nil {
panic(onICECandidateErr)
}
}
})
// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
if state == webrtc.PeerConnectionStateFailed {
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
// It may be reconnected using an ICE Restart.
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
fmt.Println("Peer Connection has gone to failed exiting")
os.Exit(0)
}
if state == webrtc.PeerConnectionStateClosed {
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
fmt.Println("Peer Connection has gone to closed exiting")
os.Exit(0)
}
})
// Create a datachannel with label 'data'
dataChannel, err := peerConnection.CreateDataChannel("data", nil)
if err != nil {
panic(err)
}
setupDataChannel(dataChannel)
// Start HTTP server that accepts requests from the answer process to exchange SDP and Candidates
// nolint: gosec
go func() { panic(http.ListenAndServe(*offerAddr, nil)) }()
// Create an offer to send to the other process
offer, err := peerConnection.CreateOffer(nil)
if err != nil {
panic(err)
}
// Sets the LocalDescription, and starts our UDP listeners
// Note: this will start the gathering of ICE candidates
if err = peerConnection.SetLocalDescription(offer); err != nil {
panic(err)
}
// Send our offer to the HTTP server listening in the other process
payload, err := json.Marshal(offer)
if err != nil {
panic(err)
}
resp, err := http.Post( // nolint:noctx
fmt.Sprintf("http://%s/sdp", *answerAddr),
"application/json; charset=utf-8",
bytes.NewReader(payload),
)
if err != nil {
panic(err)
} else if err := resp.Body.Close(); err != nil {
panic(err)
}
// Block forever
select {}
}
func setupDataChannel(dataChannel *webrtc.DataChannel) {
// Register channel opening handling
dataChannel.OnOpen(func() {
fmt.Printf(
"Data channel '%s'-'%d' open. Random messages will now be sent to any connected DataChannels every 5 seconds\n",
dataChannel.Label(), dataChannel.ID(),
)
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
message, sendTextErr := randutil.GenerateCryptoRandomString(
15, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
)
if sendTextErr != nil {
panic(sendTextErr)
}
// Send the message as text
fmt.Printf("Sending '%s'\n", message)
if sendTextErr = dataChannel.SendText(message); sendTextErr != nil {
panic(sendTextErr)
}
}
})
// Register text message handling
dataChannel.OnMessage(func(msg webrtc.DataChannelMessage) {
fmt.Printf("Message from DataChannel '%s': '%s'\n", dataChannel.Label(), string(msg.Data))
})
}

17
examples/pion-to-pion/test.sh Executable file
View File

@@ -0,0 +1,17 @@
#!/bin/bash -eu
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
docker compose up -d
function on_exit {
docker compose logs
docker compose rm -fsv
}
trap on_exit EXIT
TIMEOUT=10
timeout $TIMEOUT docker compose logs -f | grep -q "answer | Message from DataChannel"
timeout $TIMEOUT docker compose logs -f | grep -q "offer | Message from DataChannel"

View File

@@ -0,0 +1,43 @@
# play-from-disk-fec
play-from-disk-fec demonstrates how to use forward error correction (FlexFEC-03) while sending video to your Chrome-based browser from files saved to disk. The example is designed to drop 40% of the media packets, but browser will recover them using the FEC packets and the delivered packets.
## Instructions
### Create IVF named `output.ivf` that contains a VP8/VP9/AV1 track
```
ffmpeg -i $INPUT_FILE -g 30 -b:v 2M output.ivf
```
**Note**: In the `ffmpeg` command which produces the .ivf file, the argument `-b:v 2M` specifies the video bitrate to be 2 megabits per second. We provide this default value to produce decent video quality, but if you experience problems with this configuration (such as dropped frames etc.), you can decrease this. See the [ffmpeg documentation](https://ffmpeg.org/ffmpeg.html#Options) for more information on the format of the value.
### Download play-from-disk-fec
```
go install github.com/pion/webrtc/v4/examples/play-from-disk-fec@latest
```
### Open play-from-disk-fec example page
Open [jsfiddle.net](https://jsfiddle.net/hgzwr9cm/) in your browser. You should see two text-areas and buttons for the offer-answer exchange.
### Run play-from-disk-fec to generate an offer
The `output.ivf` you created should be in the same directory as `play-from-disk-fec`.
When you run play-from-disk-fec, it will generate an offer in base64 format and print it to stdout.
### Input play-from-disk-fec's offer into your browser
Copy the base64 offer that `play-from-disk-fec` just emitted and paste it into the first text area in the jsfiddle (labeled "Remote Session Description")
### Hit 'Start Session' in jsfiddle to generate an answer
Click the 'Start Session' button. This will process the offer and generate an answer, which will appear in the second text area.
### Save the browser's answer to a file
Copy the base64-encoded answer from the second text area (labeled "Browser Session Description") and save it to a file named `answer.txt` in the same directory where you're running `play-from-disk-fec`.
### Press Enter to continue
Once you've saved the answer to `answer.txt`, go back to the terminal where `play-from-disk-fec` is running and press Enter. The program will read the answer file and establish the connection.
### Enjoy your video!
A video should start playing in your browser above the input boxes. `play-from-disk-fec` will exit when the file reaches the end
You can watch the stats about transmitted/dropped media & FEC packets in the stdout.
Congrats, you have used Pion WebRTC! Now start building something cool

View File

@@ -0,0 +1,8 @@
/*
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
*/
textarea {
width: 500px;
min-height: 75px;
}

View File

@@ -0,0 +1,8 @@
---
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
name: play-from-disk-fec
description: play-from-disk-fec demonstrates how to use forward error correction (FlexFEC-03) while sending video to your Chrome-based browser from files saved to disk.
authors:
- Aleksandr Alekseev

View File

@@ -0,0 +1,30 @@
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
Remote Session Description (Paste offer from Go code here)
<br/>
<textarea id="remoteSessionDescription"></textarea>
<br/>
<button onclick="window.startSession()">Start Session</button>
<br/>
<br/>
<br/>
Browser Session Description (Copy this to answer.txt file)
<br/>
<textarea id="localSessionDescription" readonly="true"></textarea>
<br/>
<button onclick="window.copySessionDescription()">Copy browser Session Description to clipboard</button>
<br/>
<br/>
Video
<br/>
<div id="remoteVideos"></div> <br />
Logs
<br/>
<div id="div"></div>

View File

@@ -0,0 +1,73 @@
/* eslint-env browser */
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
const pc = new RTCPeerConnection({
iceServers: [
{
urls: 'stun:stun.l.google.com:19302'
}
]
})
const log = (msg) => {
document.getElementById('div').innerHTML += msg + '<br>'
}
pc.ontrack = function (event) {
const el = document.createElement(event.track.kind)
el.srcObject = event.streams[0]
el.autoplay = true
el.controls = true
document.getElementById('remoteVideos').appendChild(el)
}
pc.oniceconnectionstatechange = (e) => log(pc.iceConnectionState)
pc.onicecandidate = (event) => {
if (event.candidate === null) {
document.getElementById('localSessionDescription').value = btoa(
JSON.stringify(pc.localDescription)
)
}
}
window.startSession = () => {
const sd = document.getElementById('remoteSessionDescription').value
if (sd === '') {
return alert('Session Description must not be empty')
}
try {
// Set the remote offer
pc.setRemoteDescription(JSON.parse(atob(sd)))
.then(() => {
// Create answer
return pc.createAnswer()
})
.then((answer) => {
// Set local description with the answer
return pc.setLocalDescription(answer)
})
.catch(log)
} catch (e) {
alert(e)
}
}
window.copySessionDescription = () => {
const browserSessionDescription = document.getElementById(
'localSessionDescription'
)
browserSessionDescription.focus()
browserSessionDescription.select()
try {
const successful = document.execCommand('copy')
const msg = successful ? 'successful' : 'unsuccessful'
log('Copying SessionDescription was ' + msg)
} catch (err) {
log('Oops, unable to copy SessionDescription ' + err)
}
}

View File

@@ -0,0 +1,334 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
// play-from-disk-fec demonstrates how to use forward error correction (FlexFEC-03)
// while sending video to your Chrome-based browser from files saved to disk.
package main
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"sync"
"time"
"github.com/pion/interceptor"
"github.com/pion/rtp"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
"github.com/pion/webrtc/v4/pkg/media/ivfreader"
)
const (
videoFileName = "output.ivf"
answerFileName = "answer.txt"
)
func main() { //nolint:gocognit,cyclop,gocyclo,maintidx
// Assert that we have a video file
_, err := os.Stat(videoFileName)
if os.IsNotExist(err) {
panic("Could not find `" + videoFileName + "`")
}
// Create mediaEngine with default codecs
mediaEngine := &webrtc.MediaEngine{}
if err = mediaEngine.RegisterDefaultCodecs(); err != nil {
panic(err)
}
// Create interceptorRegistry with default interceptots
interceptorRegistry := &interceptor.Registry{}
interceptorRegistry.Add(packetDropInterceptorFactory{})
// Configure flexfec-03
if err = webrtc.ConfigureFlexFEC03(49, mediaEngine, interceptorRegistry); err != nil {
panic(err)
}
if err = webrtc.RegisterDefaultInterceptors(mediaEngine, interceptorRegistry); err != nil {
panic(err)
}
api := webrtc.NewAPI(
webrtc.WithMediaEngine(mediaEngine),
webrtc.WithInterceptorRegistry(interceptorRegistry),
)
// Create a new RTCPeerConnection
peerConnection, err := api.NewPeerConnection(webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
})
if err != nil {
panic(err)
}
defer func() {
if cErr := peerConnection.Close(); cErr != nil {
fmt.Printf("cannot close peerConnection: %v\n", cErr)
}
}()
iceConnectedCtx, iceConnectedCtxCancel := context.WithCancel(context.Background())
file, openErr := os.Open(videoFileName)
if openErr != nil {
panic(openErr)
}
_, header, openErr := ivfreader.NewWith(file)
if openErr != nil {
panic(openErr)
}
// Determine video codec
var trackCodec string
switch header.FourCC {
case "AV01":
trackCodec = webrtc.MimeTypeAV1
case "VP90":
trackCodec = webrtc.MimeTypeVP9
case "VP80":
trackCodec = webrtc.MimeTypeVP8
default:
panic(fmt.Sprintf("Unable to handle FourCC %s", header.FourCC))
}
// Create a video track
videoTrack, videoTrackErr := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: trackCodec}, "video", "pion",
)
if videoTrackErr != nil {
panic(videoTrackErr)
}
rtpSender, videoTrackErr := peerConnection.AddTrack(videoTrack)
if videoTrackErr != nil {
panic(videoTrackErr)
}
// Read incoming RTCP packets
// Before these packets are returned they are processed by interceptors. For things
// like NACK this needs to be called.
go func() {
rtcpBuf := make([]byte, 1500)
for {
if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
return
}
}
}()
go func() {
// Open a IVF file and start reading using our IVFReader
file, ivfErr := os.Open(videoFileName)
if ivfErr != nil {
panic(ivfErr)
}
ivf, header, ivfErr := ivfreader.NewWith(file)
if ivfErr != nil {
panic(ivfErr)
}
// Wait for connection established
<-iceConnectedCtx.Done()
// Send our video file frame at a time. Pace our sending so we send it at the same speed it should be played back as.
// This isn't required since the video is timestamped, but we will such much higher loss if we send all at once.
//
// It is important to use a time.Ticker instead of time.Sleep because
// * avoids accumulating skew, just calling time.Sleep didn't compensate for the time spent parsing the data
// * works around latency issues with Sleep (see https://github.com/golang/go/issues/44343)
ticker := time.NewTicker(
time.Millisecond * time.Duration((float32(header.TimebaseNumerator)/float32(header.TimebaseDenominator))*1000),
)
defer ticker.Stop()
for ; true; <-ticker.C {
frame, _, ivfErr := ivf.ParseNextFrame()
if errors.Is(ivfErr, io.EOF) {
fmt.Printf("All video frames parsed and sent")
os.Exit(0)
}
if ivfErr != nil {
panic(ivfErr)
}
if ivfErr = videoTrack.WriteSample(media.Sample{Data: frame, Duration: time.Second}); ivfErr != nil {
panic(ivfErr)
}
}
}()
// Set the handler for ICE connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
fmt.Printf("Connection State has changed %s \n", connectionState.String())
if connectionState == webrtc.ICEConnectionStateConnected {
iceConnectedCtxCancel()
}
})
// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
if state == webrtc.PeerConnectionStateFailed {
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
// It may be reconnected using an ICE Restart.
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
fmt.Println("Peer Connection has gone to failed exiting")
os.Exit(0)
}
if state == webrtc.PeerConnectionStateClosed {
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
fmt.Println("Peer Connection has gone to closed exiting")
os.Exit(0)
}
})
// Create offer
offer, err := peerConnection.CreateOffer(nil)
if err != nil {
panic(err)
}
// Create channel that is blocked until ICE Gathering is complete
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
// Sets the LocalDescription, and starts our UDP listeners
if err = peerConnection.SetLocalDescription(offer); err != nil {
panic(err)
}
// Block until ICE Gathering is complete, disabling trickle ICE
// we do this because we only can exchange one signaling message
// in a production application you should exchange ICE Candidates via OnICECandidate
<-gatherComplete
// Output the offer in base64 so we can paste it in browser
fmt.Println(encode(peerConnection.LocalDescription()))
// Wait for user to save the answer and press enter
fmt.Printf("Save the browser's answer to '%s' and press Enter to continue...\n", answerFileName)
_, err = bufio.NewReader(os.Stdin).ReadBytes('\n')
if err != nil {
panic(err)
}
// Read the answer from file
answerData, readErr := os.ReadFile(answerFileName)
if readErr != nil {
panic(readErr)
}
answerStr := strings.TrimSpace(string(answerData))
if len(answerStr) == 0 {
panic("Answer file is empty")
}
answer := webrtc.SessionDescription{}
decode(answerStr, &answer)
// Set the remote SessionDescription
if err = peerConnection.SetRemoteDescription(answer); err != nil {
panic(err)
}
fmt.Println("Answer received and set successfully!")
// Block forever
select {}
}
// JSON encode + base64 a SessionDescription.
func encode(obj *webrtc.SessionDescription) string {
b, err := json.Marshal(obj)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(b)
}
// Decode a base64 and unmarshal JSON into a SessionDescription.
func decode(in string, obj *webrtc.SessionDescription) {
b, err := base64.StdEncoding.DecodeString(in)
if err != nil {
panic(err)
}
if err = json.Unmarshal(b, obj); err != nil {
panic(err)
}
}
// Factory for creating the interceptor.
type packetDropInterceptorFactory struct{}
func (f packetDropInterceptorFactory) NewInterceptor(_ string) (interceptor.Interceptor, error) {
return &dropFilter{}, nil
}
// dropFilter drops outgoing video packets based on sequence number.
type dropFilter struct {
interceptor.NoOp
mu sync.Mutex
mediaPacketsTotal int
fecPacketsTotal int
droppedPacketsTotal int
}
func (i *dropFilter) BindLocalStream(info *interceptor.StreamInfo, writer interceptor.RTPWriter) interceptor.RTPWriter {
if !strings.HasPrefix(strings.ToLower(info.MimeType), "video/") {
return writer
}
return interceptor.RTPWriterFunc(func(header *rtp.Header, payload []byte, attrs interceptor.Attributes) (int, error) {
i.mu.Lock()
defer i.mu.Unlock()
// Check if this is a FEC packet
if header.SSRC == info.SSRCForwardErrorCorrection {
i.fecPacketsTotal++
return writer.Write(header, payload, attrs)
}
// Log stats periodically
if i.mediaPacketsTotal%100 == 0 {
dropRatio := float64(i.droppedPacketsTotal) / float64(i.mediaPacketsTotal)
fmt.Printf("Stats: Media: %d, FEC: %d, Dropped: %d, Drop ratio: %.4f%%\n",
i.mediaPacketsTotal, i.fecPacketsTotal, i.droppedPacketsTotal, dropRatio*100)
}
// Count all media packets
i.mediaPacketsTotal++
// 40% loss
if i.mediaPacketsTotal%5 <= 1 {
i.droppedPacketsTotal++
return len(payload), nil // Pretend we wrote the packet but actually drop it
}
return writer.Write(header, payload, attrs)
})
}

View File

@@ -0,0 +1,49 @@
# ogg-playlist-sctp
Streams Opus pages from multi or single track Ogg containers, exposes the playlist over an SCTP DataChannel, and lets the browser hop between tracks while showing artist/title metadata parsed from OpusTags.
## What this showcases
- Reads multi-stream Ogg containers with `oggreader` and keeps per-serial playback state.
- Publishes playlist + now-playing metadata (artist/title/vendor/comments) over a DataChannel.
- Browser can send `next`, `prev`, or a 1-based track number to jump around.
- Audio is sent as an Opus `TrackLocalStaticSample` over RTP, metadata/control ride over SCTP.
## Prepare a demo playlist
The example looks for `playlist.ogg` in the working directory.
You can provide your own `playlist.ogg` or generate it by running one of the following ffmpeg commands:
**Fake two-track Ogg with metadata (artist/title per stream)**
```sh
ffmpeg \
-f lavfi -t 8 -i "sine=frequency=330" \
-f lavfi -t 8 -i "sine=frequency=660" \
-map 0:a -map 1:a \
-c:a libopus -page_duration 20000 \
-metadata:s:a:0 artist="Pion Artist" -metadata:s:a:0 title="Fake Intro" \
-metadata:s:a:1 artist="Open-Source Friend" -metadata:s:a:1 title="Fake Outro" \
playlist.ogg
```
**Single-track fallback with tags**
```sh
ffmpeg -f lavfi -t 10 -i "sine=frequency=480" \
-c:a libopus -page_duration 20000 \
-metadata artist="Solo Bot" -metadata title="One Track Demo" \
playlist.ogg
```
## Run it
1. Build the binary:
```sh
go install github.com/pion/webrtc/v4/examples/play-from-disk-playlist-control@latest
```
2. Run it from the directory containing `playlist.ogg` (override port with `-addr` if you like):
```sh
play-from-disk-playlist-control
# or
play-from-disk-playlist-control -addr :8080
```
3. Open the hosted UI in your browser and press **Start Session**:
```
http://localhost:8080
```
Signaling is WHEP-style: the browser POSTs plain SDP to `/whep` and the server responds with the answer SDP. Use the buttons or type `next` / `prev` / a track number to switch tracks. Playlist metadata and now-playing updates arrive over the DataChannel; Opus audio flows on the media track.

View File

@@ -0,0 +1,541 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
// ogg-playlist-sctp streams Opus pages from single or multi-track Ogg containers,
// exposes the playlist over a DataChannel, and lets the browser switch tracks.
package main
import (
"context"
"embed"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
"github.com/pion/webrtc/v4/pkg/media/oggreader"
)
const (
playlistFile = "playlist.ogg"
labelAudio = "audio"
labelTrack = "pion"
)
//go:embed web/*
var content embed.FS
type bufferedPage struct {
payload []byte
duration time.Duration
granule uint64
}
type oggTrack struct {
serial uint32
header *oggreader.OggHeader
tags *oggreader.OpusTags
title string
artist string
vendor string
pages []bufferedPage
runtime time.Duration
}
func main() { //nolint:gocognit,cyclop
addr := flag.String("addr", "localhost:8080", "HTTP listen address")
flag.Parse()
tracks, err := parsePlaylist(playlistFile)
if err != nil {
log.Fatal(err)
}
if len(tracks) == 0 {
log.Fatal("no playable Opus pages were found in playlist.ogg")
}
log.Printf("Loaded %d track(s) from %s", len(tracks), playlistFile)
for i, t := range tracks {
log.Printf(" [%d] serial=%d title=%q artist=%q pages=%d duration=%v",
i+1, t.serial, t.title, t.artist, len(t.pages), t.runtime)
}
static, err := fs.Sub(content, "web")
if err != nil {
log.Fatal(err)
}
mux := http.NewServeMux()
fileServer := http.FileServer(http.FS(static))
mux.Handle("/", fileServer)
mux.HandleFunc("/whep", func(writer http.ResponseWriter, reader *http.Request) {
if reader.Method != http.MethodPost {
http.Error(writer, "method not allowed", http.StatusMethodNotAllowed)
return
}
body, err := io.ReadAll(reader.Body)
if err != nil {
http.Error(writer, "failed to read body", http.StatusBadRequest)
return
}
rawSDP := string(body)
if strings.TrimSpace(rawSDP) == "" {
http.Error(writer, "empty SDP", http.StatusBadRequest)
return
}
log.Printf("received offer (%d bytes)", len(rawSDP))
offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: rawSDP}
answer, err := handleOffer(tracks, offer) //nolint:contextcheck
if err != nil {
log.Printf("error handling offer: %v", err)
http.Error(writer, err.Error(), http.StatusBadRequest)
return
}
writer.Header().Set("Content-Type", "application/sdp")
if _, err = writer.Write([]byte(answer.SDP)); err != nil {
log.Printf("write answer failed: %v", err)
}
})
log.Printf("Serving UI at http://%s ...", *addr)
log.Fatal(http.ListenAndServe(*addr, mux)) //nolint:gosec
}
//nolint:cyclop
func handleOffer(
tracks []*oggTrack,
offer webrtc.SessionDescription,
) (*webrtc.SessionDescription, error) {
peerConnection, err := webrtc.NewPeerConnection(webrtc.Configuration{
ICEServers: []webrtc.ICEServer{{
URLs: []string{"stun:stun.l.google.com:19302"},
}},
})
if err != nil {
return nil, fmt.Errorf("create PeerConnection: %w", err)
}
iceConnectedCtx, iceConnectedCtxCancel := context.WithCancel(context.Background())
disconnectCtx, disconnectCtxCancel := context.WithCancel(context.Background())
setupComplete := false
defer func() {
if !setupComplete {
iceConnectedCtxCancel()
disconnectCtxCancel()
}
}()
audioTrack, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus},
labelAudio,
labelTrack,
)
if err != nil {
return nil, fmt.Errorf("create audio track: %w", err)
}
rtpSender, err := peerConnection.AddTrack(audioTrack)
if err != nil {
return nil, fmt.Errorf("add track: %w", err)
}
go func() {
rtcpBuf := make([]byte, 1500)
for {
if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
return
}
}
}()
playlistChannel, err := peerConnection.CreateDataChannel("playlist", nil)
if err != nil {
return nil, fmt.Errorf("create data channel: %w", err)
}
var currentTrack atomic.Int32
switchTrack := make(chan int, 4)
playlistChannel.OnOpen(func() {
fmt.Println("playlist data channel open")
sendPlaylistText(playlistChannel, tracks, int(currentTrack.Load()), true)
})
playlistChannel.OnMessage(func(msg webrtc.DataChannelMessage) {
command := strings.TrimSpace(strings.ToLower(string(msg.Data)))
limit := len(tracks)
next := -1
switch command {
case "next", "n", "forward":
next = wrapNext(int(currentTrack.Load()), limit)
case "prev", "previous", "p", "back":
next = wrapPrev(int(currentTrack.Load()), limit)
case "list":
sendPlaylistText(playlistChannel, tracks, int(currentTrack.Load()), true)
default:
if idx, convErr := strconv.Atoi(command); convErr == nil {
next = normalizeIndex(idx-1, limit)
}
}
if next < 0 || next == int(currentTrack.Load()) {
return
}
currentTrack.Store(int32(next)) //nolint:gosec
select {
case switchTrack <- next:
default:
}
sendPlaylistText(playlistChannel, tracks, next, true)
})
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
fmt.Printf("Connection State has changed %s\n", connectionState.String())
if connectionState == webrtc.ICEConnectionStateConnected {
iceConnectedCtxCancel()
}
})
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
if state == webrtc.PeerConnectionStateFailed || state == webrtc.PeerConnectionStateClosed {
disconnectCtxCancel()
}
})
go func() {
<-iceConnectedCtx.Done()
stream(tracks, audioTrack, &currentTrack, switchTrack, playlistChannel, disconnectCtx)
}()
go func() {
<-disconnectCtx.Done()
if closeErr := peerConnection.Close(); closeErr != nil {
fmt.Printf("cannot close peerConnection: %v\n", closeErr)
}
}()
//nolint:contextcheck // webrtc API does not take context for SetRemoteDescription
if err = peerConnection.SetRemoteDescription(offer); err != nil {
return nil, fmt.Errorf("set remote description: %w", err)
}
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
return nil, fmt.Errorf("create answer: %w", err)
}
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
if err = peerConnection.SetLocalDescription(answer); err != nil {
return nil, fmt.Errorf("set local description: %w", err)
}
<-gatherComplete
setupComplete = true
return peerConnection.LocalDescription(), nil
}
func stream(
tracks []*oggTrack,
audioTrack *webrtc.TrackLocalStaticSample,
currentTrack *atomic.Int32,
switchTrack <-chan int,
playlistChannel *webrtc.DataChannel,
ctx context.Context,
) {
for {
select {
case <-ctx.Done():
return
default:
}
index := normalizeIndex(int(currentTrack.Load()), len(tracks))
track := tracks[index]
sendNowPlayingText(playlistChannel, track, index)
for i := 0; i < len(track.pages); i++ {
page := track.pages[i]
if err := audioTrack.WriteSample(media.Sample{Data: page.payload, Duration: page.duration}); err != nil {
if errors.Is(err, io.ErrClosedPipe) {
return
}
panic(err)
}
wait := time.After(page.duration)
select {
case <-ctx.Done():
return
case next := <-switchTrack:
currentTrack.Store(int32(normalizeIndex(next, len(tracks)))) //nolint:gosec
goto nextTrack
case <-wait:
}
}
nextTrack:
}
}
func parsePlaylist(path string) ([]*oggTrack, error) { //nolint:cyclop
cleaned := filepath.Clean(path)
if filepath.IsAbs(cleaned) || strings.Contains(cleaned, "..") {
return nil, fmt.Errorf("invalid playlist path: %q", path) //nolint:err113
}
cleaned = filepath.Base(cleaned)
file, err := os.Open(cleaned) //nolint:gosec // path is validated and confined to local directory
if err != nil {
return nil, fmt.Errorf("open playlist %q: %w", cleaned, err)
}
defer func() {
if cErr := file.Close(); cErr != nil {
fmt.Printf("cannot close ogg file: %v\n", cErr)
}
}()
reader, err := oggreader.NewWithOptions(file, oggreader.WithDoChecksum(false))
if err != nil {
return nil, fmt.Errorf("create ogg reader: %w", err)
}
tracks := map[uint32]*oggTrack{}
var order []uint32
lastGranule := map[uint32]uint64{}
for {
payload, pageHeader, parseErr := reader.ParseNextPage()
if errors.Is(parseErr, io.EOF) {
break
}
if parseErr != nil {
return nil, fmt.Errorf("parse ogg page: %w", parseErr)
}
track := ensureTrack(tracks, pageHeader.Serial, &order)
if headerType, ok := pageHeader.HeaderType(payload); ok { //nolint:nestif
switch headerType {
case oggreader.HeaderOpusID:
header, headerErr := oggreader.ParseOpusHead(payload)
if headerErr != nil {
return nil, fmt.Errorf("parse OpusHead: %w", headerErr)
}
track.header = header
continue
case oggreader.HeaderOpusTags:
tags, tagErr := oggreader.ParseOpusTags(payload)
if tagErr != nil {
return nil, fmt.Errorf("parse OpusTags: %w", tagErr)
}
track.tags = tags
track.title, track.artist = extractMetadata(tags)
if track.vendor == "" {
track.vendor = tags.Vendor
}
continue
default:
}
}
if track.header == nil {
continue
}
duration := pageDuration(track.header, pageHeader.GranulePosition, lastGranule[track.serial])
lastGranule[track.serial] = pageHeader.GranulePosition
track.pages = append(track.pages, bufferedPage{
payload: payload,
duration: duration,
granule: pageHeader.GranulePosition,
})
track.runtime += duration
}
var ordered []*oggTrack
for _, serial := range order {
track := tracks[serial]
if len(track.pages) == 0 {
continue
}
if track.title == "" {
track.title = fmt.Sprintf("Track %d", len(ordered)+1)
}
ordered = append(ordered, track)
}
return ordered, nil
}
func ensureTrack(tracks map[uint32]*oggTrack, serial uint32, order *[]uint32) *oggTrack {
track, ok := tracks[serial]
if ok {
return track
}
track = &oggTrack{serial: serial, title: fmt.Sprintf("serial-%d", serial)}
tracks[serial] = track
*order = append(*order, serial)
return track
}
func extractMetadata(tags *oggreader.OpusTags) (title, artist string) {
for _, c := range tags.UserComments {
switch strings.ToLower(c.Comment) {
case "title":
title = c.Value
case "artist":
artist = c.Value
}
}
return title, artist
}
func pageDuration(header *oggreader.OggHeader, granule, last uint64) time.Duration {
sampleRate := header.SampleRate
if sampleRate == 0 {
sampleRate = 48000
}
if granule <= last {
return 20 * time.Millisecond
}
sampleCount := int64(granule - last) //nolint:gosec
if sampleCount <= 0 {
return 20 * time.Millisecond
}
ns := float64(sampleCount) / float64(sampleRate) * float64(time.Second)
return time.Duration(ns)
}
func wrapNext(current, limit int) int {
if limit == 0 {
return 0
}
return (current + 1) % limit
}
func wrapPrev(current, limit int) int {
if limit == 0 {
return 0
}
if current == 0 {
return limit - 1
}
return current - 1
}
func normalizeIndex(i, limit int) int {
if limit == 0 {
return 0
}
if i < 0 {
return 0
}
if i >= limit {
return limit - 1
}
return i
}
func sendPlaylistText(dc *webrtc.DataChannel, tracks []*oggTrack, current int, includeNow bool) {
if dc == nil || dc.ReadyState() != webrtc.DataChannelStateOpen {
return
}
var str strings.Builder
fmt.Fprintf(&str, "playlist|%d\n", normalizeIndex(current, len(tracks)))
for i, t := range tracks {
fmt.Fprintf(
&str, "track|%d|%d|%d|%s|%s\n", i, t.serial, t.runtime.Milliseconds(),
cleanText(t.title),
cleanText(t.artist),
)
}
if includeNow && len(tracks) > 0 {
next := normalizeIndex(current, len(tracks))
str.WriteString(nowLine(tracks[next], next))
}
if err := dc.SendText(str.String()); err != nil {
fmt.Printf("unable to send playlist: %v\n", err)
}
}
func sendNowPlayingText(dc *webrtc.DataChannel, track *oggTrack, index int) {
if dc == nil || dc.ReadyState() != webrtc.DataChannelStateOpen {
return
}
line := nowLine(track, index)
if err := dc.SendText(line); err != nil {
fmt.Printf("unable to send now-playing: %v\n", err)
}
}
func nowLine(track *oggTrack, index int) string {
comments := ""
if track.tags != nil && len(track.tags.UserComments) > 0 {
pairs := make([]string, 0, len(track.tags.UserComments))
for _, c := range track.tags.UserComments {
pairs = append(pairs, cleanText(c.Comment)+"="+cleanText(c.Value))
}
comments = strings.Join(pairs, ",")
}
return fmt.Sprintf(
"now|%d|%d|%d|%d|%d|%s|%s|%s|%s\n",
index,
track.serial,
track.header.Channels,
track.header.SampleRate,
track.runtime.Milliseconds(),
cleanText(track.title),
cleanText(track.artist),
cleanText(track.vendor),
comments,
)
}
func cleanText(v string) string {
out := strings.ReplaceAll(v, "\n", " ")
return strings.ReplaceAll(out, "|", "/")
}

View File

@@ -0,0 +1,144 @@
/* SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
* SPDX-License-Identifier: MIT
*/
body {
font-family: sans-serif;
margin: 1.5rem;
color: #121212;
}
h2 {
margin-top: 0;
}
code {
background: #eef1f7;
padding: 0.1rem 0.35rem;
border-radius: 4px;
}
.controls {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
input[type="text"] {
padding: 0.5rem;
min-width: 220px;
}
button {
padding: 0.5rem 0.75rem;
background: #0d6efd;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background: #0b5ed7;
}
.player {
display: grid;
grid-template-columns: 320px 1fr;
gap: 1rem;
align-items: center;
margin-bottom: 1rem;
}
audio {
width: 100%;
}
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.card {
background: white;
padding: 1rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
}
.logs {
min-height: 180px;
max-height: 320px;
overflow-y: auto;
}
.list {
list-style: none;
margin: 0;
padding: 0;
}
.list li {
padding: 0.35rem 0;
border-bottom: 1px solid #eceff4;
}
.list li:last-child {
border-bottom: none;
}
.list .current {
font-weight: bold;
color: #0d6efd;
}
.label {
font-size: 0.85rem;
color: #5a6572;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.track {
font-size: 1.2rem;
margin-top: 0.25rem;
}
.artist {
color: #5a6572;
}
.meta {
color: #5a6572;
font-size: 0.9rem;
}
@media (max-width: 820px) {
body {
margin: 1rem;
}
.player {
grid-template-columns: 1fr;
}
.grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 540px) {
.controls {
flex-direction: column;
align-items: stretch;
}
input[type="text"] {
width: 100%;
}
button {
width: 100%;
}
}

View File

@@ -0,0 +1,208 @@
/* eslint-env browser */
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
let pc = null
let playlistChannel = null
let started = false
const logs = document.getElementById('logs')
const nowPlayingEl = document.getElementById('nowPlaying')
const playlistEl = document.getElementById('playlist')
const startButton = document.getElementById('startButton')
const audio = document.getElementById('remoteAudio')
const log = msg => {
logs.innerHTML += `${msg}<br>`
logs.scrollTop = logs.scrollHeight
}
async function startSession () {
if (started) {
return
}
started = true
startButton.disabled = true
log('Creating PeerConnection...')
pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
})
pc.createDataChannel('sctp-bootstrap')
pc.oniceconnectionstatechange = () => log(`ICE state: ${pc.iceConnectionState}`)
pc.onconnectionstatechange = () => log(`Peer state: ${pc.connectionState}`)
pc.ontrack = event => {
audio.srcObject = event.streams[0]
audio.play().catch(() => {})
}
pc.ondatachannel = event => {
if (event.channel.label !== 'playlist') {
return
}
playlistChannel = event.channel
playlistChannel.onopen = () => log('playlist DataChannel open')
playlistChannel.onclose = () => log('playlist DataChannel closed')
playlistChannel.onmessage = e => handleMessage(e.data)
}
pc.addTransceiver('audio', { direction: 'recvonly' })
try {
const offer = await pc.createOffer()
await pc.setLocalDescription(offer)
log(`Sending offer (${pc.localDescription.sdp.length} bytes)`)
const res = await fetch('/whep', {
method: 'POST',
headers: { 'Content-Type': 'application/sdp' },
body: pc.localDescription.sdp
})
if (!res.ok) {
const body = await res.text()
throw new Error(`whep failed: ${res.status} ${body}`)
}
const answerSDP = await res.text()
if (!answerSDP) {
throw new Error('no SDP answer from server')
}
await pc.setRemoteDescription({ type: 'answer', sdp: answerSDP })
log('Answer applied. Waiting for media and playlist...')
} catch (err) {
log(`Error during negotiation: ${err}`)
}
}
function sendPrev () {
sendRawCommand('prev')
}
function sendNext () {
sendRawCommand('next')
}
function sendList () {
sendRawCommand('list')
}
function sendCommand () {
const value = document.getElementById('commandInput').value
if (value.trim() === '') {
return
}
sendRawCommand(value)
}
function sendRawCommand (text) {
if (!playlistChannel || playlistChannel.readyState !== 'open') {
log('playlist channel not open yet')
return
}
playlistChannel.send(text)
}
function handleMessage (data) {
const lines = data.trim().split('\n')
const playlist = []
let current = null
let now = null
lines.forEach(line => {
const parts = line.split('|')
if (parts.length === 0) {
return
}
switch (parts[0]) {
case 'playlist':
current = Number(parts[1] || 0)
break
case 'track':
playlist.push({
index: Number(parts[1] || 0),
serial: Number(parts[2] || 0),
duration_ms: Number(parts[3] || 0),
title: parts[4] || '',
artist: parts[5] || ''
})
break
case 'now':
now = {
index: Number(parts[1] || 0),
serial: Number(parts[2] || 0),
channels: Number(parts[3] || 0),
sample_rate: Number(parts[4] || 0),
duration_ms: Number(parts[5] || 0),
title: parts[6] || '',
artist: parts[7] || '',
vendor: parts[8] || '',
comments: (parts[9] || '').split(',').filter(Boolean).map(s => {
const [k, v] = s.split('=')
return { key: k, value: v }
})
}
break
default:
log(`Message: ${line}`)
}
})
if (playlist.length > 0) {
renderPlaylist({ tracks: playlist, current })
}
if (now) {
renderNowPlaying(now)
}
}
function renderPlaylist (message) {
playlistEl.innerHTML = ''
message.tracks.forEach(track => {
const li = document.createElement('li')
li.innerText = `${track.index + 1}. ${track.title || '(untitled)'} — ${track.artist || 'unknown artist'} (${prettyDuration(track.duration_ms)})`
if (track.index === message.current) {
li.classList.add('current')
}
playlistEl.appendChild(li)
})
if (message.hint) {
log(message.hint)
}
}
function renderNowPlaying (track) {
const title = track.title || '(untitled)'
const artist = track.artist || 'unknown artist'
const vendor = track.vendor ? `<div class="meta">Vendor: ${track.vendor}</div>` : ''
const channels = track.channels || '?'
const sampleRate = track.sample_rate || '?'
const comments = (track.comments || []).map(c => `<div class="meta">${c.key}: ${c.value}</div>`).join('')
nowPlayingEl.innerHTML = `
<div class="label">Now playing</div>
<div class="track">${title}</div>
<div class="artist">${artist}</div>
<div class="meta">Serial: ${track.serial} | Channels: ${channels} | Sample rate: ${sampleRate}</div>
<div class="meta">Duration: ${prettyDuration(track.duration_ms)}</div>
${vendor}
${comments}
`
}
function prettyDuration (ms) {
if (!ms || ms < 0) {
return 'unknown'
}
const totalSeconds = Math.round(ms / 1000)
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
}
window.startSession = startSession
window.sendPrev = sendPrev
window.sendNext = sendNext
window.sendList = sendList
window.sendCommand = sendCommand

View File

@@ -0,0 +1,45 @@
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Ogg Playlist over SCTP</title>
<link rel="stylesheet" href="app.css">
</head>
<body>
<header>
<h2>Ogg Playlist over RTP, control over SCTP</h2>
<p>Server hosts both the WebRTC sender and this page. It streams Opus from <code>playlist.ogg</code>, shares metadata over a DataChannel, and lets you jump between tracks.</p>
</header>
<section class="controls">
<button id="startButton" onclick="startSession()">Start Session</button>
<input id="commandInput" type="text" placeholder="next | prev | 1 | 2 ..." aria-label="Command">
<button onclick="sendCommand()">Send</button>
<button onclick="sendPrev()">Prev</button>
<button onclick="sendNext()">Next</button>
</section>
<section class="player">
<audio id="remoteAudio" controls autoplay></audio>
<div id="nowPlaying" class="card">Waiting for playlist...</div>
</section>
<section class="grid">
<div>
<h3>Playlist</h3>
<ul id="playlist" class="card list"></ul>
</div>
<div>
<h3>Logs</h3>
<div id="logs" class="card logs"></div>
</div>
</section>
<script src="app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,47 @@
# play-from-disk-renegotiation
play-from-disk-renegotiation demonstrates Pion WebRTC's renegotiation abilities.
For a simpler example of playing a file from disk we also have [examples/play-from-disk](/examples/play-from-disk)
## Instructions
### Download play-from-disk-renegotiation
This example requires you to clone the repo since it is serving static HTML.
```
git clone https://github.com/pion/webrtc.git
cd webrtc/examples/play-from-disk-renegotiation
```
### Create IVF named `output.ivf` that contains a VP8, VP9 or AV1 track
To encode video to VP8:
```
ffmpeg -i $INPUT_FILE -c:v libvpx -g 30 -b:v 2M output.ivf
```
alternatively, to encode video to AV1 (Note: AV1 is CPU intensive, you may need to adjust `-cpu-used`):
```
ffmpeg -i $INPUT_FILE -c:v libaom-av1 -cpu-used 8 -g 30 -b:v 2M output.ivf
```
Or to encode video to VP9:
```
ffmpeg -i $INPUT_FILE -c:v libvpx-vp9 -cpu-used 4 -g 30 -b:v 2M output.ivf
```
If you have a VP8, VP9 or AV1 file in a different container you can use `ffmpeg` to mux it into IVF:
```
ffmpeg -i $INPUT_FILE -c:v copy -an output.ivf
```
**Note**: In the `ffmpeg` command, the argument `-b:v 2M` specifies the video bitrate to be 2 megabits per second. We provide this default value to produce decent video quality, but if you experience problems with this configuration (such as dropped frames etc.), you can decrease this. See the [ffmpeg documentation](https://ffmpeg.org/ffmpeg.html#Options) for more information on the format of the value.
### Run play-from-disk-renegotiation
The `output.ivf` you created should be in the same directory as `play-from-disk-renegotiation`. Execute `go run *.go`
### Open the Web UI
Open [http://localhost:8080](http://localhost:8080) and you should have a `Add Track` and `Remove Track` button. Press these to add as many tracks as you want, or to remove as many as you wish.
Congrats, you have used Pion WebRTC! Now start building something cool

View File

@@ -0,0 +1,81 @@
<html>
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
<head>
<title>play-from-disk-renegotiation</title>
</head>
<body>
<button onclick="window.addVideo()"> Add Video </button><br />
<button onclick="window.removeVideo()"> Remove Video </button><br />
<h3> Video </h3>
<div id="remoteVideos"></div> <br />
<h3> Logs </h3>
<div id="logs"></div>
</body>
<script>
let activeVideos = 0
let pc = new RTCPeerConnection({
iceServers: [
{
urls: 'stun:stun.l.google.com:19302'
}
]
})
pc.ontrack = function (event) {
var el = document.createElement(event.track.kind)
el.srcObject = event.streams[0]
el.autoplay = true
el.controls = true
event.track.onmute = function(event) {
el.parentNode.removeChild(el);
}
document.getElementById('remoteVideos').appendChild(el)
}
let doSignaling = method => {
pc.createOffer()
.then(offer => {
pc.setLocalDescription(offer)
return fetch(`/${method}`, {
method: 'post',
headers: {
'Accept': 'application/json, text/plain, */*',
'Content-Type': 'application/json'
},
body: JSON.stringify(offer)
})
})
.then(res => res.json())
.then(res => pc.setRemoteDescription(res))
.catch(alert)
}
// Create a noop DataChannel. By default PeerConnections do not connect
// if they have no media tracks or DataChannels
pc.createDataChannel('noop')
doSignaling('createPeerConnection')
window.addVideo = () => {
if (pc.getTransceivers().length <= activeVideos) {
pc.addTransceiver('video')
activeVideos++
}
doSignaling('addVideo')
};
window.removeVideo = () => {
doSignaling('removeVideo')
};
</script>
</html>

View File

@@ -0,0 +1,215 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
// play-from-disk-renegotiation demonstrates Pion WebRTC's renegotiation abilities.
package main
import (
"encoding/json"
"fmt"
"net/http"
"os"
"time"
"github.com/pion/randutil"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
"github.com/pion/webrtc/v4/pkg/media/ivfreader"
)
var peerConnection *webrtc.PeerConnection //nolint
// doSignaling exchanges all state of the local PeerConnection and is called
// every time a video is added or removed.
func doSignaling(res http.ResponseWriter, req *http.Request) {
var offer webrtc.SessionDescription
if err := json.NewDecoder(req.Body).Decode(&offer); err != nil {
panic(err)
}
if err := peerConnection.SetRemoteDescription(offer); err != nil {
panic(err)
}
// Create channel that is blocked until ICE Gathering is complete
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
} else if err = peerConnection.SetLocalDescription(answer); err != nil {
panic(err)
}
// Block until ICE Gathering is complete, disabling trickle ICE
// we do this because we only can exchange one signaling message
// in a production application you should exchange ICE Candidates via OnICECandidate
<-gatherComplete
response, err := json.Marshal(*peerConnection.LocalDescription())
if err != nil {
panic(err)
}
res.Header().Set("Content-Type", "application/json")
if _, err := res.Write(response); err != nil {
panic(err)
}
}
// Add a single video track.
func createPeerConnection(res http.ResponseWriter, req *http.Request) {
if peerConnection.ConnectionState() != webrtc.PeerConnectionStateNew {
panic(fmt.Sprintf("createPeerConnection called in non-new state (%s)", peerConnection.ConnectionState()))
}
doSignaling(res, req)
fmt.Println("PeerConnection has been created")
}
// Add a single video track.
func addVideo(res http.ResponseWriter, req *http.Request) { //nolint:cyclop
// Open a IVF file and start reading using our IVFReader
file, err := os.Open("output.ivf")
if err != nil {
panic(err)
}
ivf, header, err := ivfreader.NewWith(file)
if err != nil {
panic(err)
}
var mimeType string
switch header.FourCC {
case "VP80":
mimeType = webrtc.MimeTypeVP8
case "VP90":
mimeType = webrtc.MimeTypeVP9
case "AV01":
mimeType = webrtc.MimeTypeAV1
default:
panic(fmt.Sprintf("unsupported codec: %s", header.FourCC))
}
videoTrack, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: mimeType},
fmt.Sprintf("video-%d", randutil.NewMathRandomGenerator().Uint32()),
fmt.Sprintf("video-%d", randutil.NewMathRandomGenerator().Uint32()),
)
if err != nil {
panic(err)
}
rtpSender, err := peerConnection.AddTrack(videoTrack)
if err != nil {
panic(err)
}
// Read incoming RTCP packets
// Before these packets are returned they are processed by interceptors. For things
// like NACK this needs to be called.
go func() {
rtcpBuf := make([]byte, 1500)
for {
if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
return
}
}
}()
doSignaling(res, req)
fmt.Println("Video track has been added")
go writeVideoToTrack(ivf, header, videoTrack)
}
// Remove a single sender.
func removeVideo(res http.ResponseWriter, req *http.Request) {
if senders := peerConnection.GetSenders(); len(senders) != 0 {
if err := peerConnection.RemoveTrack(senders[0]); err != nil {
panic(err)
}
}
doSignaling(res, req)
fmt.Println("Video track has been removed")
}
func main() {
var err error
if peerConnection, err = webrtc.NewPeerConnection(webrtc.Configuration{}); err != nil {
panic(err)
}
defer func() {
if cErr := peerConnection.Close(); cErr != nil {
fmt.Printf("cannot close peerConnection: %v\n", cErr)
}
}()
// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
if state == webrtc.PeerConnectionStateFailed {
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
// It may be reconnected using an ICE Restart.
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
fmt.Println("Peer Connection has gone to failed exiting")
os.Exit(0)
}
if state == webrtc.PeerConnectionStateClosed {
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
fmt.Println("Peer Connection has gone to closed exiting")
os.Exit(0)
}
})
http.Handle("/", http.FileServer(http.Dir(".")))
http.HandleFunc("/createPeerConnection", createPeerConnection)
http.HandleFunc("/addVideo", addVideo)
http.HandleFunc("/removeVideo", removeVideo)
go func() {
fmt.Println("Open http://localhost:8080 to access this demo")
// nolint: gosec
panic(http.ListenAndServe(":8080", nil))
}()
// Block forever
select {}
}
// Read a video file from disk and write it to a webrtc.Track
// When the video has been completely read this exits without error.
func writeVideoToTrack(
ivf *ivfreader.IVFReader, header *ivfreader.IVFFileHeader, track *webrtc.TrackLocalStaticSample,
) {
// Send our video file frame at a time. Pace our sending so we send it at the same speed it should be played back as.
// This isn't required since the video is timestamped, but we will such much higher loss if we send all at once.
//
// It is important to use a time.Ticker instead of time.Sleep because
// * avoids accumulating skew, just calling time.Sleep didn't compensate for the time spent parsing the data
// * works around latency issues with Sleep (see https://github.com/golang/go/issues/44343)
ticker := time.NewTicker(
time.Millisecond * time.Duration((float32(header.TimebaseNumerator)/float32(header.TimebaseDenominator))*1000),
)
defer ticker.Stop()
for ; true; <-ticker.C {
frame, _, err := ivf.ParseNextFrame()
if err != nil {
fmt.Printf("Finish writing video track: %s ", err)
return
}
if err = track.WriteSample(media.Sample{Data: frame, Duration: time.Second}); err != nil {
fmt.Printf("Finish writing video track: %s ", err)
return
}
}
}

View File

@@ -0,0 +1,41 @@
# play-from-disk
play-from-disk demonstrates how to send video and/or audio to your browser from files saved to disk.
For an example of playing H264 from disk see [play-from-disk-h264](https://github.com/pion/example-webrtc-applications/tree/main/play-from-disk-h264)
## Instructions
### Create IVF named `output.ivf` that contains a VP8/VP9/AV1 track and/or `output.ogg` that contains a Opus track
```
ffmpeg -i "$INPUT_FILE" -g 30 -b:v 2M output.ivf
ffmpeg -i "$INPUT_FILE" -vn -c:a libopus -ac 2 -frame_duration 20 -page_duration 20000 -map_metadata -1 output.ogg
```
**Note**: In the `ffmpeg` command which produces the .ivf file, the argument `-b:v 2M` specifies the video bitrate to be 2 megabits per second. We provide this default value to produce decent video quality, but if you experience problems with this configuration (such as dropped frames etc.), you can decrease this. See the [ffmpeg documentation](https://ffmpeg.org/ffmpeg.html#Options) for more information on the format of the value.
### Download play-from-disk
```
go install github.com/pion/webrtc/v4/examples/play-from-disk@latest
```
### Open play-from-disk example page
[jsfiddle.net](https://jsfiddle.net/8kup9mvn/) you should see two text-areas, 'Start Session' button and 'Copy browser SessionDescription to clipboard'
### Run play-from-disk with your browsers Session Description as stdin
The `output.ivf` you created should be in the same directory as `play-from-disk`. In the jsfiddle press 'Copy browser Session Description to clipboard' or copy the base64 string manually.
Now use this value you just copied as the input to `play-from-disk`
#### Linux/macOS
Run `echo $BROWSER_SDP | play-from-disk`
#### Windows
1. Paste the SessionDescription into a file.
1. Run `play-from-disk < my_file`
### Input play-from-disk's Session Description into your browser
Copy the text that `play-from-disk` just emitted and copy into the second text area in the jsfiddle
### Hit 'Start Session' in jsfiddle, enjoy your video!
A video should start playing in your browser above the input boxes. `play-from-disk` will exit when the file reaches the end
Congrats, you have used Pion WebRTC! Now start building something cool

View File

@@ -0,0 +1,8 @@
/*
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
*/
textarea {
width: 500px;
min-height: 75px;
}

View File

@@ -0,0 +1,8 @@
---
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
name: play-from-disk
description: play-from-disk demonstrates how to send video to your browser from a file saved to disk.
authors:
- Sean DuBois

View File

@@ -0,0 +1,30 @@
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
Browser Session Description
<br/>
<textarea id="localSessionDescription" readonly="true"></textarea>
<br/>
<button onclick="window.copySessionDescription()">Copy browser Session Description to clipboard</button>
<br/>
<br/>
<br/>
Remote Session Description
<br/>
<textarea id="remoteSessionDescription"></textarea>
<br/>
<button onclick="window.startSession()">Start Session</button>
<br/>
<br/>
Video
<br/>
<div id="remoteVideos"></div> <br />
Logs
<br/>
<div id="div"></div>

View File

@@ -0,0 +1,67 @@
/* eslint-env browser */
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
const pc = new RTCPeerConnection({
iceServers: [{
urls: 'stun:stun.l.google.com:19302'
}]
})
const log = msg => {
document.getElementById('div').innerHTML += msg + '<br>'
}
pc.ontrack = function (event) {
const el = document.createElement(event.track.kind)
el.srcObject = event.streams[0]
el.autoplay = true
el.controls = true
document.getElementById('remoteVideos').appendChild(el)
}
pc.oniceconnectionstatechange = e => log(pc.iceConnectionState)
pc.onicecandidate = event => {
if (event.candidate === null) {
document.getElementById('localSessionDescription').value = btoa(JSON.stringify(pc.localDescription))
}
}
// Offer to receive 1 audio, and 1 video track
pc.addTransceiver('video', {
direction: 'sendrecv'
})
pc.addTransceiver('audio', {
direction: 'sendrecv'
})
pc.createOffer().then(d => pc.setLocalDescription(d)).catch(log)
window.startSession = () => {
const sd = document.getElementById('remoteSessionDescription').value
if (sd === '') {
return alert('Session Description must not be empty')
}
try {
pc.setRemoteDescription(JSON.parse(atob(sd)))
} catch (e) {
alert(e)
}
}
window.copySessionDescription = () => {
const browserSessionDescription = document.getElementById('localSessionDescription')
browserSessionDescription.focus()
browserSessionDescription.select()
try {
const successful = document.execCommand('copy')
const msg = successful ? 'successful' : 'unsuccessful'
log('Copying SessionDescription was ' + msg)
} catch (err) {
log('Oops, unable to copy SessionDescription ' + err)
}
}

View File

@@ -0,0 +1,335 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
// play-from-disk demonstrates how to send video and/or audio to your browser from files saved to disk.
package main
import (
"bufio"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
"github.com/pion/webrtc/v4/pkg/media/ivfreader"
"github.com/pion/webrtc/v4/pkg/media/oggreader"
)
const (
audioFileName = "output.ogg"
videoFileName = "output.ivf"
oggPageDuration = time.Millisecond * 20
)
func main() { //nolint:gocognit,cyclop,gocyclo,maintidx
// Assert that we have an audio or video file
_, err := os.Stat(videoFileName)
haveVideoFile := !os.IsNotExist(err)
_, err = os.Stat(audioFileName)
haveAudioFile := !os.IsNotExist(err)
if !haveAudioFile && !haveVideoFile {
panic("Could not find `" + audioFileName + "` or `" + videoFileName + "`")
}
// Create a new RTCPeerConnection
peerConnection, err := webrtc.NewPeerConnection(webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
})
if err != nil {
panic(err)
}
defer func() {
if cErr := peerConnection.Close(); cErr != nil {
fmt.Printf("cannot close peerConnection: %v\n", cErr)
}
}()
iceConnectedCtx, iceConnectedCtxCancel := context.WithCancel(context.Background())
if haveVideoFile { //nolint:nestif
file, openErr := os.Open(videoFileName)
if openErr != nil {
panic(openErr)
}
_, header, openErr := ivfreader.NewWith(file)
if openErr != nil {
panic(openErr)
}
// Determine video codec
var trackCodec string
switch header.FourCC {
case "AV01":
trackCodec = webrtc.MimeTypeAV1
case "VP90":
trackCodec = webrtc.MimeTypeVP9
case "VP80":
trackCodec = webrtc.MimeTypeVP8
default:
panic(fmt.Sprintf("Unable to handle FourCC %s", header.FourCC))
}
// Create a video track
videoTrack, videoTrackErr := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: trackCodec}, "video", "pion",
)
if videoTrackErr != nil {
panic(videoTrackErr)
}
rtpSender, videoTrackErr := peerConnection.AddTrack(videoTrack)
if videoTrackErr != nil {
panic(videoTrackErr)
}
// Read incoming RTCP packets
// Before these packets are returned they are processed by interceptors. For things
// like NACK this needs to be called.
go func() {
rtcpBuf := make([]byte, 1500)
for {
if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
return
}
}
}()
go func() {
// Open a IVF file and start reading using our IVFReader
file, ivfErr := os.Open(videoFileName)
if ivfErr != nil {
panic(ivfErr)
}
ivf, header, ivfErr := ivfreader.NewWith(file)
if ivfErr != nil {
panic(ivfErr)
}
// Wait for connection established
<-iceConnectedCtx.Done()
// Send our video file frame at a time. Pace our sending so we send it at the same speed it should be played back as.
// This isn't required since the video is timestamped, but we will such much higher loss if we send all at once.
//
// It is important to use a time.Ticker instead of time.Sleep because
// * avoids accumulating skew, just calling time.Sleep didn't compensate for the time spent parsing the data
// * works around latency issues with Sleep (see https://github.com/golang/go/issues/44343)
ticker := time.NewTicker(
time.Millisecond * time.Duration((float32(header.TimebaseNumerator)/float32(header.TimebaseDenominator))*1000),
)
defer ticker.Stop()
for ; true; <-ticker.C {
frame, _, ivfErr := ivf.ParseNextFrame()
if errors.Is(ivfErr, io.EOF) {
fmt.Printf("All video frames parsed and sent")
os.Exit(0)
}
if ivfErr != nil {
panic(ivfErr)
}
if ivfErr = videoTrack.WriteSample(media.Sample{Data: frame, Duration: time.Second}); ivfErr != nil {
panic(ivfErr)
}
}
}()
}
if haveAudioFile { //nolint:nestif
// Create a audio track
audioTrack, audioTrackErr := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus}, "audio", "pion",
)
if audioTrackErr != nil {
panic(audioTrackErr)
}
rtpSender, audioTrackErr := peerConnection.AddTrack(audioTrack)
if audioTrackErr != nil {
panic(audioTrackErr)
}
// Read incoming RTCP packets
// Before these packets are returned they are processed by interceptors. For things
// like NACK this needs to be called.
go func() {
rtcpBuf := make([]byte, 1500)
for {
if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
return
}
}
}()
go func() {
// Open a OGG file and start reading using our OGGReader
file, oggErr := os.Open(audioFileName)
if oggErr != nil {
panic(oggErr)
}
// Open on oggfile in non-checksum mode.
ogg, _, oggErr := oggreader.NewWith(file)
if oggErr != nil {
panic(oggErr)
}
// Wait for connection established
<-iceConnectedCtx.Done()
// Keep track of last granule, the difference is the amount of samples in the buffer
var lastGranule uint64
// It is important to use a time.Ticker instead of time.Sleep because
// * avoids accumulating skew, just calling time.Sleep didn't compensate for the time spent parsing the data
// * works around latency issues with Sleep (see https://github.com/golang/go/issues/44343)
ticker := time.NewTicker(oggPageDuration)
defer ticker.Stop()
for ; true; <-ticker.C {
pageData, pageHeader, oggErr := ogg.ParseNextPage()
if errors.Is(oggErr, io.EOF) {
fmt.Printf("All audio pages parsed and sent")
os.Exit(0)
}
if oggErr != nil {
panic(oggErr)
}
// The amount of samples is the difference between the last and current timestamp
sampleCount := float64(pageHeader.GranulePosition - lastGranule)
lastGranule = pageHeader.GranulePosition
sampleDuration := time.Duration((sampleCount/48000)*1000) * time.Millisecond
if oggErr = audioTrack.WriteSample(media.Sample{Data: pageData, Duration: sampleDuration}); oggErr != nil {
panic(oggErr)
}
}
}()
}
// Set the handler for ICE connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
fmt.Printf("Connection State has changed %s \n", connectionState.String())
if connectionState == webrtc.ICEConnectionStateConnected {
iceConnectedCtxCancel()
}
})
// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
if state == webrtc.PeerConnectionStateFailed {
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
// It may be reconnected using an ICE Restart.
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
fmt.Println("Peer Connection has gone to failed exiting")
os.Exit(0)
}
if state == webrtc.PeerConnectionStateClosed {
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
fmt.Println("Peer Connection has gone to closed exiting")
os.Exit(0)
}
})
// Wait for the offer to be pasted
offer := webrtc.SessionDescription{}
decode(readUntilNewline(), &offer)
// Set the remote SessionDescription
if err = peerConnection.SetRemoteDescription(offer); err != nil {
panic(err)
}
// Create answer
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
}
// Create channel that is blocked until ICE Gathering is complete
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
// Sets the LocalDescription, and starts our UDP listeners
if err = peerConnection.SetLocalDescription(answer); err != nil {
panic(err)
}
// Block until ICE Gathering is complete, disabling trickle ICE
// we do this because we only can exchange one signaling message
// in a production application you should exchange ICE Candidates via OnICECandidate
<-gatherComplete
// Output the answer in base64 so we can paste it in browser
fmt.Println(encode(peerConnection.LocalDescription()))
// Block forever
select {}
}
// Read from stdin until we get a newline.
func readUntilNewline() (in string) {
var err error
r := bufio.NewReader(os.Stdin)
for {
in, err = r.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
panic(err)
}
if in = strings.TrimSpace(in); len(in) > 0 {
break
}
}
fmt.Println("")
return
}
// JSON encode + base64 a SessionDescription.
func encode(obj *webrtc.SessionDescription) string {
b, err := json.Marshal(obj)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(b)
}
// Decode a base64 and unmarshal JSON into a SessionDescription.
func decode(in string, obj *webrtc.SessionDescription) {
b, err := base64.StdEncoding.DecodeString(in)
if err != nil {
panic(err)
}
if err = json.Unmarshal(b, obj); err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,48 @@
# quick-switch
quick-switch demonstrates how to quickly switch between multiple videos using WebRTC.
Similiar to how sites like TikTok quickly swipe between videos
<video src="https://github.com/user-attachments/assets/07f15044-3aeb-44e2-a866-14bd4258aca0" autoplay loop muted> </video>
The logic in the frontend is purposefully kept as simple as possible. Making it easy to use this on any platform that supports WebRTC.
In the `main.go` we have one video track, and we switch which video file is written to it.
## Instructions
### Download quick-switch
This example requires you to clone the repo since it is serving static HTML.
```
git clone https://github.com/pion/webrtc.git
cd webrtc/examples/quick-switch
```
### Create your videos
This example expects AV1 inside a ivf container. I used the follow to encode for my demo.
```
ffmpeg -y \
-i $YOUR_INPUT_VIDEO \
-c:v libaom-av1 \
-usage realtime \
-lag-in-frames 0 \
-crf 30 \
-b:v 0 \
-g 15 \
-keyint_min 15 \
-sc_threshold 0 \
-pix_fmt yuv420p \
-f ivf \
output.ivf
```
### Run quick-switch
Execute `go run *.go`
### Open the Web UI
Open [http://localhost:8080](http://localhost:8080). This will automatically start a PeerConnection.
Press 'Next Video' and have fun! If you have ideas on how to make it better we would love to hear.

View File

@@ -0,0 +1,45 @@
<html>
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
<head>
<title>quick-switch</title>
</head>
<body>
<video id="video" autoplay muted playsinline width="500" height="500"></video>
<button id="nextVideo"> Next Video </button><br />
</body>
<script>
let peerConnection = new RTCPeerConnection()
peerConnection.addTransceiver('video', {direction: 'recvonly'})
peerConnection.ontrack = function (event) {
document.getElementById('video').srcObject = event.streams[0]
}
let dataChannel = peerConnection.createDataChannel('')
dataChannel.onopen = () => {
document.getElementById('nextVideo').onclick = () => {
dataChannel.send('')
}
}
peerConnection.createOffer()
.then(offer => peerConnection.setLocalDescription(offer)
.then(() => fetch(`/whip`, {
method: 'POST',
headers: {
'Content-Type': 'application/sdp'
},
body: offer.sdp
}))
)
.then(res => res.text())
.then(res => peerConnection.setRemoteDescription({type: 'answer', sdp: res}))
.catch(alert)
</script>
</html>

View File

@@ -0,0 +1,188 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
// quick-switch demonstrates Pion WebRTC's ability to quickly switch between videos.
package main
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"slices"
"sync"
"sync/atomic"
"time"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
"github.com/pion/webrtc/v4/pkg/media/ivfreader"
)
// nolint: gochecknoglobals
var (
tracksLock sync.RWMutex
tracks []*webrtc.TrackLocalStaticSample
videoFiles []string
videoFileIndex atomic.Int32
)
func nextVideo() {
newIndex := videoFileIndex.Load() + 1
if int(newIndex) >= len(videoFiles) {
newIndex = 0
}
videoFileIndex.Store(newIndex)
}
// nolint: cyclop
func doWHIP(res http.ResponseWriter, req *http.Request) {
peerConnection, err := webrtc.NewPeerConnection(webrtc.Configuration{})
if err != nil {
panic(err)
}
peerConnection.OnDataChannel(func(d *webrtc.DataChannel) {
d.OnMessage(func(_ webrtc.DataChannelMessage) {
nextVideo()
})
})
// One Track is used for PeerConnection. All video streams are written to one Track
videoTrack, err := webrtc.NewTrackLocalStaticSample(webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeAV1,
}, "video", "video")
if err != nil {
panic(err)
}
if _, err = peerConnection.AddTrack(videoTrack); err != nil {
panic(err)
}
tracksLock.Lock()
tracks = append(tracks, videoTrack)
tracksLock.Unlock()
// Set the handler for ICE connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
fmt.Printf("ICE Connection State has changed: %s\n", connectionState.String())
if connectionState == webrtc.ICEConnectionStateClosed || connectionState == webrtc.ICEConnectionStateFailed {
if closeErr := peerConnection.Close(); closeErr != nil {
panic(closeErr)
}
tracksLock.Lock()
tracks = slices.DeleteFunc(tracks, func(x *webrtc.TrackLocalStaticSample) bool { return x == videoTrack })
tracksLock.Unlock()
}
})
offer, err := io.ReadAll(req.Body)
if err != nil {
panic(err)
}
if err = peerConnection.SetRemoteDescription(webrtc.SessionDescription{
Type: webrtc.SDPTypeOffer,
SDP: string(offer),
}); err != nil {
panic(err)
}
// Create channel that is blocked until ICE Gathering is complete
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
} else if err = peerConnection.SetLocalDescription(answer); err != nil {
panic(err)
}
// Block until ICE Gathering is complete, disabling trickle ICE
// we do this because we only can exchange one signaling message
// in a production application you should exchange ICE Candidates via OnICECandidate
<-gatherComplete
res.Header().Set("Content-Type", "application/sdp")
if _, err := res.Write([]byte(peerConnection.LocalDescription().SDP)); err != nil {
panic(err)
}
}
func playFile(fileIndex int32) {
file, err := os.Open(videoFiles[fileIndex])
if err != nil {
panic(err)
}
defer file.Close() // nolint: errcheck
ivf, header, err := ivfreader.NewWith(file)
if err != nil {
panic(err)
}
frameDuration := time.Duration(header.TimebaseNumerator) * time.Second / time.Duration(header.TimebaseDenominator)
ticker := time.NewTicker(frameDuration)
defer ticker.Stop()
for {
if fileIndex != videoFileIndex.Load() {
return
}
frame, _, err := ivf.ParseNextFrame()
if errors.Is(err, io.EOF) {
nextVideo()
return
} else if err != nil {
panic(err)
}
tracksLock.RLock()
for _, t := range tracks {
if err = t.WriteSample(media.Sample{Data: frame, Duration: frameDuration}); err != nil {
tracksLock.RUnlock()
panic(err)
}
}
tracksLock.RUnlock()
<-ticker.C
}
}
func main() {
http.Handle("/", http.FileServer(http.Dir(".")))
http.HandleFunc("/whip", doWHIP)
// Switch between all ivf files in current directory
go func() {
files, err := filepath.Glob("*.ivf")
if err != nil {
panic(err)
}
for _, p := range files {
videoFiles = append(videoFiles, filepath.Base(p))
}
if len(videoFiles) == 0 {
panic("no .ivf files found in the working directory")
}
for {
playFile(videoFileIndex.Load())
}
}()
fmt.Println("Open http://localhost:8080 to access this demo")
// nolint: gosec
panic(http.ListenAndServe(":8080", nil))
}

View File

@@ -0,0 +1,29 @@
# reflect
reflect demonstrates how with one PeerConnection you can send video to Pion and have the packets sent back. This example could be easily extended to do server side processing.
## Instructions
### Download reflect
```
go install github.com/pion/webrtc/v4/examples/reflect@latest
```
### Open reflect example page
[jsfiddle.net](https://jsfiddle.net/g643ft1k/) you should see two text-areas and a 'Start Session' button.
### Run reflect, with your browsers SessionDescription as stdin
In the jsfiddle the top textarea is your browser's Session Description. Press `Copy browser SDP to clipboard` or copy the base64 string manually.
We will use this value in the next step.
#### Linux/macOS
Run `echo $BROWSER_SDP | reflect`
#### Windows
1. Paste the SessionDescription into a file.
1. Run `reflect < my_file`
### Input reflect's SessionDescription into your browser
Copy the text that `reflect` just emitted and copy into second text area
### Hit 'Start Session' in jsfiddle, enjoy your video!
Your browser should send video to Pion, and then it will be relayed right back to you.
Congrats, you have used Pion WebRTC! Now start building something cool

View File

@@ -0,0 +1,8 @@
/*
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
*/
textarea {
width: 500px;
min-height: 75px;
}

View File

@@ -0,0 +1,8 @@
---
# SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
# SPDX-License-Identifier: MIT
name: reflect
description: Example of how to have Pion send back to the user exactly what it receives using the same PeerConnection.
authors:
- Sean DuBois

View File

@@ -0,0 +1,23 @@
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
Browser base64 Session Description<br />
<textarea id="localSessionDescription" readonly="true"></textarea> <br />
<button onclick="window.copySDP()">
Copy browser SDP to clipboard
</button>
<br />
<br />
Golang base64 Session Description<br />
<textarea id="remoteSessionDescription"></textarea> <br/>
<button onclick="window.startSession()"> Start Session </button><br />
<br />
Video<br />
<div id="remoteVideos"></div> <br />
Logs<br />
<div id="logs"></div>

View File

@@ -0,0 +1,64 @@
/* eslint-env browser */
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
const pc = new RTCPeerConnection({
iceServers: [
{
urls: 'stun:stun.l.google.com:19302'
}
]
})
const log = msg => {
document.getElementById('logs').innerHTML += msg + '<br>'
}
navigator.mediaDevices.getUserMedia({ video: true, audio: true })
.then(stream => {
stream.getTracks().forEach(track => pc.addTrack(track, stream))
pc.createOffer().then(d => pc.setLocalDescription(d)).catch(log)
}).catch(log)
pc.oniceconnectionstatechange = e => log(pc.iceConnectionState)
pc.onicecandidate = event => {
if (event.candidate === null) {
document.getElementById('localSessionDescription').value = btoa(JSON.stringify(pc.localDescription))
}
}
pc.ontrack = function (event) {
const el = document.createElement(event.track.kind)
el.srcObject = event.streams[0]
el.autoplay = true
el.controls = true
document.getElementById('remoteVideos').appendChild(el)
}
window.startSession = () => {
const sd = document.getElementById('remoteSessionDescription').value
if (sd === '') {
return alert('Session Description must not be empty')
}
try {
pc.setRemoteDescription(JSON.parse(atob(sd)))
} catch (e) {
alert(e)
}
}
window.copySDP = () => {
const browserSDP = document.getElementById('localSessionDescription')
browserSDP.focus()
browserSDP.select()
try {
const successful = document.execCommand('copy')
const msg = successful ? 'successful' : 'unsuccessful'
log('Copying SDP was ' + msg)
} catch (err) {
log('Unable to copy SDP ' + err)
}
}

226
examples/reflect/main.go Normal file
View File

@@ -0,0 +1,226 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT
//go:build !js
// reflect demonstrates how with one PeerConnection you can send video to Pion and have the packets sent back
package main
import (
"bufio"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"strings"
"github.com/pion/interceptor"
"github.com/pion/interceptor/pkg/intervalpli"
"github.com/pion/webrtc/v4"
)
// nolint:gocognit, cyclop
func main() {
// Everything below is the Pion WebRTC API! Thanks for using it ❤️.
// Create a MediaEngine object to configure the supported codec
mediaEngine := &webrtc.MediaEngine{}
// Setup the codecs you want to use.
// We'll use a VP8 and Opus but you can also define your own
if err := mediaEngine.RegisterCodec(webrtc.RTPCodecParameters{
RTPCodecCapability: webrtc.RTPCodecCapability{
MimeType: webrtc.MimeTypeVP8, ClockRate: 90000, Channels: 0, SDPFmtpLine: "", RTCPFeedback: nil,
},
PayloadType: 96,
}, webrtc.RTPCodecTypeVideo); err != nil {
panic(err)
}
// Create a InterceptorRegistry. This is the user configurable RTP/RTCP Pipeline.
// This provides NACKs, RTCP Reports and other features. If you use `webrtc.NewPeerConnection`
// this is enabled by default. If you are manually managing You MUST create a InterceptorRegistry
// for each PeerConnection.
interceptorRegistry := &interceptor.Registry{}
// Use the default set of Interceptors
if err := webrtc.RegisterDefaultInterceptors(mediaEngine, interceptorRegistry); err != nil {
panic(err)
}
// Register a intervalpli factory
// This interceptor sends a PLI every 3 seconds. A PLI causes a video keyframe to be generated by the sender.
// This makes our video seekable and more error resilent, but at a cost of lower picture quality and higher bitrates
// A real world application should process incoming RTCP packets from viewers and forward them to senders
intervalPliFactory, err := intervalpli.NewReceiverInterceptor()
if err != nil {
panic(err)
}
interceptorRegistry.Add(intervalPliFactory)
// Create the API object with the MediaEngine
api := webrtc.NewAPI(webrtc.WithMediaEngine(mediaEngine), webrtc.WithInterceptorRegistry(interceptorRegistry))
// Prepare the configuration
config := webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
}
// Create a new RTCPeerConnection
peerConnection, err := api.NewPeerConnection(config)
if err != nil {
panic(err)
}
defer func() {
if cErr := peerConnection.Close(); cErr != nil {
fmt.Printf("cannot close peerConnection: %v\n", cErr)
}
}()
// Create Track that we send video back to browser on
outputTrack, err := webrtc.NewTrackLocalStaticRTP(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeVP8}, "video", "pion",
)
if err != nil {
panic(err)
}
// Add this newly created track to the PeerConnection
rtpSender, err := peerConnection.AddTrack(outputTrack)
if err != nil {
panic(err)
}
// Read incoming RTCP packets
// Before these packets are returned they are processed by interceptors. For things
// like NACK this needs to be called.
go func() {
rtcpBuf := make([]byte, 1500)
for {
if _, _, rtcpErr := rtpSender.Read(rtcpBuf); rtcpErr != nil {
return
}
}
}()
// Wait for the offer to be pasted
offer := webrtc.SessionDescription{}
decode(readUntilNewline(), &offer)
// Set the remote SessionDescription
err = peerConnection.SetRemoteDescription(offer)
if err != nil {
panic(err)
}
// Set a handler for when a new remote track starts, this handler copies inbound RTP packets,
// replaces the SSRC and sends them back
peerConnection.OnTrack(func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { //nolint: revive
fmt.Printf("Track has started, of type %d: %s \n", track.PayloadType(), track.Codec().MimeType)
for {
// Read RTP packets being sent to Pion
rtp, _, readErr := track.ReadRTP()
if readErr != nil {
panic(readErr)
}
if writeErr := outputTrack.WriteRTP(rtp); writeErr != nil {
panic(writeErr)
}
}
})
// Set the handler for Peer connection state
// This will notify you when the peer has connected/disconnected
peerConnection.OnConnectionStateChange(func(state webrtc.PeerConnectionState) {
fmt.Printf("Peer Connection State has changed: %s\n", state.String())
if state == webrtc.PeerConnectionStateFailed {
// Wait until PeerConnection has had no network activity for 30 seconds or another failure.
// It may be reconnected using an ICE Restart.
// Use webrtc.PeerConnectionStateDisconnected if you are interested in detecting faster timeout.
// Note that the PeerConnection may come back from PeerConnectionStateDisconnected.
fmt.Println("Peer Connection has gone to failed exiting")
os.Exit(0)
}
if state == webrtc.PeerConnectionStateClosed {
// PeerConnection was explicitly closed. This usually happens from a DTLS CloseNotify
fmt.Println("Peer Connection has gone to closed exiting")
os.Exit(0)
}
})
// Create an answer
answer, err := peerConnection.CreateAnswer(nil)
if err != nil {
panic(err)
}
// Create channel that is blocked until ICE Gathering is complete
gatherComplete := webrtc.GatheringCompletePromise(peerConnection)
// Sets the LocalDescription, and starts our UDP listeners
if err = peerConnection.SetLocalDescription(answer); err != nil {
panic(err)
}
// Block until ICE Gathering is complete, disabling trickle ICE
// we do this because we only can exchange one signaling message
// in a production application you should exchange ICE Candidates via OnICECandidate
<-gatherComplete
// Output the answer in base64 so we can paste it in browser
fmt.Println(encode(peerConnection.LocalDescription()))
// Block forever
select {}
}
// Read from stdin until we get a newline.
func readUntilNewline() (in string) {
var err error
r := bufio.NewReader(os.Stdin)
for {
in, err = r.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
panic(err)
}
if in = strings.TrimSpace(in); len(in) > 0 {
break
}
}
fmt.Println("")
return
}
// JSON encode + base64 a SessionDescription.
func encode(obj *webrtc.SessionDescription) string {
b, err := json.Marshal(obj)
if err != nil {
panic(err)
}
return base64.StdEncoding.EncodeToString(b)
}
// Decode a base64 and unmarshal JSON into a SessionDescription.
func decode(in string, obj *webrtc.SessionDescription) {
b, err := base64.StdEncoding.DecodeString(in)
if err != nil {
panic(err)
}
if err = json.Unmarshal(b, obj); err != nil {
panic(err)
}
}

View File

@@ -0,0 +1,25 @@
# repacketize
repacketize demonstrates how many video codecs can be received, depacketized and packetized by Pion over RTP.
## Instructions
### Download and run repacketize
```
go install github.com/pion/webrtc/v4/examples/repacketize@latest
```
### Open repacketize local page
[localhost:8080](http://localhost:8080/) you should see a dropdown selector and a "Start" button.
### Select one of the video codecs
Availability of codecs depends on the browser you are accessing the page from; Safari and Google Chrome on Windows should support all of them.
### Hit 'Start', enjoy your video
Your browser should send video to Pion, and then it will be relayed right back to you.
Congrats, you have used Pion WebRTC! Now start building something cool.

View File

@@ -0,0 +1,28 @@
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Pion - Repacketize Example</title>
<script src="index.js" defer></script>
</head>
<body>
<label for="codec">Codec: </label>
<select id="codec">
<option value="H264" selected>H.264</option>
<option value="H265">H.265</option>
<option value="VP8">VP8</option>
<option value="VP9">VP9</option>
<option value="AV1">AV1</option>
</select>
<button id="start">Start</button>
<br />
<video id="screen" autoplay hidden></video>
<video id="received" autoplay hidden></video>
<br />
<div id="logs"></div>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More