Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ If you have a request please make an issue, we also love contributions more exam
* [Janus Gateway](janus-gateway): Example janus-gateway is a collection of examples showing how to use Pion WebRTC with [janus-gateway](https://github.com/meetecho/janus-gateway).
* [SFU Websocket](sfu-ws): The SFU example demonstrates a conference system that uses WebSocket for signaling. It also includes a flutter client for Android, iOS and Native.
* [Save to WebM](save-to-webm): Example save-to-webm shows how to receive audio and video using Pion and then save to WebM/Matroska container.
* [Text to Speech](text-to-speech): Example text-to-speech converts text with eSpeak NG and streams the encoded Opus audio to a browser over WebRTC.
* [Twitch](twitch): Example twitch shows how to send audio/video from WebRTC to https://www.twitch.tv/ via RTMP.
* [C DataChannels](c-data-channels) Example c-data-channels shows how you can use Pion WebRTC from a C program
* [Snapshot](snapshot) Example snapshot shows how you can convert incoming video frames to jpeg and serve them via HTTP.
Expand Down
6 changes: 6 additions & 0 deletions examples.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@
"description": "save-to-webm demonstrates how to save audio/video from your browser as a webm to the local disk.",
"type": "browser"
},
{
"title": "Text to Speech",
"link": "text-to-speech",
"description": "text-to-speech converts text with eSpeak NG and streams the encoded Opus audio to a browser over WebRTC.",
"type": "browser"
},
{
"title": "C DataChannels",
"link": "#",
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ require (
github.com/notedit/janus-go v0.0.0-20210115013133-fdce1b146d0e
github.com/pion/interceptor v0.1.45
github.com/pion/logging v0.2.4
github.com/pion/opus v0.1.1-0.20260712200830-b06512674d84
github.com/pion/rtcp v1.2.17
github.com/pion/rtp v1.10.3
github.com/pion/sdp/v3 v3.0.19
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
github.com/pion/mdns/v2 v2.1.0 h1:3IJ9+Xio6tWYjhN6WwuY142P/1jA0D5ERaIqawg/fOY=
github.com/pion/mdns/v2 v2.1.0/go.mod h1:pcez23GdynwcfRU1977qKU0mDxSeucttSHbCSfFOd9A=
github.com/pion/opus v0.1.1-0.20260712200830-b06512674d84 h1:lE+9GWU3T83e+P4jva8K5HqF2CzjR9ug32Be7EPXEnw=
github.com/pion/opus v0.1.1-0.20260712200830-b06512674d84/go.mod h1:t5Xog2n682JnawoykACE6nKVmupFvmJvkpM7x6bTv6g=
github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA=
github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8=
github.com/pion/rtcp v1.2.17 h1:PxiT6L79yPZKtXIsXdG1eakBl6dtBj4x+4oVEL0DlSw=
Expand Down
61 changes: 61 additions & 0 deletions text-to-speech/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Text-to-Speech Example

This example converts text to speech with eSpeak NG and streams the audio to a browser over WebRTC. It uses Pion's
pure Go Opus encoder.

## Install `espeak-ng`

Your system must have the `espeak-ng` executable installed and available in your `PATH`.

### macOS

```sh
brew install espeak-ng
```

### Ubuntu / Debian

```sh
sudo apt update
sudo apt install espeak-ng
```

Verify the installation:

```sh
espeak-ng --version
```

## Run the example

Clone the repository and enter the example directory:

```sh
git clone https://github.com/pion/example-webrtc-applications.git
cd example-webrtc-applications/text-to-speech
```

Start the server:

```sh
go run main.go
```

Open [http://localhost:8080](http://localhost:8080) in your browser.

## Usage

1. Wait for the ICE connection state to show `connected`.
2. Enter text in the text area.
3. Click **Convert to Speech** to hear it in the browser.

## How it works

- The browser sends text to the Go server over a WebRTC data channel.
- eSpeak NG produces mono 16-bit WAV audio at 22.05 kHz.
- The server converts the PCM samples to 48 kHz and encodes 20 ms Opus frames.
- Generated speech is queued. When no speech is available, the server continues sending encoded Opus silence.

## License

This project is licensed under the MIT License. See the repository's `LICENSE` file for details.
86 changes: 86 additions & 0 deletions text-to-speech/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<html>
<!--
SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
SPDX-License-Identifier: MIT
-->
<head>
<title>text-to-speech</title>
<style>
body {
font-family: Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
div {
margin-bottom: 15px;
}
textarea {
width: 100%;
padding: 8px;
}
button {
padding: 10px 20px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
</style>
</head>

<body>
<h3> ICE Connection States </h3>
<div id="iceConnectionStates"></div>
<div>
<textarea id="textInput" rows="4" placeholder="Enter text to convert to speech"></textarea>
</div>
<div>
<button id="convertButton" onclick="window.sendTextToSpeechRequest(document.getElementById('textInput').value)">Convert to Speech</button>
</div>
</body>

<script>
let peerConnection = new RTCPeerConnection()
let dataChannel = peerConnection.createDataChannel('data')
peerConnection.addTransceiver('audio', {direction: 'recvonly'})

peerConnection.oniceconnectionstatechange = () => {
let el = document.createElement('p')
el.appendChild(document.createTextNode(peerConnection.iceConnectionState))
document.getElementById('iceConnectionStates').appendChild(el);
}

peerConnection.ontrack = function (event) {
const el = document.createElement(event.track.kind)
el.srcObject = event.streams[0]
el.autoplay = true
el.controls = true

document.body.appendChild(el)
}


window.sendTextToSpeechRequest = text => {
dataChannel.send(text)
}

peerConnection.createOffer()
.then(offer => {
peerConnection.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 => peerConnection.setRemoteDescription(res))
.catch(alert)
</script>
</html>
177 changes: 177 additions & 0 deletions text-to-speech/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
// SPDX-FileCopyrightText: 2026 The Pion community <https://pion.ly>
// SPDX-License-Identifier: MIT

//go:build !js

// text-to-speech demonstrates Text-to-Speech using eSpeak NG and Pion's pure Go Opus encoder.
package main

import (
"context"
"encoding/json"
"fmt"
"net/http"
"os/exec"
"time"

"github.com/pion/opus"
"github.com/pion/webrtc/v4"
"github.com/pion/webrtc/v4/pkg/media"
)

func doSignaling(res http.ResponseWriter, req *http.Request) { //nolint:cyclop
peerConnection, err := webrtc.NewPeerConnection(webrtc.Configuration{})
if err != nil {
panic(err)
}

audioTrack, err := webrtc.NewTrackLocalStaticSample(
webrtc.RTPCodecCapability{MimeType: webrtc.MimeTypeOpus},
"audio",
"pion",
)
if err != nil {
panic(err)
}

if _, err = peerConnection.AddTrack(audioTrack); err != nil {
panic(err)
}

wavAudio := make(chan []byte, 16)
speechContext := context.WithoutCancel(req.Context())

onDataChannelHandler := func(dataChannel *webrtc.DataChannel) {
dataChannel.OnMessage(func(msg webrtc.DataChannelMessage) {
commandContext, cancel := context.WithTimeout(speechContext, 30*time.Second)
defer cancel()

cmd := exec.CommandContext( //nolint:gosec // The text is an argument to eSpeak, not a shell command.
commandContext,
"espeak-ng",
"--stdout",
"-v", "en-us",
string(msg.Data),
)

wav, commandErr := cmd.Output()
if commandErr != nil {
panic(commandErr)
}

wavAudio <- wav
})
}
peerConnection.OnDataChannel(onDataChannelHandler)
go writeAudio(audioTrack, wavAudio)

peerConnection.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
fmt.Printf("ICE Connection State has changed: %s\n", connectionState.String())
})

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)
}
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 writeAudio(audioTrack *webrtc.TrackLocalStaticSample, wavAudio <-chan []byte) {
ticker := time.NewTicker(time.Millisecond * 20)
defer ticker.Stop()

encoder, err := opus.NewEncoder()
if err != nil {
panic(err)
}
encodedAudio := make([]byte, 1275)
frame := make([]byte, 960*2)
var pendingAudio []byte

for range ticker.C {
clear(frame)
if len(pendingAudio) == 0 {
select {
case wav := <-wavAudio:
pendingAudio = wavToPCM48kMono(wav)
default:
}
}

copied := copy(frame, pendingAudio)
pendingAudio = pendingAudio[copied:]

encodedLen, encodeErr := encoder.Encode(frame, encodedAudio)
if encodeErr != nil {
panic(encodeErr)
}
if writeErr := audioTrack.WriteSample(media.Sample{
Data: encodedAudio[:encodedLen],
Duration: 20 * time.Millisecond,
}); writeErr != nil {
panic(writeErr)
}
}
}

func wavToPCM48kMono(wav []byte) []byte {
// eSpeak returns mono 16-bit PCM at 22.05 kHz after a 44-byte WAV header.
const (
wavHeaderSize = 44
bytesPerSample = 2
wavSampleRate = 22050
opusSampleRate = 48000
)

pcm := wav[wavHeaderSize:]
inputSamples := len(pcm) / bytesPerSample
outputSamples := inputSamples * opusSampleRate / wavSampleRate
output := make([]byte, outputSamples*bytesPerSample)

for outputSample := range outputSamples {
inputSample := outputSample * wavSampleRate / opusSampleRate
inputOffset := inputSample * bytesPerSample
outputOffset := outputSample * bytesPerSample
copy(output[outputOffset:outputOffset+bytesPerSample], pcm[inputOffset:inputOffset+bytesPerSample])
}

return output
}

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))
}
Loading