Browse Source

Initial voice chat implementation

wip
Trevor Slocum 3 years ago
parent
commit
2e482ca157
  1. 3
      README.md
  2. 28
      cmd/harmony-server/main.go
  3. 8
      go.mod
  4. 116
      go.sum
  5. 114
      pkg/web/client.go
  6. 8
      pkg/web/message.go
  7. 50
      pkg/web/public/assets/css/harmony.css
  8. 334
      pkg/web/public/assets/js/harmony.js
  9. 46
      pkg/web/public/index.html
  10. 228
      pkg/web/web.go

3
README.md

@ -7,7 +7,8 @@ Voice and text communications platform
## Features
- WIP
- Low-latency voice chat using [Opus](https://en.wikipedia.org/wiki/Opus_%28audio_format%29)
- Rich text chat using [Markdown](https://en.wikipedia.org/wiki/Markdown)
## Support

28
cmd/harmony-server/main.go

@ -1,16 +1,40 @@
package main
import (
"flag"
"log"
"net/http"
_ "net/http/pprof"
"git.sr.ht/~tslocum/harmony/pkg/web"
)
var (
debugAddress string
webAddress string
webPath string
)
func main() {
log.Println("starting server")
flag.StringVar(&debugAddress, "debug-address", "", "address to serve debug info")
flag.StringVar(&webAddress, "web-address", "", "address to serve web client")
flag.StringVar(&webPath, "web-path", "/", "path to serve web client")
flag.Parse()
if webAddress == "" {
log.Fatal("Argument -web-address is required")
}
w := web.NewWebInterface("localhost:8080")
if debugAddress != "" {
go func() {
log.Fatal(http.ListenAndServe(debugAddress, nil))
}()
}
w := web.NewWebInterface(webAddress, webPath)
_ = w
log.Println("harmony-server started")
select {}
}

8
go.mod

@ -5,6 +5,14 @@ go 1.13
require (
github.com/GeertJohan/go.rice v1.0.0
github.com/daaku/go.zipexe v1.0.1 // indirect
github.com/golang/protobuf v1.3.2 // indirect
github.com/gorilla/mux v1.7.3
github.com/gorilla/websocket v1.4.1
github.com/lucas-clemente/quic-go v0.13.1 // indirect
github.com/pion/rtp v1.1.4
github.com/pion/webrtc/v2 v2.1.16
github.com/pkg/errors v0.8.1
golang.org/x/crypto v0.0.0-20191202143827-86a70503ff7e // indirect
golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933 // indirect
golang.org/x/sys v0.0.0-20191128015809-6d18c012aee9 // indirect
)

116
go.sum

@ -2,16 +2,130 @@ github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo
github.com/GeertJohan/go.rice v1.0.0 h1:KkI6O9uMaQU3VEKaj01ulavtF7o1fWT7+pk/4voiMLQ=
github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0=
github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/daaku/go.zipexe v1.0.0 h1:VSOgZtH418pH9L16hC/JrgSNJbbAL26pj7lmD1+CGdY=
github.com/alangpierce/go-forceexport v0.0.0-20160317203124-8f1d6941cd75 h1:3ILjVyslFbc4jl1w5TWuvvslFD/nDfR2H8tVaMVLrEY=
github.com/alangpierce/go-forceexport v0.0.0-20160317203124-8f1d6941cd75/go.mod h1:uAXEEpARkRhCZfEvy/y0Jcc888f9tHCc1W7/UeEtreE=
github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE=
github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ=
github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E=
github.com/daaku/go.zipexe v1.0.1 h1:wV4zMsDOI2SZ2m7Tdz1Ps96Zrx+TzaK15VbUaGozw0M=
github.com/daaku/go.zipexe v1.0.1/go.mod h1:5xWogtqlYnfBXkSB1o9xysukNP9GTvaNkqzUZbt3Bw8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/golang/mock v1.2.0 h1:28o5sBqPkBsMGnC6b4MvE2TzSr5/AT4c/1fLqVGIwlk=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.0 h1:kbxbvI4Un1LUWKxufD+BiE6AEExYYgkQLQmLFqA1LFk=
github.com/golang/protobuf v1.3.0/go.mod h1:Qd/q+1AKNOZr9uGQzbzCmRO6sUih6GTPZv6a1/R87v0=
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hpcloud/tail v1.0.0 h1:nfCOvKYfkgYP8hkirhJocXT2+zOD8yUNjXaWfTlyFKI=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lucas-clemente/quic-go v0.7.1-0.20190401152353-907071221cf9/go.mod h1:PpMmPfPKO9nKJ/psF49ESTAGQSdfXxlg1otPbEB2nOw=
github.com/lucas-clemente/quic-go v0.13.1 h1:CxtJTXQIh2aboCPk0M6vf530XOov6DZjVBiSE3nSj8s=
github.com/lucas-clemente/quic-go v0.13.1/go.mod h1:Vn3/Fb0/77b02SGhQk36KzOUmXgVpFfizUfW5WMaqyU=
github.com/marten-seemann/chacha20 v0.2.0 h1:f40vqzzx+3GdOmzQoItkLX5WLvHgPgyYqFFIO5Gh4hQ=
github.com/marten-seemann/chacha20 v0.2.0/go.mod h1:HSdjFau7GzYRj+ahFNwsO3ouVJr1HFkWoEwNDb4TMtE=
github.com/marten-seemann/qpack v0.1.0/go.mod h1:LFt1NU/Ptjip0C2CPkhimBz5CGE3WGDAUWqna+CNTrI=
github.com/marten-seemann/qtls v0.2.3/go.mod h1:xzjG7avBwGGbdZ8dTGxlBnLArsVKLvwmjgmPuiQEcYk=
github.com/marten-seemann/qtls v0.4.1 h1:YlT8QP3WCCvvok7MGEZkMldXbyqgr8oFg5/n8Gtbkks=
github.com/marten-seemann/qtls v0.4.1/go.mod h1:pxVXcHHw1pNIt8Qo0pwSYQEoZ8yYOOPXTCZLQQunvRc=
github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0 h1:WSHQ+IS43OoUrWtD1/bbclrwK8TTH5hzp+umCiuxHgs=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3 h1:RE1xgDvH7imwFD45h+u2SgIfERHlS2yNG4DObb5BSKU=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pion/datachannel v1.4.13 h1:ezTn3AtUtXvKemRRjRdUgao/T8bH4ZJwrpOqU8Iz3Ss=
github.com/pion/datachannel v1.4.13/go.mod h1:+rBUwEDonA63KXx994DP/ofyyGVAm6AIMvOqQZxjWRU=
github.com/pion/dtls/v2 v2.0.0-rc.3 h1:u9utI+EDJOjOWfrkGQsD8WNssPcTwfYIanFB6oI8K+4=
github.com/pion/dtls/v2 v2.0.0-rc.3/go.mod h1:x0XH+cN5z+l/+/4nYL8r4sB8g6+0d1Zp2Pfkcoz8BKY=
github.com/pion/ice v0.7.2 h1:b+QxnpJ7AVyFDXBOMnEypNXS+fZM8+4+itNInwrrI6U=
github.com/pion/ice v0.7.2/go.mod h1:xLKf+788DA/ZubtdBfiDT3vnEmIdiF5eDqjs4rzUAg8=
github.com/pion/logging v0.2.1/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/logging v0.2.2 h1:M9+AIj/+pxNsDfAT64+MAVgJO0rsyLnoJKCqf//DoeY=
github.com/pion/logging v0.2.2/go.mod h1:k0/tDVsRCX2Mb2ZEmTqNa7CWsQPc+YYCB7Q+5pahoms=
github.com/pion/mdns v0.0.3 h1:DxdOYd0pgwLKiDlIIxfU0qdG5iWh1Xn6CsS9vc6cMAY=
github.com/pion/mdns v0.0.3/go.mod h1:VrN3wefVgtfL8QgpEblPUC46ag1reLIfpqekCnKunLE=
github.com/pion/quic v0.1.1 h1:D951FV+TOqI9A0rTF7tHx0Loooqz+nyzjEyj8o3PuMA=
github.com/pion/quic v0.1.1/go.mod h1:zEU51v7ru8Mp4AUBJvj6psrSth5eEFNnVQK5K48oV3k=
github.com/pion/rtcp v1.2.1 h1:S3yG4KpYAiSmBVqKAfgRa5JdwBNj4zK3RLUa8JYdhak=
github.com/pion/rtcp v1.2.1/go.mod h1:a5dj2d6BKIKHl43EnAOIrCczcjESrtPuMgfmL6/K6QM=
github.com/pion/rtp v1.1.3/go.mod h1:/l4cvcKd0D3u9JLs2xSVI95YkfXW87a3br3nqmVtSlE=
github.com/pion/rtp v1.1.4 h1:P6xh8Y8JfzR7+JAbI79X2M8kfYETaqbuM5Otm+Z+k6U=
github.com/pion/rtp v1.1.4/go.mod h1:/l4cvcKd0D3u9JLs2xSVI95YkfXW87a3br3nqmVtSlE=
github.com/pion/sctp v1.7.3 h1:Pok18oncuAq/WjNxbyltfBSLvbv/6QSCyVJKYyDWP5M=
github.com/pion/sctp v1.7.3/go.mod h1:c6C9jaDGX7f5xeSRVju/140XatpO9sOVe81EwpfzAc8=
github.com/pion/sdp/v2 v2.3.1 h1:45dub4NRdwyDmQCD3GIY7DZuqC49GBUwBdjuetvdOr0=
github.com/pion/sdp/v2 v2.3.1/go.mod h1:jccXVYW0fuK6ds2pwKr89SVBDYlCjhgMI6nucl5R5rA=
github.com/pion/srtp v1.2.6 h1:mHQuAMh0P67R7/j1F260u3O+fbRWLyjKLRPZYYvODFM=
github.com/pion/srtp v1.2.6/go.mod h1:rd8imc5htjfs99XiEoOjLMEOcVjME63UHx9Ek9IGst0=
github.com/pion/stun v0.3.3 h1:brYuPl9bN9w/VM7OdNzRSLoqsnwlyNvD9MVeJrHjDQw=
github.com/pion/stun v0.3.3/go.mod h1:xrCld6XM+6GWDZdvjPlLMsTU21rNxnO6UO8XsAvHr/M=
github.com/pion/transport v0.6.0/go.mod h1:iWZ07doqOosSLMhZ+FXUTq+TamDoXSllxpbGcfkCmbE=
github.com/pion/transport v0.8.9/go.mod h1:lpeSM6KJFejVtZf8k0fgeN7zE73APQpTF83WvA1FVP8=
github.com/pion/transport v0.8.10 h1:lTiobMEw2PG6BH/mgIVqTV2mBp/mPT+IJLaN8ZxgdHk=
github.com/pion/transport v0.8.10/go.mod h1:tBmha/UCjpum5hqTWhfAEs3CO4/tHSg0MYRhSzR+CZ8=
github.com/pion/turn v1.4.0 h1:7NUMRehQz4fIo53Qv9ui1kJ0Kr1CA82I81RHKHCeM80=
github.com/pion/turn v1.4.0/go.mod h1:aDSi6hWX/hd1+gKia9cExZOR0MU95O7zX9p3Gw/P2aU=
github.com/pion/webrtc/v2 v2.1.16 h1:WxljXV1jj/1aOeMR1kkrQELYrvX9N6iQLLF0uUKtslk=
github.com/pion/webrtc/v2 v2.1.16/go.mod h1:mnx1SpzMEnH34BJ3yzW3L5TPGqw0eH5q87nu9g7c5v4=
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190829043050-9756ffdc2472/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191029031824-8986dd9e96cf/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20191202143827-86a70503ff7e h1:egKlR8l7Nu9vHGWbcUV8lqR4987UfUbBd7GbhqGzNYU=
golang.org/x/crypto v0.0.0-20191202143827-86a70503ff7e/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190228165749-92fc7df08ae7/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190619014844-b5b0513f8c1b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191028085509-fe3aa8a45271/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933 h1:e6HwijUxhDe+hPNjZQQn9bA5PW3vNmnN64U2ZW759Lk=
golang.org/x/net v0.0.0-20191126235420-ef20fe5d7933/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190228124157-a34e9553db1e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191029155521-f43be2a4598c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191128015809-6d18c012aee9 h1:ZBzSG/7F4eNKz2L3GE9o300RX0Az1Bw5HF7PDraD+qU=
golang.org/x/sys v0.0.0-20191128015809-6d18c012aee9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

114
pkg/web/client.go

@ -0,0 +1,114 @@
package web
import (
"encoding/json"
"log"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/pion/webrtc/v2"
"github.com/pkg/errors"
)
type Client struct {
ID int
Status int
Conn *websocket.Conn
PeerConn *webrtc.PeerConnection
In chan *Message
Out chan *Message
AudioTrack *webrtc.Track
AudioTrackLock *sync.RWMutex
Terminated chan bool
}
func NewClient(conn *websocket.Conn) *Client {
c := Client{Conn: conn, In: make(chan *Message, 10), Out: make(chan *Message, 10), Terminated: make(chan bool), AudioTrackLock: new(sync.RWMutex)}
go c.handleRead()
go c.handleWrite()
return &c
}
func (c *Client) handleRead() {
var (
messageType int
message []byte
err error
)
for {
c.Conn.SetReadDeadline(time.Now().Add(1 * time.Minute))
messageType, message, err = c.Conn.ReadMessage()
if err != nil || c.Status == -1 {
c.Close()
return
}
in := Message{}
if messageType == 2 {
in.T = messageType
in.M = message
} else {
err = json.Unmarshal(message, &in)
if err != nil {
// TODO Place error behind debug/verbose var
log.Println(string(message))
log.Println()
log.Println(err)
c.Close()
return
}
}
in.S = c.ID
c.In <- &in
}
}
func (c *Client) handleWrite() {
var (
out []byte
err error
)
for msg := range c.Out {
if msg == nil {
return
}
out, err = json.Marshal(msg)
if err != nil {
//c.Close()
return
}
c.Conn.WriteMessage(1, out)
}
}
func (c *Client) Close() {
if c.Status == -1 {
return
}
c.Status = -1
if c.PeerConn != nil {
c.PeerConn.Close()
}
if c.Conn != nil {
c.Conn.Close()
}
c.In <- nil
c.Out <- nil
log.Printf("closing %d: %+v", c.ID, errors.New("test"))
go func() {
c.Terminated <- true
}()
}

8
pkg/web/message.go

@ -0,0 +1,8 @@
package web
const (
MessageBinary = 2
MessagePing = 100
MessageCall = 101
MessageAnswer = 102
)

50
pkg/web/public/assets/css/harmony.css

@ -0,0 +1,50 @@
html, body {
width: 100%;
height: 100%;
}
body {
margin: 0;
padding: 0;
}
textarea {
margin: 0;
}
* {
font-size: 22pt;
}
td {
vertical-align: top;
}
#chathistory {
overflow-x: auto;
overflow-y: scroll;
display: inline-block;
width: 100%;
max-height: 100%;
}
#inputcontainer {
padding-top: 7px;
padding-bottom: 7px;
}
#chatinput {
width: 100%;
height: 100%;
}
#voiceinactive {
display: inline-block;
}
#voicestatus {
display: inline-block;
width: 50px;
white-space: pre;
}

334
pkg/web/public/assets/js/harmony.js

@ -3,30 +3,179 @@ var ReconnectDelay = 0;
var reconnectTimeout;
var connected;
var chatprefix = "";
var voice = false;
var ptt = false;
var printStats = false;
var pc;
var RTCOfferOptions = {
offerToReceiveAudio: 1,
offerToReceiveVideo: 0,
voiceActivityDetection: false
};
var RTCICEServers = [{urls: 'stun:stun.l.google.com:19302'}];
var audioTrack;
$(document).keydown(HandleInput);
$(document).keyup(HandleInput);
function HandleInput(e) {
if (e.which == 119) {
if (e.type == "keydown") {
StartPTT();
} else if (e.type == "keyup") {
StopPTT();
}
e.preventDefault();
} else if ((e.which == 13 || e.which == 176) && e.type == "keydown" && !e.shiftKey) {
if (!$("#chatinput").is(":focus")) {
return;
}
if ($("#chatinput").val() != "") {
Log("&lt;tee&gt; " + $("#chatinput").val());
}
$("#chatinput").val('');
e.preventDefault();
}
}
$(document).ready(function () {
$("#startButton").on("click", function () {
navigator.mediaDevices.getUserMedia({audio: true}).then(stream => {
let options = {mimeType: 'audio/ogg'};
recorder = new MediaRecorder(stream, options);
recorder.start(60);
recorder.addEventListener('dataavailable', (e) => {
if (!e.data || e.data.size <= 0) {
return;
$("#voiceButtonJoin").on("click", function () {
pc = new RTCPeerConnection({
iceServers: RTCICEServers
});
pc.onicecandidate = event => {
if (event.candidate === null) {
}
};
navigator.mediaDevices.getUserMedia({audio: true, video: false})
.then(localStream => {
audioTracks = localStream.getAudioTracks();
if (audioTracks.length > 0) {
console.log(`Using Audio device: ${audioTracks[0].label} - tracks available: ${audioTracks}`);
}
socket.send(e.data);
});
});
audioTrack = audioTracks[0];
pc.addTrack(audioTrack);
pc.getSenders()[0].replaceTrack(null);
pc.ontrack = function (event) {
console.log(`onTrack ${event.streams.length}`);
pc.addTransceiver(event.streams[0].getAudioTracks()[0], {'direction': 'sendrecv'});
var el = document.getElementById('audioplayer');
el.autoplay = true;
el.srcObject = event.streams[0];
voice = true;
Log("* tee has joined voice chat");
Log("* Push to talk is bound to F8");
$('#voiceinactive').css('display', 'none');
$('#voiceactive').css('display', 'inline-block');
$('#voiceactiveside').css('display', 'inline-block');
$('#voiceButton').html('Quit voice chat');
updateVoiceStatus();
};
pc.createOffer(RTCOfferOptions)
.then(onRTCDescription, onRTCDescriptionError);
}).catch(Log);
});
$("#stopButton").on("click", function () {
recorder.stop();
$("#pushtotalk").on("mousedown", function (e) {
StartPTT();
});
$(document).on("mouseup", function (e) {
StopPTT();
});
$("#voiceButtonQuit").on("click", function () {
pc.close();
voice = false;
Log("* tee has quit voice chat");
$('#voiceactive').css('display', 'none');
$('#voiceactiveside').css('display', 'none');
$('#voiceinactive').css('display', 'inline-block');
$('#voiceButton').html('Join voice chat');
updateVoiceStatus();
});
Connect();
window.setInterval(() => {
if (!webSocketReady()) {
return;
}
w(100, "ping");
}, 15000);
if (printStats) {
window.setInterval(() => {
if (!pc) {
return;
}
const sender = pc.getSenders()[0];
if (sender === undefined) {
return;
}
sender.getStats().then(stats => {
let statsOutput = "";
stats.forEach(report => {
if (report.type == "local-candidate" || report.type == "remote-candidate") {
return;
} else if (report.type == "candidate-pair" && (report.bytesSent == 0 && report.bytesReceived == 0)) {
return;
}
statsOutput += `<b>Report: ${report.type}</b>\n<strong>ID:</strong> ${report.id}<br>\n` +
`<strong>Timestamp:</strong> ${report.timestamp}<br>\n`;
Object.keys(report).forEach(statName => {
if (statName !== "id" && statName !== "timestamp" && statName !== "type") {
statsOutput += `<strong>${statName}:</strong> ${report[statName]}<br>\n`;
}
});
});
document.querySelector("#stats").innerHTML = statsOutput;
});
}, 1000);
}
});
function onRTCDescriptionError(error) {
console.log(`Failed to create/set session description: ${error.toString()}`);
}
function onRTCDescription(desc) {
console.log(`Offer from pc\n${desc.sdp}`);
pc.setLocalDescription(desc)
.then(() => {
desc.sdp = maybePreferCodec(desc.sdp, 'audio', 'send', 'opus');
desc.sdp = maybePreferCodec(desc.sdp, 'audio', 'receive', 'opus');
console.log("onRTCDescription");
console.log(desc);
w(101, desc.sdp);
}, onRTCDescriptionError);
}
function Connect() {
reconnectTimeout = null;
if (webSocketReady() || ReconnectDelay === -1) {
@ -34,6 +183,8 @@ function Connect() {
return;
}
Log("* Connecting...");
var loc = window.location, wsurl, pathname;
if (loc.protocol === "https:") {
wsurl = "wss:";
@ -49,32 +200,39 @@ function Connect() {
socket = new WebSocket(wsurl);
socket.onerror = function (e) {
Log(e);
console.log(e);
};
socket.onopen = function (e) {
if (reconnectTimeout != null) {
clearTimeout(reconnectTimeout);
}
Log("* Connected");
updateVoiceStatus();
};
socket.onmessage = function (e) {
if (ReconnectDelay > 0) {
ReconnectDelay = 0;
}
var incomingmsg = e.data;
var nowconnected = false;
try {
//var data = jQuery.parseJSON(incomingmsg);
//if (data.hasOwnProperty("status") && data.status === "fail") {
console.log("Got " + incomingmsg);
if (typeof e.data === "string") {
var p = JSON.parse(e.data);
if (p.T == 102) {
pc.setRemoteDescription(new RTCSessionDescription({type: 'answer', sdp: atob(p.M)}));
}
} else {
// TODO Binary data
}
} catch (e) {
console.log(e);
}
NoteCheckingItem = false;
};
socket.onclose = function (e) {
connected = false;
Log("* Disconnected");
if (ReconnectDelay < 0 || reconnectTimeout != null) {
return;
}
@ -105,3 +263,137 @@ function waitForSocketConnection(socket, callback) {
}
}, 250);
}
function Log(msg) {
$('#chathistory').append(chatprefix + msg + "\n");
if (chatprefix == "") {
chatprefix = "<br>";
}
}
function StartPTT() {
if (ptt) {
return;
}
ptt = true;
var sender = pc.getSenders()[0];
sender.replaceTrack(audioTrack);
updateVoiceStatus();
}
function StopPTT() {
if (!ptt) {
return;
}
ptt = false;
var sender = pc.getSenders()[0];
sender.replaceTrack(null);
updateVoiceStatus();
}
function updateVoiceStatus() {
if (ptt) {
$('#voicestatus').html('<b>PTT Active</b>');
} else {
if (voice) {
$('#voicestatus').html('1 user');
} else {
$('#voicestatus').html('0 users');
}
}
}
function w(t, m) {
if (!webSocketReady()) {
return;
}
socket.send(JSON.stringify({T: t, M: btoa(m)}));
}
// Copied from AppRTC's sdputils.js:
// Sets |codec| as the default |type| codec if it's present.
// The format of |codec| is 'NAME/RATE', e.g. 'opus/48000'.
function maybePreferCodec(sdp, type, dir, codec) {
const str = `${type} ${dir} codec`;
if (codec === '') {
console.log(`No preference on ${str}.`);
return sdp;
}
console.log(`Prefer ${str}: ${codec}`);
const sdpLines = sdp.split('\r\n');
// Search for m line.
const mLineIndex = findLine(sdpLines, 'm=', type);
if (mLineIndex === null) {
return sdp;
}
// If the codec is available, set it as the default in m line.
const codecIndex = findLine(sdpLines, 'a=rtpmap', codec);
console.log('codecIndex', codecIndex);
if (codecIndex) {
const payload = getCodecPayloadType(sdpLines[codecIndex]);
if (payload) {
sdpLines[mLineIndex] = setDefaultCodec(sdpLines[mLineIndex], payload);
}
}
sdp = sdpLines.join('\r\n');
return sdp;
}
// Find the line in sdpLines that starts with |prefix|, and, if specified,
// contains |substr| (case-insensitive search).
function findLine(sdpLines, prefix, substr) {
return findLineInRange(sdpLines, 0, -1, prefix, substr);
}
// Find the line in sdpLines[startLine...endLine - 1] that starts with |prefix|
// and, if specified, contains |substr| (case-insensitive search).
function findLineInRange(sdpLines, startLine, endLine, prefix, substr) {
const realEndLine = endLine !== -1 ? endLine : sdpLines.length;
for (let i = startLine; i < realEndLine; ++i) {
if (sdpLines[i].indexOf(prefix) === 0) {
if (!substr ||
sdpLines[i].toLowerCase().indexOf(substr.toLowerCase()) !== -1) {
return i;
}
}
}
return null;
}
// Gets the codec payload type from an a=rtpmap:X line.
function getCodecPayloadType(sdpLine) {
const pattern = new RegExp('a=rtpmap:(\\d+) \\w+\\/\\d+');
const result = sdpLine.match(pattern);
return (result && result.length === 2) ? result[1] : null;
}
// Returns a new m= line with the specified codec as the first one.
function setDefaultCodec(mLine, payload) {
const elements = mLine.split(' ');
// Just copy the first three parameters; codec order starts on fourth.
const newLine = elements.slice(0, 3);
// Put target payload first and copy in the rest.
newLine.push(payload);
for (let i = 3; i < elements.length; i++) {
if (elements[i] !== payload) {
newLine.push(elements[i]);
}
}
return newLine.join(' ');
}

46
pkg/web/public/index.html

@ -1,12 +1,46 @@
<!DOCTYPE html>
<html>
<head>
<title>harmony</title>
<script src="assets/js/jquery.js"></script>
<script src="assets/js/harmony.js"></script>
<title>#lobby - harmony</title>
<link rel="stylesheet" href="assets/css/harmony.css">
<script src="assets/js/jquery.js"></script>
<script src="assets/js/harmony.js"></script>
</head>
<body>
<button id="startButton">Start</button><br><br>
<button id="stopButton">Stop</button><br><br>
<audio id="player" controls></audio>
<table style="width: 100%; height: 100%;">
<tr>
<td id="chatcontainer" colspan="2" style="height: 100%;">
<div id="chathistory"></div>
</td>
</tr>
<tr style="height: 35px;">
<td colspan="2">
<hr>
</td>
</tr>
<tr style="height: 100px;">
<td id="inputcontainer" colspan="2"><textarea id="chatinput" placeholder="Message #lobby"></textarea></td>
</tr>
<tr style="height: 55px;">
<td>
<div id="voiceinactive">
<button id="voiceButtonJoin">Join voice chat</button>
</div>
<div id="voiceactive" style="display: none;">
<button id="pushtotalk">Push to talk</button>
</div> &nbsp;
<div id="voicestatus"></div>
</td>
<td align="right">
<div id="voiceactiveside" style="display: none;">
<button id="voiceButtonQuit">Quit voice chat</button>
</div>
</td>
</tr>
</table>
<div id="stats" style="display: none"></div>
<div style="display: none">
<audio id="audioplayer"></audio>
</div>
</body>
</html>

228
pkg/web/web.go

@ -1,14 +1,31 @@
package web
import (
"io"
"log"
"math/rand"
"net/http"
"sync"
"time"
rice "github.com/GeertJohan/go.rice"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
"github.com/pion/rtp"
"github.com/pion/webrtc/v2"
"github.com/pkg/errors"
)
var peerConnectionConfig = webrtc.Configuration{
ICEServers: []webrtc.ICEServer{
{
URLs: []string{"stun:stun.l.google.com:19302"},
},
},
}
var incomingClients = make(chan *Client, 10)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
@ -18,15 +35,28 @@ var upgrader = websocket.Upgrader{
EnableCompression: true,
}
type Message struct {
S int // Source
T int // Type
M []byte // Message
}
type WebInterface struct {
Clients map[int]*Client
ClientsLock *sync.Mutex
}
func NewWebInterface(address string) *WebInterface {
w := WebInterface{}
func NewWebInterface(address string, path string) *WebInterface {
w := WebInterface{Clients: make(map[int]*Client), ClientsLock: new(sync.Mutex)}
r := mux.NewRouter()
r.HandleFunc("/w", webSocketHandler)
r.PathPrefix("/").Handler(http.FileServer(rice.MustFindBox("public").HTTPBox()))
r.HandleFunc(path+"w", w.webSocketHandler)
r.PathPrefix(path).Handler(http.StripPrefix(path, http.FileServer(rice.MustFindBox("public").HTTPBox())))
go w.handleIncomingClients()
go w.handleTerminatedClients()
go func() {
if err := http.ListenAndServe(address, r); err != nil {
@ -37,36 +67,188 @@ func NewWebInterface(address string) *WebInterface {
return &w
}
func webSocketHandler(w http.ResponseWriter, r *http.Request) {
log.Println("new WS")
func (w *WebInterface) handleIncomingClients() {
for c := range incomingClients {
w.ClientsLock.Lock()
id := w.nextClientID()
c.ID = id
w.Clients[id] = c
w.ClientsLock.Unlock()
conn, err := upgrader.Upgrade(w, r, nil)
go w.handleRead(c)
}
}
func (w *WebInterface) handleTerminatedClients() {
for {
time.Sleep(1 * time.Second)
w.ClientsLock.Lock()
for id := range w.Clients {
if w.Clients[id].Status == -1 {
delete(w.Clients, id)
}
}
w.ClientsLock.Unlock()
}
}
func (w *WebInterface) handleRead(c *Client) {
for msg := range c.In {
if msg == nil {
return
}
log.Printf("%d -> %d %d", msg.S, msg.T, len(msg.M))
switch msg.T {
case MessageBinary:
// TODO Binary message
continue
case MessagePing:
// TODO Handle ping
case MessageCall:
answer, err := w.answerRTC(c, msg.M)
if err != nil {
continue
}
c.Out <- &Message{T: MessageAnswer, M: answer}
default:
log.Printf("Unhandled message %d %s", msg.T, msg.M)
}
}
}
func (w *WebInterface) nextClientID() int {
id := 1
for {
if _, ok := w.Clients[id]; !ok {
break
}
id++
}
return id
}
func (w *WebInterface) webSocketHandler(wr http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(wr, r, nil)
if err != nil {
return
}
vars := mux.Vars(r)
c := NewClient(conn)
incomingClients <- c
<-c.Terminated
}
func (w *WebInterface) answerRTC(c *Client, sdp []byte) ([]byte, error) {
if c.PeerConn != nil {
return nil, errors.New("already have peerconn")
}
offer := webrtc.SessionDescription{Type: webrtc.SDPTypeOffer, SDP: string(sdp)}
m := webrtc.MediaEngine{}
err = conn.WriteMessage(websocket.TextMessage, []byte("Test server write"))
err := m.PopulateFromSDP(offer)
if err != nil {
conn.Close()
return
panic(err)
}
_ = vars
api := webrtc.NewAPI(webrtc.WithMediaEngine(m))
var (
messageType int
message []byte
)
for {
messageType, message, err = conn.ReadMessage()
if err != nil {
conn.Close()
return
audioCodecs := m.GetCodecsByKind(webrtc.RTPCodecTypeAudio)
if len(audioCodecs) == 0 {
panic("Offer contained no audio codecs")
}
pc, err := api.NewPeerConnection(peerConnectionConfig)
if err != nil {
panic(err)
}
c.PeerConn = pc
err = pc.SetRemoteDescription(offer)
if err != nil {
panic(err)
}
var payloadType uint8
for i := range audioCodecs {
if audioCodecs[i].Name == webrtc.Opus {
payloadType = audioCodecs[i].PayloadType
break
}
}
if payloadType == 0 {
panic("no payloadType")
}
log.Printf("received %d %d", messageType, len(message))
_ = messageType
t, err := pc.NewTrack(payloadType, rand.Uint32(), "audio", "harmony")
if err != nil {
panic(err)
}
c.AudioTrack = t
_, err = pc.AddTrack(t)
if err != nil {
panic(err)
}
pc.OnTrack(func(remoteTrack *webrtc.Track, receiver *webrtc.RTPReceiver) {
var (
p *rtp.Packet
err error
)
for {
p, err = remoteTrack.ReadRTP()
if err != nil {
c.Close()
return
}
w.ClientsLock.Lock()
for _, wc := range w.Clients {
if wc.ID == c.ID || wc.AudioTrack == nil {
continue
}
p.SSRC = wc.AudioTrack.SSRC()
p.PayloadType = wc.AudioTrack.PayloadType()
if err = wc.AudioTrack.WriteRTP(p); err != nil && err != io.ErrClosedPipe {
panic(err)
}
}
w.ClientsLock.Unlock()
}
})
pc.OnConnectionStateChange(func(connectionState webrtc.PeerConnectionState) {
log.Printf("%d conn state -> %s\n", c.ID, connectionState)
// TODO User has quit voice chat
})
pc.OnICEConnectionStateChange(func(connectionState webrtc.ICEConnectionState) {
log.Printf("%d ice state -> %s\n", c.ID, connectionState)
})
answer, err := pc.CreateAnswer(nil)
if err != nil {
panic(err)
}
err = pc.SetLocalDescription(answer)
if err != nil {
panic(err)
}
return []byte(answer.SDP), nil
}

Loading…
Cancel
Save