├── .github └── workflows │ └── release.yml ├── .gitignore ├── .goreleaser.yaml ├── LICENSE ├── README.md ├── conn.go ├── embed.go ├── go.mod ├── go.sum ├── html ├── css │ └── common.css ├── fontello │ ├── LICENSE.txt │ ├── README.txt │ ├── config.json │ ├── css │ │ ├── animation.css │ │ ├── fontello-codes.css │ │ ├── fontello-embedded.css │ │ ├── fontello-ie7-codes.css │ │ ├── fontello-ie7.css │ │ └── fontello.css │ ├── demo.html │ └── font │ │ ├── fontello.eot │ │ ├── fontello.svg │ │ ├── fontello.ttf │ │ ├── fontello.woff │ │ └── fontello.woff2 ├── fonts │ └── Pacifico.woff2 ├── img │ └── whitenoise.png ├── index.html ├── js │ ├── common.js │ ├── publisher.js │ ├── soundmeter.js │ └── subscriber.js ├── publisher.html └── subscriber.html ├── http.go ├── main.go ├── registry.go └── webrtc.go /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: goreleaser 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | goreleaser: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - 16 | name: Checkout 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - run: git fetch --force --tags 21 | - 22 | name: Set up Go 23 | uses: actions/setup-go@v5 24 | with: 25 | go-version: 'stable' 26 | check-latest: true 27 | - 28 | name: Run GoReleaser 29 | uses: goreleaser/goreleaser-action@v5 30 | with: 31 | distribution: goreleaser 32 | version: latest 33 | #args: release --clean --skip=publish 34 | args: release --clean 35 | env: 36 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | dist 3 | babelcast 4 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | builds: 5 | - env: 6 | - CGO_ENABLED=0 7 | targets: 8 | - linux_amd64 9 | - windows_amd64 10 | - darwin_amd64 11 | archives: 12 | - 13 | name_template: >- 14 | {{- .ProjectName }}_ 15 | {{- .Version }}_ 16 | {{- title .Os }}_ 17 | {{- if eq .Arch "amd64" }}x86_64 18 | {{- else }}{{ .Arch }}{{ end }} 19 | {{- if .Arm }}v{{ .Arm }}{{ end -}} 20 | format_overrides: 21 | - goos: windows 22 | format: zip 23 | files: 24 | - LICENSE 25 | - README.md 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Babelcast 2 | 3 | A server which allows audio publishers to broadcast to subscribers on a channel, using nothing more than a modern web browser. 4 | 5 | It uses websockets for signalling & WebRTC for audio. 6 | 7 | The designed use case is for live events where language translation is happening. 8 | A translator would act as a publisher and people wanting to hear the translation would be subscribers. 9 | 10 | ## Building 11 | 12 | Download a [precompiled binary](https://github.com/porjo/babelcast/releases/latest) or build it yourself. 13 | 14 | ## Usage 15 | 16 | ``` 17 | Usage of ./babelcast: 18 | -debug 19 | enable debug log 20 | -port int 21 | listen on this port (default 8080) 22 | ``` 23 | 24 | Then point your web browser to `http://localhost:8080/` 25 | 26 | If the `PUBLISHER_PASSWORD` environment variable is set, then publishers will be required to enter the 27 | password before they can connect. 28 | 29 | ### TLS 30 | 31 | Except when testing against localhost, web browsers require that TLS (`https://`) be in use any time media devices (e.g. microphone) are in use. You should put Babelcast behind a reverse proxy that can provide SSL certificates e.g. [Caddy](https://github.com/caddyserver/caddy). 32 | 33 | See this [Stackoverflow post](https://stackoverflow.com/a/34198101/202311) for more information. 34 | 35 | ## Credit 36 | 37 | Thanks to the excellent [Pion](https://github.com/pion/webrtc) library for making WebRTC so accessible. 38 | -------------------------------------------------------------------------------- /conn.go: -------------------------------------------------------------------------------- 1 | /* 2 | Licensed under the Apache License, Version 2.0 (the "License"); 3 | you may not use this file except in compliance with the License. 4 | You may obtain a copy of the License at 5 | 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | */ 14 | 15 | package main 16 | 17 | import ( 18 | "encoding/json" 19 | "errors" 20 | "fmt" 21 | "io" 22 | "log/slog" 23 | "regexp" 24 | "sync" 25 | 26 | "github.com/gorilla/websocket" 27 | "github.com/pion/webrtc/v4" 28 | ) 29 | 30 | // channel name should NOT match the negation of valid characters 31 | var channelRegexp = regexp.MustCompile("[^a-zA-Z0-9 ]+") 32 | 33 | type Conn struct { 34 | sync.Mutex 35 | peer *WebRTCPeer 36 | wsConn *websocket.Conn 37 | channelName string 38 | infoChan chan string 39 | quitchan chan struct{} 40 | logger *slog.Logger 41 | hasClosed bool 42 | 43 | clientID string 44 | isPublisher bool 45 | } 46 | 47 | func NewConn(ws *websocket.Conn) *Conn { 48 | c := &Conn{} 49 | c.infoChan = make(chan string) 50 | c.quitchan = make(chan struct{}) 51 | c.logger = slog.With("remote_addr", ws.RemoteAddr()) 52 | c.wsConn = ws 53 | 54 | return c 55 | } 56 | 57 | func (c *Conn) setupSessionPublisher(offer webrtc.SessionDescription) error { 58 | 59 | answer, err := c.peer.SetupPublisher(offer, c.rtcStateChangeHandler, c.rtcTrackHandlerPublisher, c.onIceCandidate) 60 | if err != nil { 61 | return err 62 | } 63 | 64 | j, err := json.Marshal(answer.SDP) 65 | if err != nil { 66 | return err 67 | } 68 | err = c.writeMsg(wsMsg{Key: "sd_answer", Value: j}) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | return nil 74 | } 75 | 76 | func (c *Conn) setupSessionSubscriber() error { 77 | 78 | channel := reg.GetChannel(c.channelName) 79 | if channel == nil { 80 | return fmt.Errorf("channel %q not found", c.channelName) 81 | } 82 | 83 | answer, err := c.peer.SetupSubscriber(channel, c.rtcStateChangeHandler, c.onIceCandidate) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | j, err := json.Marshal(answer.SDP) 89 | if err != nil { 90 | return err 91 | } 92 | err = c.writeMsg(wsMsg{Key: "sd_answer", Value: j}) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | return nil 98 | } 99 | 100 | func (c *Conn) connectPublisher(cmd CmdConnect) error { 101 | 102 | if c.peer.pc == nil { 103 | return fmt.Errorf("webrtc session not established") 104 | } 105 | 106 | if cmd.Channel == "" { 107 | return fmt.Errorf("channel cannot be empty") 108 | } 109 | 110 | if channelRegexp.MatchString(cmd.Channel) { 111 | return fmt.Errorf("channel name must contain only alphanumeric characters") 112 | } 113 | 114 | if publisherPassword != "" && cmd.Password != publisherPassword { 115 | return fmt.Errorf("incorrect password") 116 | } 117 | 118 | c.channelName = cmd.Channel 119 | c.logger.Info("setting up publisher for channel", "channel", c.channelName) 120 | 121 | localTrack := <-c.peer.localTrackChan 122 | c.logger.Info("publisher has localTrack") 123 | 124 | if err := reg.AddPublisher(c.channelName, localTrack); err != nil { 125 | return err 126 | } 127 | 128 | return nil 129 | } 130 | 131 | func (c *Conn) Close() { 132 | c.logger.Debug("close called") 133 | c.Lock() 134 | defer c.Unlock() 135 | if c.hasClosed { 136 | return 137 | } 138 | if c.isPublisher { 139 | reg.RemovePublisher(c.channelName) 140 | } else { 141 | reg.RemoveSubscriber(c.channelName, c.clientID) 142 | } 143 | if c.peer.pc != nil { 144 | c.peer.pc.Close() 145 | } 146 | if c.wsConn != nil { 147 | c.wsConn.Close() 148 | } 149 | c.hasClosed = true 150 | } 151 | 152 | func (c *Conn) writeMsg(val interface{}) error { 153 | c.Lock() 154 | defer c.Unlock() 155 | j, err := json.Marshal(val) 156 | if err != nil { 157 | return err 158 | } 159 | c.logger.Debug("write message", "msg", string(j)) 160 | if err = c.wsConn.WriteMessage(websocket.TextMessage, j); err != nil { 161 | return err 162 | } 163 | 164 | return nil 165 | } 166 | 167 | // WebRTC callback function 168 | func (c *Conn) rtcTrackHandlerPublisher(remoteTrack *webrtc.TrackRemote, receiver *webrtc.RTPReceiver) { 169 | 170 | // Create a local track, all our SFU clients will be fed via this track 171 | localTrack, newTrackErr := webrtc.NewTrackLocalStaticRTP(remoteTrack.Codec().RTPCodecCapability, "audio", "babelcast") 172 | if newTrackErr != nil { 173 | panic(newTrackErr) 174 | } 175 | 176 | c.logger.Debug("trackhandler sending localtrack") 177 | c.peer.localTrackChan <- localTrack 178 | c.logger.Debug("trackhandler sent localtrack") 179 | 180 | rtpBuf := make([]byte, 1400) 181 | for { 182 | i, _, readErr := remoteTrack.Read(rtpBuf) 183 | if readErr != nil { 184 | if !errors.Is(readErr, io.EOF) { 185 | c.logger.Error("remoteTrack.Read error", "err", readErr) 186 | } 187 | return 188 | } 189 | 190 | // ErrClosedPipe means we don't have any subscribers, this is ok if no peers have connected yet 191 | _, err := localTrack.Write(rtpBuf[:i]) 192 | if err != nil { 193 | c.logger.Error("localTrack.write error", "err", err) 194 | if !errors.Is(err, io.ErrClosedPipe) { 195 | return 196 | } 197 | } 198 | } 199 | } 200 | 201 | // WebRTC callback function 202 | func (c *Conn) rtcStateChangeHandler(connectionState webrtc.ICEConnectionState) { 203 | switch connectionState { 204 | case webrtc.ICEConnectionStateConnected: 205 | c.logger.Info("ice connected") 206 | c.logger.Debug("remote SDP", "sdp", c.peer.pc.RemoteDescription().SDP) 207 | c.logger.Debug("local SDP", "sdp", c.peer.pc.LocalDescription().SDP) 208 | c.infoChan <- "ice connected" 209 | 210 | case webrtc.ICEConnectionStateDisconnected: 211 | c.logger.Info("ice disconnected") 212 | c.infoChan <- "ice disconnected" 213 | } 214 | } 215 | 216 | // WebRTC callback function 217 | func (c *Conn) onIceCandidate(candidate *webrtc.ICECandidate) { 218 | if candidate == nil { 219 | return 220 | } 221 | 222 | j, err := json.Marshal(candidate.ToJSON()) 223 | if err != nil { 224 | c.logger.Error("marshal error", "err", err.Error()) 225 | return 226 | } 227 | 228 | c.logger.Debug("ICE candidate", "candidate", j) 229 | 230 | m := wsMsg{Key: "ice_candidate", Value: j} 231 | err = c.writeMsg(m) 232 | if err != nil { 233 | c.logger.Error("writemsg error", "err", err.Error()) 234 | return 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /embed.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | "io/fs" 6 | "log" 7 | ) 8 | 9 | // embedContent holds our static web server content. 10 | // embedContentHtml moves the filesystem root to html/ 11 | // 12 | //go:embed html 13 | var embedContent embed.FS 14 | var embedContentHtml fs.FS 15 | 16 | func init() { 17 | var err error 18 | embedContentHtml, err = fs.Sub(embedContent, "html") 19 | if err != nil { 20 | log.Fatal(err) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/porjo/babelcast 2 | 3 | go 1.22 4 | toolchain go1.24.1 5 | 6 | require ( 7 | github.com/google/uuid v1.6.0 8 | github.com/gorilla/websocket v1.5.3 9 | github.com/pion/webrtc/v4 v4.0.0 10 | ) 11 | 12 | require ( 13 | github.com/pion/datachannel v1.5.9 // indirect 14 | github.com/pion/dtls/v3 v3.0.3 // indirect 15 | github.com/pion/ice/v4 v4.0.2 // indirect 16 | github.com/pion/interceptor v0.1.37 // indirect 17 | github.com/pion/logging v0.2.2 // indirect 18 | github.com/pion/mdns/v2 v2.0.7 // indirect 19 | github.com/pion/randutil v0.1.0 // indirect 20 | github.com/pion/rtcp v1.2.14 // indirect 21 | github.com/pion/rtp v1.8.9 // indirect 22 | github.com/pion/sctp v1.8.33 // indirect 23 | github.com/pion/sdp/v3 v3.0.9 // indirect 24 | github.com/pion/srtp/v3 v3.0.4 // indirect 25 | github.com/pion/stun/v3 v3.0.0 // indirect 26 | github.com/pion/transport/v3 v3.0.7 // indirect 27 | github.com/pion/turn/v4 v4.0.0 // indirect 28 | github.com/wlynxg/anet v0.0.5 // indirect 29 | golang.org/x/crypto v0.36.0 // indirect 30 | golang.org/x/net v0.38.0 // indirect 31 | golang.org/x/sys v0.31.0 // indirect 32 | ) 33 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 5 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 6 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 7 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 8 | github.com/pion/datachannel v1.5.9 h1:LpIWAOYPyDrXtU+BW7X0Yt/vGtYxtXQ8ql7dFfYUVZA= 9 | github.com/pion/datachannel v1.5.9/go.mod h1:kDUuk4CU4Uxp82NH4LQZbISULkX/HtzKa4P7ldf9izE= 10 | github.com/pion/dtls/v3 v3.0.3 h1:j5ajZbQwff7Z8k3pE3S+rQ4STvKvXUdKsi/07ka+OWM= 11 | github.com/pion/dtls/v3 v3.0.3/go.mod h1:weOTUyIV4z0bQaVzKe8kpaP17+us3yAuiQsEAG1STMU= 12 | github.com/pion/ice/v4 v4.0.2 h1:1JhBRX8iQLi0+TfcavTjPjI6GO41MFn4CeTBX+Y9h5s= 13 | github.com/pion/ice/v4 v4.0.2/go.mod h1:DCdqyzgtsDNYN6/3U8044j3U7qsJ9KFJC92VnOWHvXg= 14 | github.com/pion/interceptor v0.1.37 h1:aRA8Zpab/wE7/c0O3fh1PqY0AJI3fCSEM5lRWJVorwI= 15 | github.com/pion/interceptor v0.1.37/go.mod h1:JzxbJ4umVTlZAf+/utHzNesY8tmRkM2lVmkS82TTj8Y= 16 | github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY= 17 | github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms= 18 | github.com/pion/mdns/v2 v2.0.7 h1:c9kM8ewCgjslaAmicYMFQIde2H9/lrZpjBkN8VwoVtM= 19 | github.com/pion/mdns/v2 v2.0.7/go.mod h1:vAdSYNAT0Jy3Ru0zl2YiW3Rm/fJCwIeM0nToenfOJKA= 20 | github.com/pion/randutil v0.1.0 h1:CFG1UdESneORglEsnimhUjf33Rwjubwj6xfiOXBa3mA= 21 | github.com/pion/randutil v0.1.0/go.mod h1:XcJrSMMbbMRhASFVOlj/5hQial/Y8oH/HVo7TBZq+j8= 22 | github.com/pion/rtcp v1.2.14 h1:KCkGV3vJ+4DAJmvP0vaQShsb0xkRfWkO540Gy102KyE= 23 | github.com/pion/rtcp v1.2.14/go.mod h1:sn6qjxvnwyAkkPzPULIbVqSKI5Dv54Rv7VG0kNxh9L4= 24 | github.com/pion/rtp v1.8.9 h1:E2HX740TZKaqdcPmf4pw6ZZuG8u5RlMMt+l3dxeu6Wk= 25 | github.com/pion/rtp v1.8.9/go.mod h1:pBGHaFt/yW7bf1jjWAoUjpSNoDnw98KTMg+jWWvziqU= 26 | github.com/pion/sctp v1.8.33 h1:dSE4wX6uTJBcNm8+YlMg7lw1wqyKHggsP5uKbdj+NZw= 27 | github.com/pion/sctp v1.8.33/go.mod h1:beTnqSzewI53KWoG3nqB282oDMGrhNxBdb+JZnkCwRM= 28 | github.com/pion/sdp/v3 v3.0.9 h1:pX++dCHoHUwq43kuwf3PyJfHlwIj4hXA7Vrifiq0IJY= 29 | github.com/pion/sdp/v3 v3.0.9/go.mod h1:B5xmvENq5IXJimIO4zfp6LAe1fD9N+kFv+V/1lOdz8M= 30 | github.com/pion/srtp/v3 v3.0.4 h1:2Z6vDVxzrX3UHEgrUyIGM4rRouoC7v+NiF1IHtp9B5M= 31 | github.com/pion/srtp/v3 v3.0.4/go.mod h1:1Jx3FwDoxpRaTh1oRV8A/6G1BnFL+QI82eK4ms8EEJQ= 32 | github.com/pion/stun/v3 v3.0.0 h1:4h1gwhWLWuZWOJIJR9s2ferRO+W3zA/b6ijOI6mKzUw= 33 | github.com/pion/stun/v3 v3.0.0/go.mod h1:HvCN8txt8mwi4FBvS3EmDghW6aQJ24T+y+1TKjB5jyU= 34 | github.com/pion/transport/v3 v3.0.7 h1:iRbMH05BzSNwhILHoBoAPxoB9xQgOaJk+591KC9P1o0= 35 | github.com/pion/transport/v3 v3.0.7/go.mod h1:YleKiTZ4vqNxVwh77Z0zytYi7rXHl7j6uPLGhhz9rwo= 36 | github.com/pion/turn/v4 v4.0.0 h1:qxplo3Rxa9Yg1xXDxxH8xaqcyGUtbHYw4QSCvmFWvhM= 37 | github.com/pion/turn/v4 v4.0.0/go.mod h1:MuPDkm15nYSklKpN8vWJ9W2M0PlyQZqYt1McGuxG7mA= 38 | github.com/pion/webrtc/v4 v4.0.0 h1:x8ec7uJQPP3D1iI8ojPAiTOylPI7Fa7QgqZrhpLyqZ8= 39 | github.com/pion/webrtc/v4 v4.0.0/go.mod h1:SfNn8CcFxR6OUVjLXVslAQ3a3994JhyE3Hw1jAuqEto= 40 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 41 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 42 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 43 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 44 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 45 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 46 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 47 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 48 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 49 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 50 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 51 | github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= 52 | github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= 53 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 54 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 55 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 56 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 57 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 58 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 59 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 60 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 61 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 62 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 63 | -------------------------------------------------------------------------------- /html/css/common.css: -------------------------------------------------------------------------------- 1 | /* https://fonts.googleapis.com/css?family=Pacifico */ 2 | @font-face { 3 | font-family: 'Pacifico'; 4 | font-style: normal; 5 | font-weight: 400; 6 | src: local('Pacifico Regular'), local('Pacifico-Regular'), url(../fonts/Pacifico.woff2) format('woff2'); 7 | unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; 8 | } 9 | 10 | html { 11 | margin: 0; 12 | padding: 0; 13 | } 14 | 15 | body { 16 | line-height: 1; 17 | font-family: sans-serif; 18 | padding: 5px; 19 | margin: 0; 20 | background-image: url('../img/whitenoise.png'); 21 | background-repeat: repeat; 22 | } 23 | 24 | input, textarea { 25 | width: 100%; 26 | } 27 | 28 | #input-form { 29 | padding: 20px; 30 | } 31 | 32 | #input-form table { 33 | margin: 0 auto; 34 | } 35 | 36 | #input-form th { 37 | text-align: right; 38 | vertical-align: middle; 39 | padding-right: 10px; 40 | padding-bottom: 10px; 41 | } 42 | #input-form td { 43 | text-align: left; 44 | padding-bottom: 10px; 45 | } 46 | 47 | .container { 48 | max-width: 800px; 49 | margin: 0 auto; 50 | text-align: center; 51 | padding: 10px; 52 | border-radius: 5px; 53 | } 54 | 55 | #footer { 56 | margin-top: 40px; 57 | font-size: 0.8em; 58 | border-top: 1px #ddd solid; 59 | padding: 10px; 60 | } 61 | 62 | #output { 63 | margin: 10px 0; 64 | } 65 | 66 | .hidden { 67 | display: none; 68 | } 69 | 70 | label { 71 | font-size: 0.8em; 72 | margin: 5px 0; 73 | display: inline-block; 74 | } 75 | 76 | #errors { 77 | border: 2px solid #8c4d4d; 78 | background-color: #fff1f1; 79 | min-height: 50px; 80 | border-radius: 2px; 81 | font-size: 1.2em; 82 | font-weight: bold; 83 | text-align: left; 84 | } 85 | 86 | .error { 87 | margin: 10px; 88 | } 89 | 90 | #messages { 91 | border: 2px solid #fff; 92 | text-align: left; 93 | padding: 10px; 94 | } 95 | 96 | #messages summary { 97 | cursor: pointer; 98 | } 99 | 100 | #message-log { 101 | font-family: monospace; 102 | overflow: auto; 103 | padding: 10px; 104 | white-space: pre-wrap; 105 | height: 100px; 106 | } 107 | 108 | .title { 109 | font-family: 'Pacifico', cursive; 110 | margin: 10px 0; 111 | padding-bottom: 20px; 112 | border-bottom: 1px #ddd solid; 113 | } 114 | 115 | .logo { 116 | font-size: 2em; 117 | margin: 0 10px; 118 | } 119 | .subtitle { 120 | font-size: 1.2em; 121 | color: blue; 122 | margin: 0 10px; 123 | } 124 | 125 | #connect-button { 126 | width: 200px; 127 | height: 50px; 128 | margin: 20px 10px; 129 | } 130 | 131 | #github-logo { 132 | font-size: 2em; 133 | color: #555; 134 | } 135 | 136 | #channels ul { 137 | padding: 0; 138 | } 139 | 140 | .channel { 141 | padding: 10px; 142 | border: 1px solid #bbb; 143 | border-radius: 5px; 144 | max-width: 400px; 145 | cursor: pointer; 146 | list-style-type: none; 147 | margin: 5px auto; 148 | background-color: #fff; 149 | } 150 | 151 | #microphone-meter { 152 | margin: 20px 0; 153 | } 154 | 155 | 156 | #microphone { 157 | font-size: 2em; 158 | cursor: pointer; 159 | color: #555; 160 | } 161 | 162 | @keyframes colorPulse { 163 | 0% { 164 | color: #660000; 165 | } 166 | 50% { 167 | color: #ff0000; 168 | } 169 | 100% { 170 | color: #660000; 171 | } 172 | } 173 | 174 | #microphone.on { 175 | color: red; 176 | animation-name: colorPulse; 177 | animation-duration: 2s; 178 | animation-iteration-count: infinite; 179 | } 180 | 181 | #reload { 182 | font-size: 1em; 183 | width: 200px; 184 | height: 50px; 185 | } 186 | 187 | #reload .symbol { 188 | margin: 0 5px; 189 | font-size: 1.2em; 190 | } 191 | 192 | /* Button */ 193 | 194 | /* This button was generated using CSSButtonGenerator.com */ 195 | .button { 196 | margin: 10px; 197 | box-shadow: inset 0px 1px 0px 0px #ffffff; 198 | background-color: #f9f9f9; 199 | border: 1px solid #bdbdbd; 200 | border-radius: 5px; 201 | color: #666666; 202 | font-size: 1em; 203 | font-weight: bold; 204 | text-align: center; 205 | text-shadow: 1px 1px 0px #ffffff; 206 | } 207 | .button:hover { 208 | background-color:#e9e9e9; 209 | cursor: pointer; 210 | } 211 | .button:active { 212 | position:relative; 213 | top:1px; 214 | } 215 | 216 | 217 | /* CSS Spinner 218 | * http://tobiasahlin.com/spinkit/ 219 | */ 220 | 221 | #spinner { 222 | margin: 20px; 223 | } 224 | 225 | .sk-fading-circle { 226 | margin: 0 auto; 227 | width: 40px; 228 | height: 40px; 229 | position: relative; 230 | } 231 | 232 | .sk-fading-circle .sk-circle { 233 | width: 100%; 234 | height: 100%; 235 | position: absolute; 236 | left: 0; 237 | top: 0; 238 | } 239 | 240 | .sk-fading-circle .sk-circle:before { 241 | content: ''; 242 | display: block; 243 | margin: 0 auto; 244 | width: 15%; 245 | height: 15%; 246 | background-color: #333; 247 | border-radius: 100%; 248 | -webkit-animation: sk-circleFadeDelay 1.2s infinite ease-in-out both; 249 | animation: sk-circleFadeDelay 1.2s infinite ease-in-out both; 250 | } 251 | .sk-fading-circle .sk-circle2 { 252 | -webkit-transform: rotate(30deg); 253 | -ms-transform: rotate(30deg); 254 | transform: rotate(30deg); 255 | } 256 | .sk-fading-circle .sk-circle3 { 257 | -webkit-transform: rotate(60deg); 258 | -ms-transform: rotate(60deg); 259 | transform: rotate(60deg); 260 | } 261 | .sk-fading-circle .sk-circle4 { 262 | -webkit-transform: rotate(90deg); 263 | -ms-transform: rotate(90deg); 264 | transform: rotate(90deg); 265 | } 266 | .sk-fading-circle .sk-circle5 { 267 | -webkit-transform: rotate(120deg); 268 | -ms-transform: rotate(120deg); 269 | transform: rotate(120deg); 270 | } 271 | .sk-fading-circle .sk-circle6 { 272 | -webkit-transform: rotate(150deg); 273 | -ms-transform: rotate(150deg); 274 | transform: rotate(150deg); 275 | } 276 | .sk-fading-circle .sk-circle7 { 277 | -webkit-transform: rotate(180deg); 278 | -ms-transform: rotate(180deg); 279 | transform: rotate(180deg); 280 | } 281 | .sk-fading-circle .sk-circle8 { 282 | -webkit-transform: rotate(210deg); 283 | -ms-transform: rotate(210deg); 284 | transform: rotate(210deg); 285 | } 286 | .sk-fading-circle .sk-circle9 { 287 | -webkit-transform: rotate(240deg); 288 | -ms-transform: rotate(240deg); 289 | transform: rotate(240deg); 290 | } 291 | .sk-fading-circle .sk-circle10 { 292 | -webkit-transform: rotate(270deg); 293 | -ms-transform: rotate(270deg); 294 | transform: rotate(270deg); 295 | } 296 | .sk-fading-circle .sk-circle11 { 297 | -webkit-transform: rotate(300deg); 298 | -ms-transform: rotate(300deg); 299 | transform: rotate(300deg); 300 | } 301 | .sk-fading-circle .sk-circle12 { 302 | -webkit-transform: rotate(330deg); 303 | -ms-transform: rotate(330deg); 304 | transform: rotate(330deg); 305 | } 306 | .sk-fading-circle .sk-circle2:before { 307 | -webkit-animation-delay: -1.1s; 308 | animation-delay: -1.1s; 309 | } 310 | .sk-fading-circle .sk-circle3:before { 311 | -webkit-animation-delay: -1s; 312 | animation-delay: -1s; 313 | } 314 | .sk-fading-circle .sk-circle4:before { 315 | -webkit-animation-delay: -0.9s; 316 | animation-delay: -0.9s; 317 | } 318 | .sk-fading-circle .sk-circle5:before { 319 | -webkit-animation-delay: -0.8s; 320 | animation-delay: -0.8s; 321 | } 322 | .sk-fading-circle .sk-circle6:before { 323 | -webkit-animation-delay: -0.7s; 324 | animation-delay: -0.7s; 325 | } 326 | .sk-fading-circle .sk-circle7:before { 327 | -webkit-animation-delay: -0.6s; 328 | animation-delay: -0.6s; 329 | } 330 | .sk-fading-circle .sk-circle8:before { 331 | -webkit-animation-delay: -0.5s; 332 | animation-delay: -0.5s; 333 | } 334 | .sk-fading-circle .sk-circle9:before { 335 | -webkit-animation-delay: -0.4s; 336 | animation-delay: -0.4s; 337 | } 338 | .sk-fading-circle .sk-circle10:before { 339 | -webkit-animation-delay: -0.3s; 340 | animation-delay: -0.3s; 341 | } 342 | .sk-fading-circle .sk-circle11:before { 343 | -webkit-animation-delay: -0.2s; 344 | animation-delay: -0.2s; 345 | } 346 | .sk-fading-circle .sk-circle12:before { 347 | -webkit-animation-delay: -0.1s; 348 | animation-delay: -0.1s; 349 | } 350 | 351 | @-webkit-keyframes sk-circleFadeDelay { 352 | 0%, 39%, 100% { opacity: 0; } 353 | 40% { opacity: 1; } 354 | } 355 | 356 | @keyframes sk-circleFadeDelay { 357 | 0%, 39%, 100% { opacity: 0; } 358 | 40% { opacity: 1; } 359 | } 360 | -------------------------------------------------------------------------------- /html/fontello/LICENSE.txt: -------------------------------------------------------------------------------- 1 | Font license info 2 | 3 | 4 | ## Font Awesome 5 | 6 | Copyright (C) 2016 by Dave Gandy 7 | 8 | Author: Dave Gandy 9 | License: SIL () 10 | Homepage: http://fortawesome.github.com/Font-Awesome/ 11 | 12 | 13 | -------------------------------------------------------------------------------- /html/fontello/README.txt: -------------------------------------------------------------------------------- 1 | This webfont is generated by http://fontello.com open source project. 2 | 3 | 4 | ================================================================================ 5 | Please, note, that you should obey original font licenses, used to make this 6 | webfont pack. Details available in LICENSE.txt file. 7 | 8 | - Usually, it's enough to publish content of LICENSE.txt file somewhere on your 9 | site in "About" section. 10 | 11 | - If your project is open-source, usually, it will be ok to make LICENSE.txt 12 | file publicly available in your repository. 13 | 14 | - Fonts, used in Fontello, don't require a clickable link on your site. 15 | But any kind of additional authors crediting is welcome. 16 | ================================================================================ 17 | 18 | 19 | Comments on archive content 20 | --------------------------- 21 | 22 | - /font/* - fonts in different formats 23 | 24 | - /css/* - different kinds of css, for all situations. Should be ok with 25 | twitter bootstrap. Also, you can skip style and assign icon classes 26 | directly to text elements, if you don't mind about IE7. 27 | 28 | - demo.html - demo file, to show your webfont content 29 | 30 | - LICENSE.txt - license info about source fonts, used to build your one. 31 | 32 | - config.json - keeps your settings. You can import it back into fontello 33 | anytime, to continue your work 34 | 35 | 36 | Why so many CSS files ? 37 | ----------------------- 38 | 39 | Because we like to fit all your needs :) 40 | 41 | - basic file, .css - is usually enough, it contains @font-face 42 | and character code definitions 43 | 44 | - *-ie7.css - if you need IE7 support, but still don't wish to put char codes 45 | directly into html 46 | 47 | - *-codes.css and *-ie7-codes.css - if you like to use your own @font-face 48 | rules, but still wish to benefit from css generation. That can be very 49 | convenient for automated asset build systems. When you need to update font - 50 | no need to manually edit files, just override old version with archive 51 | content. See fontello source code for examples. 52 | 53 | - *-embedded.css - basic css file, but with embedded WOFF font, to avoid 54 | CORS issues in Firefox and IE9+, when fonts are hosted on the separate domain. 55 | We strongly recommend to resolve this issue by `Access-Control-Allow-Origin` 56 | server headers. But if you ok with dirty hack - this file is for you. Note, 57 | that data url moved to separate @font-face to avoid problems with 2 | 3 | 4 | 278 | 279 | 291 | 292 | 293 |
294 |

fontello font demo

295 | 298 |
299 |
300 |
301 |
icon-arrows-cw0xe800
302 |
icon-down-open0xe801
303 |
icon-left-open0xe802
304 |
icon-up-open0xe803
305 |
306 |
307 |
icon-right-open0xe804
308 |
icon-github-circled0xf09b
309 |
icon-mic0xf130
310 |
icon-mute0xf131
311 |
312 |
313 | 314 | 315 | -------------------------------------------------------------------------------- /html/fontello/font/fontello.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/porjo/babelcast/d6ee400447ef6f954dff586ba9468ee6e3a90e60/html/fontello/font/fontello.eot -------------------------------------------------------------------------------- /html/fontello/font/fontello.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Copyright (C) 2018 by original authors @ fontello.com 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /html/fontello/font/fontello.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/porjo/babelcast/d6ee400447ef6f954dff586ba9468ee6e3a90e60/html/fontello/font/fontello.ttf -------------------------------------------------------------------------------- /html/fontello/font/fontello.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/porjo/babelcast/d6ee400447ef6f954dff586ba9468ee6e3a90e60/html/fontello/font/fontello.woff -------------------------------------------------------------------------------- /html/fontello/font/fontello.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/porjo/babelcast/d6ee400447ef6f954dff586ba9468ee6e3a90e60/html/fontello/font/fontello.woff2 -------------------------------------------------------------------------------- /html/fonts/Pacifico.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/porjo/babelcast/d6ee400447ef6f954dff586ba9468ee6e3a90e60/html/fonts/Pacifico.woff2 -------------------------------------------------------------------------------- /html/img/whitenoise.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/porjo/babelcast/d6ee400447ef6f954dff586ba9468ee6e3a90e60/html/img/whitenoise.png -------------------------------------------------------------------------------- /html/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Babelcast 8 | 9 | 10 | 11 | 12 | 13 |
14 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /html/js/common.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Check for WebRTC support 4 | var isWebRTCSupported = navigator.getUserMedia || 5 | navigator.webkitGetUserMedia || 6 | navigator.mozGetUserMedia || 7 | navigator.msGetUserMedia || 8 | window.RTCPeerConnection; 9 | 10 | if(!isWebRTCSupported) { 11 | document.getElementById("not-supported").classList.remove('hidden'); 12 | document.getElementById("supported").classList.add('hidden'); 13 | throw new Error("WebRTC not supported"); 14 | } 15 | 16 | var loc = window.location, ws_uri; 17 | if (loc.protocol === "https:") { 18 | ws_uri = "wss:"; 19 | } else { 20 | ws_uri = "ws:"; 21 | } 22 | ws_uri += "//" + loc.host; 23 | var path = loc.pathname.substring(0, loc.pathname.lastIndexOf("/")); 24 | ws_uri += path + "/ws"; 25 | 26 | var ws = new WebSocket(ws_uri); 27 | 28 | // array of funcs to call when WS is ready 29 | var onWSReady = []; 30 | 31 | var error, msg; 32 | 33 | var debug = (...m) => { 34 | console.log(...m) 35 | msg(m.join(' ')) 36 | } 37 | 38 | error = (...msgs) => { 39 | console.log(...msgs) 40 | var errorEle = document.getElementById('errors'); 41 | msgs.forEach(m => { 42 | let c = document.createElement("div"); 43 | c.classList.add('error'); 44 | c.innerText = m; 45 | errorEle.appendChild(c); 46 | }) 47 | errorEle.classList.remove('hidden'); 48 | } 49 | msg = m => { 50 | let d = new Date(Date.now()).toISOString(); 51 | let msgEle = document.getElementById('message-log'); 52 | msgEle.prepend(d + ' ' + m + '\n'); 53 | } 54 | 55 | var wsSend = m => { 56 | let j = JSON.stringify(m); 57 | if (ws.readyState === WebSocket.OPEN) { 58 | ws.send(j) 59 | } else { 60 | debug("ws: send not ready, skipping...", j); 61 | } 62 | } 63 | 64 | ws.onopen = function() { 65 | debug("ws: connection open"); 66 | onWSReady.forEach(f => { 67 | f() 68 | }) 69 | }; 70 | 71 | // 72 | // -------- WebRTC ------------ 73 | // 74 | 75 | var pc = new RTCPeerConnection({ 76 | 77 | iceServers: [ 78 | { 79 | urls: 'stun:stun.l.google.com:19302' 80 | } 81 | ] 82 | }) 83 | 84 | pc.oniceconnectionstatechange = e => { 85 | debug("ICE state:", pc.iceConnectionState) 86 | switch (pc.iceConnectionState) { 87 | case "new": 88 | case "checking": 89 | case "failed": 90 | case "disconnected": 91 | case "closed": 92 | case "completed": 93 | case "connected": 94 | document.getElementById('spinner').classList.add('hidden'); 95 | let cb = document.getElementById('connect-button'); 96 | if(cb) { cb.classList.remove('hidden') }; 97 | break; 98 | default: 99 | debug("webrtc: ice state unknown", e); 100 | break; 101 | } 102 | } 103 | 104 | var startSession = sd => { 105 | document.getElementById('spinner').classList.remove('hidden'); 106 | try { 107 | debug("webrtc: set remote description") 108 | pc.setRemoteDescription(new RTCSessionDescription({type: 'answer', sdp: sd})); 109 | } catch (e) { 110 | alert(e); 111 | } 112 | } 113 | 114 | pc.onicecandidate = e => { 115 | if (e.candidate && e.candidate.candidate !== "") { 116 | let val = {Key: 'ice_candidate', Value: e.candidate}; 117 | wsSend(val); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /html/js/publisher.js: -------------------------------------------------------------------------------- 1 | 2 | var audioTrack; 3 | 4 | document.getElementById('reload').addEventListener('click', function() { 5 | window.location.reload(false); 6 | }); 7 | 8 | document.getElementById('microphone').addEventListener('click', function() { 9 | toggleMic() 10 | }); 11 | 12 | var toggleMic = function() { 13 | let micEle = document.getElementById('microphone'); 14 | micEle.classList.toggle('icon-mute'); 15 | micEle.classList.toggle('icon-mic'); 16 | micEle.classList.toggle('on'); 17 | audioTrack.enabled = micEle.classList.contains('icon-mic'); 18 | } 19 | 20 | document.getElementById('input-form').addEventListener('submit', function(e) { 21 | e.preventDefault(); 22 | 23 | document.getElementById('output').classList.remove('hidden'); 24 | document.getElementById('input-form').classList.add('hidden'); 25 | let params = {}; 26 | 27 | params.Channel = document.getElementById('channel').value; 28 | params.Password = document.getElementById('password').value; 29 | let val = {Key: 'connect_publisher', Value: params}; 30 | wsSend(val); 31 | }); 32 | 33 | ws.onmessage = function (e) { 34 | let wsMsg = JSON.parse(e.data); 35 | if( 'Key' in wsMsg ) { 36 | switch (wsMsg.Key) { 37 | case 'info': 38 | debug("server info: " + wsMsg.Value); 39 | break; 40 | case 'error': 41 | error("server error", wsMsg.Value); 42 | document.getElementById('output').classList.add('hidden'); 43 | document.getElementById('input-form').classList.add('hidden'); 44 | break; 45 | case 'sd_answer': 46 | startSession(wsMsg.Value); 47 | break; 48 | case 'ice_candidate': 49 | pc.addIceCandidate(wsMsg.Value) 50 | break; 51 | case 'password_required': 52 | document.getElementById('password-form').classList.remove('hidden'); 53 | break; 54 | } 55 | } 56 | }; 57 | 58 | ws.onclose = function() { 59 | error("websocket connection closed"); 60 | debug("ws: connection closed"); 61 | if (audioTrack) { 62 | audioTrack.stop() 63 | } 64 | pc.close() 65 | }; 66 | 67 | // 68 | // -------- WebRTC ------------ 69 | // 70 | 71 | const constraints = window.constraints = { 72 | audio: true, 73 | video: false 74 | }; 75 | 76 | try { 77 | window.AudioContext = window.AudioContext || window.webkitAudioContext; 78 | window.audioContext = new AudioContext(); 79 | } catch (e) { 80 | alert('Web Audio API not supported.'); 81 | } 82 | 83 | const signalMeter = document.querySelector('#microphone-meter meter'); 84 | 85 | navigator.mediaDevices.getUserMedia(constraints).then(stream => { 86 | audioTrack = stream.getAudioTracks()[0]; 87 | stream.getTracks().forEach(track => { 88 | debug("webrtc: add track") 89 | pc.addTrack(track, stream) 90 | }) 91 | // mute until we're ready 92 | audioTrack.enabled = false; 93 | 94 | const soundMeter = new SoundMeter(window.audioContext); 95 | soundMeter.connectToSource(stream, function(e) { 96 | if (e) { 97 | alert(e); 98 | return; 99 | } 100 | 101 | // make the meter value relative to a sliding max 102 | let max = 0.0; 103 | setInterval(() => { 104 | let val = soundMeter.instant.toFixed(2); 105 | if( val > max ) { max = val } 106 | if( max > 0) { val = (val / max) } 107 | signalMeter.value = val; 108 | }, 50); 109 | }); 110 | 111 | let f = () => { 112 | debug("webrtc: create offer") 113 | pc.createOffer().then(d => { 114 | debug("webrtc: set local description") 115 | pc.setLocalDescription(d); 116 | let val = { Key: 'session_publisher', Value: d }; 117 | wsSend(val); 118 | }).catch(debug) 119 | } 120 | // create offer if WS is ready, otherwise queue 121 | ws.readyState == WebSocket.OPEN ? f() : onWSReady.push(f) 122 | 123 | }).catch(debug) 124 | 125 | -------------------------------------------------------------------------------- /html/js/soundmeter.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2015 The WebRTC project authors. All Rights Reserved. 3 | * 4 | * Use of this source code is governed by a BSD-style license 5 | * that can be found in the LICENSE file in the root of the source 6 | * tree. 7 | */ 8 | 9 | 'use strict'; 10 | 11 | // Meter class that generates a number correlated to audio volume. 12 | // The meter class itself displays nothing, but it makes the 13 | // instantaneous and time-decaying volumes available for inspection. 14 | // It also reports on the fraction of samples that were at or near 15 | // the top of the measurement range. 16 | function SoundMeter(context) { 17 | this.context = context; 18 | this.instant = 0.0; 19 | this.slow = 0.0; 20 | this.clip = 0.0; 21 | this.script = context.createScriptProcessor(2048, 1, 1); 22 | const that = this; 23 | this.script.onaudioprocess = function(event) { 24 | const input = event.inputBuffer.getChannelData(0); 25 | let i; 26 | let sum = 0.0; 27 | let clipcount = 0; 28 | for (i = 0; i < input.length; ++i) { 29 | sum += input[i] * input[i]; 30 | if (Math.abs(input[i]) > 0.99) { 31 | clipcount += 1; 32 | } 33 | } 34 | that.instant = Math.sqrt(sum / input.length); 35 | that.slow = 0.95 * that.slow + 0.05 * that.instant; 36 | that.clip = clipcount / input.length; 37 | }; 38 | } 39 | 40 | SoundMeter.prototype.connectToSource = function(stream, callback) { 41 | try { 42 | this.mic = this.context.createMediaStreamSource(stream); 43 | this.mic.connect(this.script); 44 | // necessary to make sample run, but should not be. 45 | this.script.connect(this.context.destination); 46 | if (typeof callback !== 'undefined') { 47 | callback(null); 48 | } 49 | } catch (e) { 50 | console.error(e); 51 | if (typeof callback !== 'undefined') { 52 | callback(e); 53 | } 54 | } 55 | }; 56 | 57 | SoundMeter.prototype.stop = function() { 58 | this.mic.disconnect(); 59 | this.script.disconnect(); 60 | }; 61 | 62 | -------------------------------------------------------------------------------- /html/js/subscriber.js: -------------------------------------------------------------------------------- 1 | 2 | var getChannelsId = setInterval(function() { 3 | debug("get_channels"); 4 | let val = {Key: 'get_channels'} 5 | wsSend(val); 6 | }, 1000); 7 | 8 | document.getElementById('reload').addEventListener('click', function() { 9 | window.location.reload(false); 10 | }); 11 | 12 | function channelClick(e) { 13 | document.getElementById('output').classList.remove('hidden'); 14 | document.getElementById('channels').classList.add('hidden'); 15 | let params = {}; 16 | params.Channel = e.target.innerText; 17 | let val = {Key: 'connect_subscriber', Value: params}; 18 | wsSend(val); 19 | }; 20 | 21 | function updateChannels(channels) { 22 | let channelsEle = document.querySelector('#channels ul'); 23 | channelsEle.innerHTML = ''; 24 | if(channels.length > 0) { 25 | clearInterval(getChannelsId); 26 | document.getElementById('nochannels').classList.add('hidden'); 27 | channels.forEach((e) => { 28 | let c = document.createElement("li"); 29 | c.classList.add('channel'); 30 | c.innerText = e; 31 | c.addEventListener("click", channelClick); 32 | channelsEle.appendChild(c); 33 | }); 34 | } 35 | }; 36 | 37 | ws.onmessage = function (e) { 38 | let wsMsg = JSON.parse(e.data); 39 | if( 'Key' in wsMsg ) { 40 | switch (wsMsg.Key) { 41 | case 'info': 42 | debug("server info: " + wsMsg.Value); 43 | break; 44 | case 'error': 45 | error("server error:", wsMsg.Value); 46 | document.getElementById('output').classList.add('hidden'); 47 | document.getElementById('channels').classList.add('hidden'); 48 | break; 49 | case 'sd_answer': 50 | startSession(wsMsg.Value); 51 | break; 52 | case 'channels': 53 | updateChannels(wsMsg.Value); 54 | break; 55 | case "session_received": // wait for the message that session_subscriber was received 56 | document.getElementById("channels").classList.remove("hidden"); 57 | document.getElementById('reload').classList.remove('hidden'); 58 | document.getElementById("spinner").classList.add("hidden"); 59 | break; 60 | case 'ice_candidate': 61 | pc.addIceCandidate(wsMsg.Value) 62 | break; 63 | case 'channel_closed': 64 | error("channel '" + wsMsg.Value + "' closed by server") 65 | break; 66 | } 67 | } 68 | }; 69 | 70 | ws.onclose = function() { 71 | error("websocket connection closed"); 72 | pc.close() 73 | document.getElementById('media').classList.add('hidden') 74 | clearInterval(getChannelsId); 75 | }; 76 | 77 | // 78 | // -------- WebRTC ------------ 79 | // 80 | 81 | pc.ontrack = function (event) { 82 | debug("webrtc: ontrack"); 83 | let el = document.createElement(event.track.kind); 84 | el.srcObject = event.streams[0]; 85 | el.autoplay = true; 86 | el.controls = true; 87 | 88 | document.getElementById('media').appendChild(el); 89 | } 90 | 91 | pc.addTransceiver('audio') 92 | 93 | let f = () => { 94 | debug("webrtc: create offer") 95 | pc.createOffer().then(d => { 96 | debug("webrtc: set local description") 97 | pc.setLocalDescription(d); 98 | let val = { Key: 'session_subscriber', Value: d }; 99 | wsSend(val); 100 | }).catch(debug) 101 | } 102 | // create offer if WS is ready, otherwise queue 103 | ws.readyState == WebSocket.OPEN ? f() : onWSReady.push(f) 104 | 105 | // ---------------------------------------------------------------- 106 | -------------------------------------------------------------------------------- /html/publisher.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Babelcast Publisher 8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 |
Publisher
16 | 19 |
20 |
21 |

Create a channel by entering a channel name in the field below and clicking 'Connect'.

22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
Channel:
32 | 33 |
34 |

Negotiating connection...

35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | 52 | 60 | 61 |
62 | Messages 63 |
64 |
65 | 68 |
69 |
70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /html/subscriber.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Babelcast Subscriber 8 | 9 | 10 | 11 | 12 | 13 |
14 |
Subscriber
15 | 18 |
19 | 25 |
26 |

Negotiating connection...

27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | 43 | 46 | 47 | 48 |
49 | Messages 50 |
51 |
52 | 55 |
56 |
57 | 58 | 59 | 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | /* 2 | Licensed under the Apache License, Version 2.0 (the "License"); 3 | you may not use this file except in compliance with the License. 4 | You may obtain a copy of the License at 5 | 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | */ 14 | 15 | package main 16 | 17 | import ( 18 | "encoding/json" 19 | "fmt" 20 | "log" 21 | "net/http" 22 | "time" 23 | 24 | "github.com/gorilla/websocket" 25 | "github.com/pion/webrtc/v4" 26 | ) 27 | 28 | const PingInterval = 10 * time.Second 29 | const WriteWait = 10 * time.Second 30 | 31 | var upgrader = websocket.Upgrader{ 32 | ReadBufferSize: 1024, 33 | WriteBufferSize: 1024, 34 | } 35 | 36 | type wsMsg struct { 37 | Key string 38 | Value json.RawMessage 39 | } 40 | 41 | type CmdConnect struct { 42 | Channel string 43 | Password string 44 | } 45 | 46 | func wsHandler(w http.ResponseWriter, r *http.Request) { 47 | 48 | gconn, err := upgrader.Upgrade(w, r, nil) 49 | if err != nil { 50 | log.Println(err) 51 | return 52 | } 53 | 54 | clientAddress := gconn.RemoteAddr().String() 55 | xFwdIP := r.Header.Get("X-Forwarded-For") 56 | if xFwdIP != "" { 57 | clientAddress += " (" + xFwdIP + ")" 58 | } 59 | 60 | c := NewConn(gconn) 61 | defer c.Close() 62 | c.peer, err = NewWebRTCPeer() 63 | if err != nil { 64 | c.logger.Error("NewWebRTCPeer error", "err", err.Error()) 65 | return 66 | } 67 | 68 | c.logger.Info("client connected", "addr", clientAddress) 69 | 70 | // setup ping/pong to keep connection open 71 | pingCh := time.Tick(PingInterval) 72 | 73 | // websocket connections support one concurrent reader and one concurrent writer. 74 | // we put reads in a new goroutine below and leave writes in the main goroutine 75 | wsInMsg := make(chan wsMsg) 76 | wsReadQuitChan := make(chan struct{}) 77 | 78 | go func() { 79 | defer close(wsReadQuitChan) 80 | defer c.logger.Debug("ws read goroutine quit") 81 | for { 82 | msgType, raw, err := c.wsConn.ReadMessage() 83 | if err != nil { 84 | c.logger.Error("ReadMessage error", "err", err) 85 | return 86 | } 87 | c.logger.Debug("read message", "msg", string(raw)) 88 | if msgType != websocket.TextMessage { 89 | c.logger.Error("unknown message type", "type", msgType) 90 | return 91 | } 92 | var msg wsMsg 93 | err = json.Unmarshal(raw, &msg) 94 | if err != nil { 95 | c.logger.Error(err.Error()) 96 | return 97 | } 98 | wsInMsg <- msg 99 | } 100 | }() 101 | 102 | for { 103 | select { 104 | case msg := <-wsInMsg: 105 | err = c.handleWSMsg(msg) 106 | if err != nil { 107 | j, _ := json.Marshal(err.Error()) 108 | m := wsMsg{Key: "error", Value: j} 109 | err = c.writeMsg(m) 110 | if err != nil { 111 | c.logger.Error("writemsg error", "err", err.Error()) 112 | } 113 | return 114 | } 115 | case <-wsReadQuitChan: 116 | return 117 | case <-c.quitchan: 118 | c.logger.Debug("quitChan closed") 119 | return 120 | case info := <-c.infoChan: 121 | j, err := json.Marshal(info) 122 | if err != nil { 123 | c.logger.Error("marshal error", "err", err.Error()) 124 | return 125 | } 126 | m := wsMsg{Key: "info", Value: j} 127 | err = c.writeMsg(m) 128 | if err != nil { 129 | c.logger.Error("writemsg error", "err", err.Error()) 130 | return 131 | } 132 | case <-pingCh: 133 | err := c.wsConn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(WriteWait)) 134 | if err != nil { 135 | c.logger.Error("ping client error", "err", err.Error()) 136 | return 137 | } 138 | } 139 | } 140 | } 141 | 142 | func (c *Conn) handleWSMsg(msg wsMsg) error { 143 | var err error 144 | switch msg.Key { 145 | case "ice_candidate": 146 | var candidate webrtc.ICECandidateInit 147 | err = json.Unmarshal(msg.Value, &candidate) 148 | if err != nil { 149 | return err 150 | } 151 | 152 | if candidate.Candidate != "" { 153 | if err = c.peer.pc.AddICECandidate(candidate); err != nil { 154 | c.logger.Error("AddICECandidate error", "err", err.Error()) 155 | return err 156 | } 157 | } 158 | case "get_channels": 159 | // send list of channels to client 160 | channels := reg.GetChannels() 161 | c.logger.Debug("channels", "c", channels) 162 | j, err := json.Marshal(channels) 163 | if err != nil { 164 | c.logger.Error("getchannels marshal", "err", err) 165 | return err 166 | } 167 | m := wsMsg{Key: "channels", Value: j} 168 | err = c.writeMsg(m) 169 | if err != nil { 170 | c.logger.Error(err.Error()) 171 | return err 172 | } 173 | case "session_subscriber": 174 | // subscriber session is only partially setup here as we have to wait for 175 | // channel selection to complete the setup 176 | var offer webrtc.SessionDescription 177 | err = json.Unmarshal(msg.Value, &offer) 178 | if err != nil { 179 | return err 180 | } 181 | if err := c.peer.pc.SetRemoteDescription(offer); err != nil { 182 | return err 183 | } 184 | // If there is no error, send a success message 185 | m := wsMsg{Key: "session_received"} 186 | err = c.writeMsg(m) 187 | if err != nil { 188 | c.logger.Error(err.Error()) 189 | return err 190 | } 191 | case "session_publisher": 192 | c.isPublisher = true 193 | var offer webrtc.SessionDescription 194 | err = json.Unmarshal(msg.Value, &offer) 195 | if err != nil { 196 | return err 197 | } 198 | 199 | err = c.setupSessionPublisher(offer) 200 | if err != nil { 201 | c.logger.Error("setupSession error", "err", err) 202 | return err 203 | } 204 | if publisherPassword != "" { 205 | m := wsMsg{Key: "password_required"} 206 | err = c.writeMsg(m) 207 | if err != nil { 208 | c.logger.Error(err.Error()) 209 | return err 210 | } 211 | } 212 | case "connect_publisher": 213 | cmd := CmdConnect{} 214 | err = json.Unmarshal(msg.Value, &cmd) 215 | if err != nil { 216 | return err 217 | } 218 | err := c.connectPublisher(cmd) 219 | if err != nil { 220 | c.logger.Error("connectPublisher error", "err", err) 221 | return err 222 | } 223 | case "connect_subscriber": 224 | cmd := CmdConnect{} 225 | err = json.Unmarshal(msg.Value, &cmd) 226 | if err != nil { 227 | return err 228 | } 229 | 230 | // finish subscriber session setup here 231 | c.channelName = cmd.Channel 232 | err = c.setupSessionSubscriber() 233 | if err != nil { 234 | c.logger.Error("setupSession error", "err", err) 235 | return err 236 | } 237 | 238 | if cmd.Channel == "" { 239 | return fmt.Errorf("channel cannot be empty") 240 | } 241 | if channelRegexp.MatchString(cmd.Channel) { 242 | return fmt.Errorf("channel name must contain only alphanumeric characters") 243 | } 244 | 245 | c.logger.Info("setting up subscriber for channel", "channel", c.channelName) 246 | 247 | s := reg.NewSubscriber() 248 | c.clientID = s.ID 249 | 250 | go func() { 251 | for { 252 | select { 253 | case <-c.quitchan: 254 | return 255 | case <-s.QuitChan: 256 | j, _ := json.Marshal(c.channelName) 257 | m := wsMsg{Key: "channel_closed", Value: j} 258 | c.writeMsg(m) 259 | close(c.quitchan) 260 | return 261 | } 262 | } 263 | }() 264 | 265 | if err := reg.AddSubscriber(c.channelName, s); err != nil { 266 | return err 267 | } 268 | } 269 | return nil 270 | } 271 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Babelcast a WebRTC audio broadcast server 2 | 3 | /* 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package main 18 | 19 | import ( 20 | "context" 21 | "flag" 22 | "fmt" 23 | "log/slog" 24 | "net/http" 25 | "os" 26 | "os/signal" 27 | "syscall" 28 | "time" 29 | ) 30 | 31 | const httpTimeout = 15 * time.Second 32 | 33 | var ( 34 | publisherPassword = "" 35 | 36 | reg *Registry 37 | ) 38 | 39 | func main() { 40 | port := flag.Int("port", 8080, "listen on this port") 41 | debug := flag.Bool("debug", false, "enable debug log") 42 | flag.Parse() 43 | 44 | var programLevel = new(slog.LevelVar) // Info by default 45 | logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: programLevel})) 46 | slog.SetDefault(logger) 47 | 48 | if *debug { 49 | programLevel.Set(slog.LevelDebug) 50 | } 51 | 52 | /* 53 | file, _ := os.Create("./cpu.pprof") 54 | pprof.StartCPUProfile(file) 55 | defer pprof.StopCPUProfile() 56 | */ 57 | 58 | slog.Info("starting server") 59 | 60 | publisherPassword = os.Getenv("PUBLISHER_PASSWORD") 61 | if publisherPassword != "" { 62 | slog.Info("publisher password set") 63 | } 64 | 65 | http.HandleFunc("/ws", wsHandler) 66 | http.Handle("/", http.FileServer(http.FS(embedContentHtml))) 67 | 68 | slog.Info("listening on port", "port", *port) 69 | 70 | srv := &http.Server{ 71 | Addr: fmt.Sprintf(":%d", *port), 72 | WriteTimeout: httpTimeout, 73 | ReadTimeout: httpTimeout, 74 | } 75 | 76 | reg = NewRegistry() 77 | 78 | go func() { 79 | err := srv.ListenAndServe() 80 | if err != nil && err != http.ErrServerClosed { 81 | slog.Error("error starting server", "err", err) 82 | } 83 | }() 84 | 85 | // trap sigterm or interrupt and gracefully shutdown the server 86 | sigChan := make(chan os.Signal, 1) 87 | signal.Notify(sigChan, syscall.SIGINT) 88 | signal.Notify(sigChan, syscall.SIGTERM) 89 | 90 | // block until a signal is received 91 | sig := <-sigChan 92 | slog.Info("got signal", "sig", sig) 93 | slog.Info("shutting down") 94 | 95 | ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) 96 | defer cancel() 97 | if err := srv.Shutdown(ctx); err != nil { 98 | slog.Error("graceful shutdown failed", "err", err) 99 | os.Exit(1) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /registry.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | "sync" 7 | 8 | "github.com/google/uuid" 9 | "github.com/pion/webrtc/v4" 10 | ) 11 | 12 | // keep track of which channels are being used 13 | // only permit one publisher per channel 14 | type Registry struct { 15 | sync.Mutex 16 | channels map[string]*Channel 17 | } 18 | 19 | type Channel struct { 20 | LocalTrack *webrtc.TrackLocalStaticRTP 21 | 22 | Publisher *Publisher 23 | Subscribers map[string]*Subscriber 24 | } 25 | 26 | type Publisher struct { 27 | ID string 28 | } 29 | type Subscriber struct { 30 | ID string 31 | QuitChan chan struct{} 32 | } 33 | 34 | func NewRegistry() *Registry { 35 | r := &Registry{} 36 | r.channels = make(map[string]*Channel) 37 | return r 38 | } 39 | 40 | func (r *Registry) AddPublisher(channelName string, localTrack *webrtc.TrackLocalStaticRTP) error { 41 | r.Lock() 42 | defer r.Unlock() 43 | var channel *Channel 44 | var ok bool 45 | p := Publisher{} 46 | p.ID = uuid.NewString() 47 | if channel, ok = r.channels[channelName]; ok { 48 | if channel.Publisher != nil { 49 | return fmt.Errorf("channel %q is already in use", channelName) 50 | } 51 | channel.LocalTrack = localTrack 52 | channel.Publisher = &p 53 | } else { 54 | channel = &Channel{ 55 | LocalTrack: localTrack, 56 | Publisher: &p, 57 | Subscribers: make(map[string]*Subscriber), 58 | } 59 | r.channels[channelName] = channel 60 | } 61 | slog.Info("publisher added", "channel", channelName) 62 | return nil 63 | } 64 | 65 | func (r *Registry) NewSubscriber() *Subscriber { 66 | s := &Subscriber{} 67 | s.QuitChan = make(chan struct{}) 68 | s.ID = uuid.NewString() 69 | return s 70 | } 71 | 72 | func (r *Registry) AddSubscriber(channelName string, s *Subscriber) error { 73 | r.Lock() 74 | defer r.Unlock() 75 | var channel *Channel 76 | var ok bool 77 | if channel, ok = r.channels[channelName]; ok && channel.Publisher != nil { 78 | channel.Subscribers[s.ID] = s 79 | slog.Info("subscriber added", "channel", channelName, "subscriber_count", len(channel.Subscribers)) 80 | } else { 81 | return fmt.Errorf("channel %q not ready", channelName) 82 | } 83 | return nil 84 | } 85 | 86 | func (r *Registry) RemovePublisher(channelName string) { 87 | r.Lock() 88 | defer r.Unlock() 89 | if channel, ok := r.channels[channelName]; ok { 90 | channel.Publisher = nil 91 | // tell all subscribers to quit 92 | for _, s := range channel.Subscribers { 93 | close(s.QuitChan) 94 | } 95 | slog.Info("publisher removed", "channel", channelName) 96 | } 97 | } 98 | 99 | func (r *Registry) RemoveSubscriber(channelName string, id string) { 100 | r.Lock() 101 | defer r.Unlock() 102 | if channel, ok := r.channels[channelName]; ok { 103 | delete(channel.Subscribers, id) 104 | slog.Info("subscriber removed", "channel", channelName, "subscriber_count", len(channel.Subscribers)) 105 | } 106 | } 107 | 108 | func (r *Registry) GetChannels() []string { 109 | r.Lock() 110 | defer r.Unlock() 111 | channels := make([]string, 0) 112 | for name, c := range r.channels { 113 | if c.Publisher != nil { 114 | channels = append(channels, name) 115 | } 116 | } 117 | return channels 118 | } 119 | 120 | func (r *Registry) GetChannel(channelName string) *Channel { 121 | r.Lock() 122 | defer r.Unlock() 123 | for name, c := range r.channels { 124 | if name == channelName && c.Publisher != nil { 125 | return c 126 | } 127 | } 128 | return nil 129 | } 130 | -------------------------------------------------------------------------------- /webrtc.go: -------------------------------------------------------------------------------- 1 | /* 2 | Licensed under the Apache License, Version 2.0 (the "License"); 3 | you may not use this file except in compliance with the License. 4 | You may obtain a copy of the License at 5 | 6 | http://www.apache.org/licenses/LICENSE-2.0 7 | 8 | Unless required by applicable law or agreed to in writing, software 9 | distributed under the License is distributed on an "AS IS" BASIS, 10 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 11 | See the License for the specific language governing permissions and 12 | limitations under the License. 13 | */ 14 | 15 | package main 16 | 17 | import ( 18 | "errors" 19 | "io" 20 | "log/slog" 21 | 22 | "github.com/pion/webrtc/v4" 23 | ) 24 | 25 | type WebRTCPeer struct { 26 | pc *webrtc.PeerConnection 27 | localTrackChan chan *webrtc.TrackLocalStaticRTP 28 | } 29 | 30 | func NewWebRTCPeer() (*WebRTCPeer, error) { 31 | 32 | var err error 33 | wp := &WebRTCPeer{} 34 | // Create a new RTCPeerConnection 35 | wp.pc, err = webrtc.NewPeerConnection(webrtc.Configuration{ 36 | ICEServers: []webrtc.ICEServer{ 37 | { 38 | URLs: []string{"stun:stun.l.google.com:19302"}, 39 | }, 40 | }, 41 | }) 42 | if err != nil { 43 | return nil, err 44 | } 45 | wp.localTrackChan = make(chan *webrtc.TrackLocalStaticRTP) 46 | 47 | return wp, nil 48 | } 49 | 50 | func (wp *WebRTCPeer) SetupPublisher(offer webrtc.SessionDescription, onStateChange func(connectionState webrtc.ICEConnectionState), onTrack func(track *webrtc.TrackRemote, receiver *webrtc.RTPReceiver), onIceCandidate func(c *webrtc.ICECandidate)) (answer webrtc.SessionDescription, err error) { 51 | 52 | // Allow us to receive 1 audio track 53 | if _, err = wp.pc.AddTransceiverFromKind(webrtc.RTPCodecTypeAudio); err != nil { 54 | return 55 | } 56 | 57 | wp.pc.OnICEConnectionStateChange(onStateChange) 58 | wp.pc.OnTrack(onTrack) 59 | wp.pc.OnICECandidate(onIceCandidate) 60 | 61 | // Set the remote SessionDescription 62 | if err = wp.pc.SetRemoteDescription(offer); err != nil { 63 | return 64 | } 65 | 66 | // Sets the LocalDescription, and starts our UDP listeners 67 | answer, err = wp.pc.CreateAnswer(nil) 68 | if err != nil { 69 | return 70 | } 71 | 72 | err = wp.pc.SetLocalDescription(answer) 73 | if err != nil { 74 | return 75 | } 76 | 77 | return 78 | } 79 | 80 | // SetupSubscriber completes the subscriber WebRTC session setup. 81 | // Earlier we called webrtc.SetRemoteDescription() to allow ICE to kick off 82 | func (wp *WebRTCPeer) SetupSubscriber(channel *Channel, onStateChange func(connectionState webrtc.ICEConnectionState), onIceCandidate func(c *webrtc.ICECandidate)) (answer webrtc.SessionDescription, err error) { 83 | 84 | rtpSender, addTrackErr := wp.pc.AddTrack(channel.LocalTrack) 85 | if addTrackErr != nil { 86 | err = addTrackErr 87 | return 88 | } 89 | 90 | // Read incoming RTCP packets 91 | // Before these packets are returned they are processed by interceptors. For things 92 | // like NACK this needs to be called. 93 | go func() { 94 | rtcpBuf := make([]byte, 1500) 95 | for { 96 | _, _, rtcpErr := rtpSender.Read(rtcpBuf) 97 | if rtcpErr != nil { 98 | if !errors.Is(rtcpErr, io.EOF) { 99 | slog.Error("rtpSender.Read error", "err", rtcpErr) 100 | } 101 | return 102 | } 103 | } 104 | }() 105 | 106 | wp.pc.OnICEConnectionStateChange(onStateChange) 107 | wp.pc.OnICECandidate(onIceCandidate) 108 | 109 | // Sets the LocalDescription, and starts our UDP listeners 110 | answer, err = wp.pc.CreateAnswer(nil) 111 | if err != nil { 112 | return 113 | } 114 | 115 | ldErr := wp.pc.SetLocalDescription(answer) 116 | if ldErr != nil { 117 | err = ldErr 118 | return 119 | } 120 | 121 | return 122 | } 123 | --------------------------------------------------------------------------------