Strict message validation

Finally, we parse incoming JSON into Message objects, check timestamps for being too far in the past, and perform other quality-of-life checks at the server, to limit avenues for abuse. This also adds a "binary" message type, which could be useful for Arduino projects.

The websocket library has been swapped out for one that makes more sense to me, bringing in a ton of dependencies.

Finally, we are now telling clients how many other clients are connected.
This commit is contained in:
Neale Pickett 2022-06-06 14:03:01 -06:00 committed by GitHub
commit 910e9e75c0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 816 additions and 287 deletions

View File

@ -1,19 +1,23 @@
package main package main
import ( import (
"io"
"log" "log"
) )
// Book maps names to repeaters
//
// It ensures that names map 1-1 to repeaters.
type Book struct { type Book struct {
entries map[string]*Repeater entries map[string]*Repeater
events chan bookEvent events chan bookEvent
makeRepeater func() *Repeater
} }
func NewBook() Book { func NewBook() Book {
return Book{ return Book{
entries: make(map[string]*Repeater), entries: make(map[string]*Repeater),
events: make(chan bookEvent, 5), events: make(chan bookEvent, 5),
makeRepeater: NewRepeater,
} }
} }
@ -28,34 +32,38 @@ const (
type bookEvent struct { type bookEvent struct {
eventType bookEventType eventType bookEventType
name string name string
w io.Writer sender MessageSender
p []byte m Message
} }
func (b Book) Join(name string, w io.Writer) { // Join adds a writer to a named repeater
func (b Book) Join(name string, sender MessageSender) {
b.events <- bookEvent{ b.events <- bookEvent{
eventType: joinEvent, eventType: joinEvent,
name: name, name: name,
w: w, sender: sender,
} }
} }
func (b Book) Part(name string, w io.Writer) { // Part removes a writer from a named repeater
func (b Book) Part(name string, sender MessageSender) {
b.events <- bookEvent{ b.events <- bookEvent{
eventType: partEvent, eventType: partEvent,
name: name, name: name,
w: w, sender: sender,
} }
} }
func (b Book) Send(name string, p []byte) { // Send transmits a message to the named repeater
func (b Book) Send(name string, m Message) {
b.events <- bookEvent{ b.events <- bookEvent{
eventType: sendEvent, eventType: sendEvent,
name: name, name: name,
p: p, m: m,
} }
} }
// Run is the endless run loop
func (b Book) Run() { func (b Book) Run() {
for { for {
b.loop() b.loop()
@ -69,16 +77,16 @@ func (b Book) loop() {
switch event.eventType { switch event.eventType {
case joinEvent: case joinEvent:
if !ok { if !ok {
repeater = NewRepeater() repeater = b.makeRepeater()
b.entries[event.name] = repeater b.entries[event.name] = repeater
} }
repeater.Join(event.w) repeater.Join(event.sender)
case partEvent: case partEvent:
if !ok { if !ok {
log.Println("WARN: Parting an empty channel:", event.name) log.Println("WARN: Parting an empty channel:", event.name)
break break
} }
repeater.Part(event.w) repeater.Part(event.sender)
if repeater.Listeners() == 0 { if repeater.Listeners() == 0 {
delete(b.entries, event.name) delete(b.entries, event.name)
} }
@ -87,6 +95,6 @@ func (b Book) loop() {
log.Println("WARN: Sending to an empty channel:", event.name) log.Println("WARN: Sending to an empty channel:", event.name)
break break
} }
repeater.Send(event.p) repeater.Send(event.m)
} }
} }

View File

@ -1,59 +1,57 @@
package main package main
import ( import (
"bytes"
"testing" "testing"
) )
func TestBook(t *testing.T) { func TestBook(t *testing.T) {
b := NewBook() b := Book{
entries: make(map[string]*Repeater),
events: make(chan bookEvent, 5),
makeRepeater: NewTestingRepeater,
}
buf1 := bytes.NewBufferString("buf1") c1 := NewTestingClient(t)
b.Join("moo", buf1) b.Join("moo", c1)
b.loop() b.loop()
if len(b.entries) != 1 { if len(b.entries) != 1 {
t.Error("Wrong number of entries") t.Error("Wrong number of entries")
} }
c1.Expect(1)
// Send to an empty channel // Send to an empty channel
b.Send("merf", []byte("goober")) m := Message{0, 0, []uint16{22, 33}}
b.Send("merf", m)
b.loop() b.loop()
if buf1.String() != "buf1" { if c1.Len() > 0 {
t.Error("Sending to empty channel sent to non-empty channel") t.Error("Sending to empty channel sent to non-empty channel")
} }
// Send to a non-empty channel! // Send to a non-empty channel!
b.Send("moo", []byte("goober")) b.Send("moo", m)
b.loop() b.loop()
if buf1.String() != "buf1goober" { c1.Expect(1, 22, 33)
t.Error("Sending didn't work")
}
// Join another client // Join another client
buf2 := bytes.NewBufferString("buf2") c2 := NewTestingClient(t)
b.Join("moo", buf2) b.Join("moo", c2)
b.loop() b.loop()
c1.Expect(2)
c2.Expect(2)
// Send to both // Send to both
b.Send("moo", []byte("snerk")) m.Duration = append(m.Duration, 44)
b.Send("moo", m)
b.loop() b.loop()
if buf1.String() != "buf1goobersnerk" { c1.Expect(2, 22, 33, 44)
t.Error("Send to 2-member channel busted", buf1) c2.Expect(2, 22, 33, 44)
}
if buf2.String() != "buf2snerk" {
t.Error("Send to 2-member channel busted", buf2)
}
// Part a client // Part a client
b.Part("moo", buf1) b.Part("moo", c1)
b.loop() b.loop()
c2.Expect(1)
b.Send("moo", []byte("peanut")) b.Send("moo", m)
b.loop() b.loop()
if buf1.String() != "buf1goobersnerk" { c2.Expect(1, 22, 33, 44)
t.Error("Parted channel but still getting messages", buf1)
}
if buf2.String() != "buf2snerkpeanut" {
t.Error("Someone else parting somehow messed up sends", buf2)
}
} }

View File

@ -1,49 +1,145 @@
package main package main
import ( import (
"context"
"encoding/json"
"fmt" "fmt"
"time"
"os"
"log" "log"
"net/http" "net/http"
"golang.org/x/net/websocket" "os"
"time"
"nhooyr.io/websocket"
) )
var book Book var book Book
type Client struct { const JsonProtocol = "json.vail.woozle.org"
repeaterName string const BinaryProtocol = "binary.vail.woozle.org"
// Clock defines an interface for getting the current time.
//
// We use this in testing to provide a fixed value for the current time, so we
// can still compare clocks.
type Clock interface {
Now() time.Time
} }
func (c Client) Handle(ws *websocket.Conn) { // WallClock is a Clock which provides the actual time
ws.MaxPayloadBytes = 500 type WallClock struct{}
book.Join(c.repeaterName, ws)
defer book.Part(c.repeaterName, ws)
// Tell the client what time we think it is
fmt.Fprintf(ws, "[%d]", time.Now().UnixNano() / time.Millisecond.Nanoseconds())
for { func (WallClock) Now() time.Time {
buf := make([]byte, ws.MaxPayloadBytes) return time.Now()
}
if n, err := ws.Read(buf); err != nil { // VailWebSocketConnection reads and writes Message structs
break type VailWebSocketConnection struct {
} else { *websocket.Conn
buf = buf[:n] usingJSON bool
} }
book.Send(c.repeaterName, buf) func (c *VailWebSocketConnection) Receive() (Message, error) {
var m Message
messageType, buf, err := c.Read(context.Background())
if err != nil {
return m, err
} }
if messageType == websocket.MessageText {
err = json.Unmarshal(buf, &m)
} else {
err = m.UnmarshalBinary(buf)
}
return m, err
}
func (c *VailWebSocketConnection) Send(m Message) error {
var err error
var buf []byte
var messageType websocket.MessageType
if c.usingJSON {
messageType = websocket.MessageText
buf, err = json.Marshal(m)
} else {
messageType = websocket.MessageBinary
buf, err = m.MarshalBinary()
}
if err != nil {
return err
}
return c.Write(context.Background(), messageType, buf)
} }
func ChatHandler(w http.ResponseWriter, r *http.Request) { func ChatHandler(w http.ResponseWriter, r *http.Request) {
c := Client { forwardedFor := r.Header.Get("X-Forwarded-For")
repeaterName: r.FormValue("repeater"), client := fmt.Sprintf("<%s|%s>", forwardedFor, r.RemoteAddr)
// Set up websocket
ws, err := websocket.Accept(
w, r,
&websocket.AcceptOptions{
Subprotocols: []string{JsonProtocol, BinaryProtocol},
},
)
if err != nil {
log.Println(err)
return
}
defer ws.Close(websocket.StatusInternalError, "Internal error")
// Create our Vail websocket connection for books to send to
sock := VailWebSocketConnection{
Conn: ws,
} }
// This API is confusing as hell. // websockets apparently sends a subprotocol string, so we can ignore Accept headers!
// I suspect there's a better way to do this. switch ws.Subprotocol() {
websocket.Handler(c.Handle).ServeHTTP(w, r) case JsonProtocol:
sock.usingJSON = true
case BinaryProtocol:
sock.usingJSON = false
default:
ws.Close(websocket.StatusPolicyViolation, "client must speak a vail protocol")
return
}
// Join the repeater
repeaterName := r.FormValue("repeater")
book.Join(repeaterName, &sock)
defer book.Part(repeaterName, &sock)
log.Println(client, repeaterName, "connect")
for {
// Read a packet
m, err := sock.Receive()
if err != nil {
ws.Close(websocket.StatusInvalidFramePayloadData, err.Error())
break
}
// If it's empty, skip it
if len(m.Duration) == 0 {
continue
}
// If it's wildly out of time, reject it
timeDelta := (time.Now().UnixMilli() - m.Timestamp)
if timeDelta < 0 {
timeDelta = -timeDelta
}
if timeDelta > 9999 {
log.Println(err)
ws.Close(websocket.StatusInvalidFramePayloadData, "Your clock is off by too much")
break
}
book.Send(repeaterName, m)
}
log.Println(client, repeaterName, "disconnect")
} }
func main() { func main() {
@ -57,7 +153,7 @@ func main() {
port = "8080" port = "8080"
} }
log.Println("Listening on port", port) log.Println("Listening on port", port)
err := http.ListenAndServe(":" + port, nil) err := http.ListenAndServe(":"+port, nil)
if err != nil { if err != nil {
log.Fatal(err.Error()) log.Fatal(err.Error())
} }

View File

@ -6,32 +6,49 @@ import (
"time" "time"
) )
// MessageSender can send Messages
type MessageSender interface {
Send(m Message) error
}
// MessageReceiver can receive Messages
type MessageReceiver interface {
Receive() (Message, error)
}
// MessageSocket can send and receive Messages
type MessageSocket interface {
MessageSender
MessageReceiver
}
// VailMessage is a single Vail message. // VailMessage is a single Vail message.
type Message struct { type Message struct {
// Relative time in ms of this message. // Timestamp of this message. Milliseconds since epoch.
// These timestamps need to be consistent, but the offset can be anything.
// ECMAScript `performance.now()` is ideal.
Timestamp int64 Timestamp int64
// Number of connected clients
Clients uint16
// Message timing in ms. // Message timing in ms.
// Timings alternate between tone and silence. // Timings alternate between tone and silence.
// For example, `A` could be sent as [80, 80, 240] // For example, `A` could be sent as [80, 80, 240]
Duration []uint8 Duration []uint16
} }
func NewMessage(ts time.Time, durations []time.Duration) Message { func NewMessage(ts time.Time, durations ...time.Duration) Message {
msg := Message{ msg := Message{
Timestamp: ts.UnixNano() / time.Millisecond.Nanoseconds(), Timestamp: ts.UnixNano() / time.Millisecond.Nanoseconds(),
Duration: make([]uint8, len(durations)), Duration: make([]uint16, len(durations)),
} }
for i, dns := range durations { for i, dns := range durations {
ms := dns.Milliseconds() ms := dns.Milliseconds()
if (ms > 255) { if ms > 255 {
ms = 255 ms = 255
} else if (ms < 0) { } else if ms < 0 {
ms = 0 ms = 0
} }
msg.Duration[i] = uint8(ms) msg.Duration[i] = uint16(ms)
} }
return msg return msg
} }
@ -42,20 +59,26 @@ func (m Message) MarshalBinary() ([]byte, error) {
if err := binary.Write(&w, binary.BigEndian, m.Timestamp); err != nil { if err := binary.Write(&w, binary.BigEndian, m.Timestamp); err != nil {
return nil, err return nil, err
} }
if err := binary.Write(&w, binary.BigEndian, m.Clients); err != nil {
return nil, err
}
if err := binary.Write(&w, binary.BigEndian, m.Duration); err != nil { if err := binary.Write(&w, binary.BigEndian, m.Duration); err != nil {
return nil, err return nil, err
} }
return w.Bytes(), nil return w.Bytes(), nil
} }
// Unmarshaling presumes something else is keeping track of lengths // UnmarshalBinary unpacks a binary buffer into a Message.
func (m *Message) UnmarshalBinary(data []byte) error { func (m *Message) UnmarshalBinary(data []byte) error {
r := bytes.NewReader(data) r := bytes.NewReader(data)
if err := binary.Read(r, binary.BigEndian, &m.Timestamp); err != nil { if err := binary.Read(r, binary.BigEndian, &m.Timestamp); err != nil {
return err return err
} }
dlen := r.Len() if err := binary.Read(r, binary.BigEndian, &m.Clients); err != nil {
m.Duration = make([]uint8, dlen) return err
}
dlen := r.Len() / 2
m.Duration = make([]uint16, dlen)
if err := binary.Read(r, binary.BigEndian, &m.Duration); err != nil { if err := binary.Read(r, binary.BigEndian, &m.Duration); err != nil {
return err return err
} }
@ -66,16 +89,16 @@ func (m Message) Equal(m2 Message) bool {
if m.Timestamp != m2.Timestamp { if m.Timestamp != m2.Timestamp {
return false return false
} }
if len(m.Duration) != len(m2.Duration) { if len(m.Duration) != len(m2.Duration) {
return false return false
} }
for i := range m.Duration { for i := range m.Duration {
if m.Duration[i] != m2.Duration[i] { if m.Duration[i] != m2.Duration[i] {
return false return false
} }
} }
return true return true
} }

View File

@ -6,9 +6,9 @@ import (
"time" "time"
) )
func TestMessage(t *testing.T) { func TestMessageStruct(t *testing.T) {
m := Message{0x1122334455, []uint8{0xaa, 0xbb, 0xcc}} m := Message{0x1122334455, 0, []uint16{0xaa, 0xbb, 0xcc}}
m2 := Message{12, []uint8{1}} m2 := Message{12, 0, []uint16{1}}
if !m.Equal(m) { if !m.Equal(m) {
t.Error("Equal messages did not compare equal") t.Error("Equal messages did not compare equal")
@ -16,7 +16,7 @@ func TestMessage(t *testing.T) {
if m.Equal(m2) { if m.Equal(m2) {
t.Error("Unequal messages compared equal") t.Error("Unequal messages compared equal")
} }
if m.Equal(Message{m.Timestamp, []uint8{1, 2, 3}}) { if m.Equal(Message{m.Timestamp, 0, []uint16{1, 2, 3}}) {
t.Error("Messages with different payloads compared equal") t.Error("Messages with different payloads compared equal")
} }
@ -24,7 +24,7 @@ func TestMessage(t *testing.T) {
if err != nil { if err != nil {
t.Error(err) t.Error(err)
} }
if !bytes.Equal(bm, []byte("\x00\x00\x00\x11\x22\x33\x44\x55\xaa\xbb\xcc")) { if !bytes.Equal(bm, []byte("\x00\x00\x00\x11\x22\x33\x44\x55\x00\x00\x00\xaa\x00\xbb\x00\xcc")) {
t.Error("Encoded wrong:", bm) t.Error("Encoded wrong:", bm)
} }
@ -40,11 +40,9 @@ func TestMessage(t *testing.T) {
0, 0,
m.Timestamp*time.Millisecond.Nanoseconds(), m.Timestamp*time.Millisecond.Nanoseconds(),
), ),
[]time.Duration{ time.Duration(m.Duration[0])*time.Millisecond,
time.Duration(m.Duration[0]) * time.Millisecond, time.Duration(m.Duration[1])*time.Millisecond,
time.Duration(m.Duration[1]) * time.Millisecond, time.Duration(m.Duration[2])*time.Millisecond,
time.Duration(m.Duration[2]) * time.Millisecond,
},
) )
if !m.Equal(m3) { if !m.Equal(m3) {
t.Error("NewMessage didn't work", m, m3) t.Error("NewMessage didn't work", m, m3)

View File

@ -1,40 +1,56 @@
package main package main
import ( import (
"io" "time"
) )
// A Repeater is just a list of Writers. // A Repeater is just a list of senders.
type Repeater struct { type Repeater struct {
writers []io.Writer clock Clock
senders []MessageSender
} }
// NewRepeater returns a newly-created repeater
func NewRepeater() *Repeater { func NewRepeater() *Repeater {
return &Repeater{ return &Repeater{
writers: make([]io.Writer, 0, 20), clock: WallClock{},
senders: make([]MessageSender, 0, 20),
} }
} }
func (r *Repeater) Join(w io.Writer) { // Join joins a writer to this repeater
r.writers = append(r.writers, w) func (r *Repeater) Join(sender MessageSender) {
r.senders = append(r.senders, sender)
r.SendMessage()
} }
func (r *Repeater) Part(w io.Writer) { // Part removes a writer from this repeater
for i, s := range r.writers { func (r *Repeater) Part(sender MessageSender) {
if s == w { for i, s := range r.senders {
nsubs := len(r.writers) if s == sender {
r.writers[i] = r.writers[nsubs-1] nsubs := len(r.senders)
r.writers = r.writers[:nsubs-1] r.senders[i] = r.senders[nsubs-1]
r.senders = r.senders[:nsubs-1]
} }
} }
r.SendMessage()
} }
func (r *Repeater) Send(p []byte) { // Send send a message to all connected clients
for _, s := range r.writers { func (r *Repeater) Send(m Message) {
s.Write(p) m.Clients = uint16(r.Listeners())
for _, s := range r.senders {
s.Send(m)
} }
} }
func (r *Repeater) Listeners() int { // SendMessage constructs and sends a message
return len(r.writers) func (r *Repeater) SendMessage(durations ...time.Duration) {
m := NewMessage(r.clock.Now(), durations...)
r.Send(m)
}
// Listeners returns the number of connected clients
func (r *Repeater) Listeners() int {
return len(r.senders)
} }

View File

@ -1,39 +1,85 @@
package main package main
import ( import (
"bytes"
"testing" "testing"
"time"
) )
func TestRepeater(t *testing.T) { type FakeClock struct{}
r := NewRepeater()
buf1 := bytes.NewBufferString("buf1") func (f FakeClock) Now() time.Time {
r.Join(buf1) return time.UnixMilli(0)
if r.Listeners() != 1 { }
t.Error("Joining did nothing")
}
r.Send([]byte("moo"))
if buf1.String() != "buf1moo" {
t.Error("Client 1 not repeating", buf1)
}
buf2 := bytes.NewBufferString("buf2") type TestingClient struct {
r.Join(buf2) buf []Message
r.Send([]byte("bar")) expected []Message
if buf1.String() != "buf1moobar" { t *testing.T
t.Error("Client 1 not repeating", buf1) }
}
if buf2.String() != "buf2bar" {
t.Error("Client 2 not repeating", buf2)
}
r.Part(buf1) func NewTestingClient(t *testing.T) *TestingClient {
r.Send([]byte("baz")) return &TestingClient{
if buf1.String() != "buf1moobar" { t: t,
t.Error("Client 1 still getting data after part", buf1) }
} }
if buf2.String() != "buf2barbaz" {
t.Error("Client 2 not getting data after part", buf2) func (tc *TestingClient) Send(m Message) error {
tc.buf = append(tc.buf, m)
return nil
}
func (tc *TestingClient) Len() int {
return len(tc.buf)
}
func (tc *TestingClient) Expect(clients uint16, payload ...uint16) {
m := Message{0, clients, payload}
tc.expected = append(tc.expected, m)
if len(tc.buf) != len(tc.expected) {
tc.t.Errorf("Client buffer mismatch. Wanted length %d, got length %d", len(tc.expected), len(tc.buf))
}
for i := 0; i < len(tc.buf); i++ {
if !tc.buf[i].Equal(tc.expected[i]) {
tc.t.Errorf("Client buffer mismatch at entry %d. Wanted %#v, got %#v", i, tc.expected[i], tc.buf[i])
}
}
tc.buf = []Message{}
tc.expected = []Message{}
}
func NewTestingRepeater() *Repeater {
return &Repeater{
clock: FakeClock{},
senders: make([]MessageSender, 0, 2),
}
}
func TestRepeater(t *testing.T) {
r := NewTestingRepeater()
c1 := NewTestingClient(t)
r.Join(c1)
c1.Expect(1)
r.SendMessage(15 * time.Millisecond)
c1.Expect(1, 15)
c2 := NewTestingClient(t)
r.Join(c2)
c1.Expect(2)
c2.Expect(2)
r.SendMessage(58 * time.Millisecond)
c1.Expect(2, 58)
c2.Expect(2, 58)
r.Part(c1)
c2.Expect(1)
r.SendMessage(5 * time.Millisecond)
c2.Expect(1, 5)
if c1.Len() > 0 {
t.Error("Client 1 still getting data after part")
} }
} }

5
go.mod
View File

@ -2,4 +2,7 @@ module github.com/nealey/vail
go 1.12 go 1.12
require golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5 require (
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd // indirect
nhooyr.io/websocket v1.8.7
)

70
go.sum
View File

@ -1,6 +1,66 @@
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5 h1:WQ8q63x+f/zpC8Ac1s9wLElVoHhm32p6tudrU72n1QA= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.6.3 h1:ahKqKTFpO5KTPHxWZjEdPScmYaGtLo8Y4DMHoEsnp14=
github.com/gin-gonic/gin v1.6.3/go.mod h1:75u5sXoLsGZoRN5Sgbi1eraJ4GU3++wFwWzhwvtwp4M=
github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.13.0 h1:HyWk6mgj5qFqCT5fjGBuRArbVDfE4hi8+e8ceBS/t7Q=
github.com/go-playground/locales v0.13.0/go.mod h1:taPMhCMXrRLJO55olJkUXHZBHCxTMfnGwq/HNwmWNS8=
github.com/go-playground/universal-translator v0.17.0 h1:icxd5fm+REJzpZx7ZfpaD876Lmtgy7VtROAbHHXk8no=
github.com/go-playground/universal-translator v0.17.0/go.mod h1:UkSxE5sNxxRwHyU+Scu5vgOQjsIJAF8j9muTVoKLVtA=
github.com/go-playground/validator/v10 v10.2.0 h1:KgJ0snyC2R9VXYN2rneOtQcw5aHQB1Vv0sFl1UcHBOY=
github.com/go-playground/validator/v10 v10.2.0/go.mod h1:uOYAAleCW8F/7oMFd6aG0GOhaH6EGOAJShg8Id5JGkI=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee h1:s+21KNqlpePfkah2I+gwHF8xmJWRjooY+5248k6m4A0=
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0 h1:QEmUOlnSjWtnpRGHF3SauEiOsy82Cup83Vf2LcMlnc8=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2 h1:CoAavW/wd/kulfZmSIBt6p24n4j7tHgNVCjsfHVNUbo=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5 h1:F768QJ1E9tib+q5Sc8MkdJi1RxLTbRcTf8LJV56aRls=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
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/json-iterator/go v1.1.9 h1:9yzud/Ht36ygwatGx56VwCZtlI/2AD15T1X2sjSuGns=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8=
github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/leodido/go-urn v1.2.0 h1:hpXL4XnriNwQ/ABnpepYM/1vCLWNDfUNts8dX3xTG6Y=
github.com/leodido/go-urn v1.2.0/go.mod h1:+8+nEpDfqqsY+g338gtMEUOtuK+4dEMhiQEgxpxOKII=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742 h1:Esafd1046DLDQ0W1YjYsBW+p8U2u7vzgW2SQVmlNazg=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
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/ugorji/go v1.1.7 h1:/68gy2h+1mWMrwZFeD1kQialdSzAb432dtpeJ42ovdo=
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
github.com/ugorji/go/codec v1.1.7 h1:2SvQaVZ1ouYrrKKwoSk2pzd4A9evlKJb9oTL+OaLUSs=
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g=
nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=

View File

@ -46,7 +46,7 @@
<div class="level-right"> <div class="level-right">
<div class="level-item"> <div class="level-item">
<!-- This appears as a little light that turns on when someone's sending --> <!-- This appears as a little light that turns on when someone's sending -->
<span class="tag" id="recv"> <span class="tag recv-lamp">
<output class="has-text-info" id="note"></output> <output class="has-text-info" id="note"></output>
<i class="mdi mdi-volume-off" id="muted"></i> <i class="mdi mdi-volume-off" id="muted"></i>
</span> </span>

View File

@ -2,9 +2,14 @@ class Input {
constructor(keyer) { constructor(keyer) {
this.keyer = keyer this.keyer = keyer
} }
SetDitDuration(delay) { SetDitDuration(delay) {
// Nothing // Nothing
} }
SetKeyerMode(mode) {
// Nothing
}
} }
export class HTML extends Input{ export class HTML extends Input{
@ -103,6 +108,8 @@ export class Keyboard extends Input{
export class MIDI extends Input{ export class MIDI extends Input{
constructor(keyer) { constructor(keyer) {
super(keyer) super(keyer)
this.ditDuration = 100
this.keyerMode = 0
this.midiAccess = {outputs: []} // stub while we wait for async stuff this.midiAccess = {outputs: []} // stub while we wait for async stuff
if (navigator.requestMIDIAccess) { if (navigator.requestMIDIAccess) {
@ -117,12 +124,28 @@ export class MIDI extends Input{
this.midiStateChange() this.midiStateChange()
} }
SetIntervalDuration(delay) { sendState() {
// Send the Vail adapter the current iambic delay setting
for (let output of this.midiAccess.outputs.values()) { for (let output of this.midiAccess.outputs.values()) {
// MIDI only supports 7-bit values, so we have to divide it by two // Turn off keyboard mode
output.send([0x8B, 0x01, delay/2]) output.send([0xB0, 0x00, 0x00])
// MIDI only supports 7-bit values, so we have to divide ditduration by two
output.send([0xB0, 0x01, this.ditDuration/2])
// Send keyer mode
output.send([0xC0, this.keyerMode])
} }
}
SetDitDuration(duration) {
this.ditDuration = duration
this.sendState()
}
SetKeyerMode(mode) {
this.keyerMode = mode
this.sendState()
} }
midiStateChange(event) { midiStateChange(event) {
@ -135,9 +158,7 @@ export class MIDI extends Input{
} }
// Tell the Vail adapter to disable keyboard events: we can do MIDI! // Tell the Vail adapter to disable keyboard events: we can do MIDI!
for (let output of this.midiAccess.outputs.values()) { this.sendState()
output.send([0x8B, 0x00, 0x00]) // Turn off keyboard mode
}
} }
midiMessage(event) { midiMessage(event) {
@ -229,16 +250,36 @@ export class Gamepad extends Input{
} }
} }
/** class Collection {
* Set up all input methods constructor(keyer) {
* this.html =new HTML(keyer)
* @param keyer Keyer object for everyone to use this.keyboard =new Keyboard(keyer)
*/ this.midi =new MIDI(keyer)
export function SetupAll(keyer) { this.gamepad =new Gamepad(keyer)
return { this.collection = [this.html, this.keyboard, this.midi, this.gamepad]
HTML: new HTML(keyer), }
Keyboard: new Keyboard(keyer),
MIDI: new MIDI(keyer), /**
Gamepad: new Gamepad(keyer), * Set duration of all inputs
*
* @param duration Duration to set
*/
SetDitDuration(duration) {
for (let e of this.collection) {
e.SetDitDuration(duration)
}
}
/**
* Set keyer mode of all inputs
*
* @param mode Keyer mode to set
*/
SetKeyerMode(mode) {
for (let e of this.collection) {
e.SetKeyerMode(mode)
}
} }
} }
export {Collection}

View File

@ -53,10 +53,17 @@ class QSet extends Set {
} }
/** /**
* A callback to start or stop transmission * Definition of a transmitter type.
* *
* @callback TxControl * The VailClient class implements this.
*/ */
class Transmitter {
/** Begin transmitting */
BeginTx() {}
/** End transmitting */
EndTx() {}
}
/** /**
* A straight keyer. * A straight keyer.
@ -67,12 +74,10 @@ class QSet extends Set {
*/ */
class StraightKeyer { class StraightKeyer {
/** /**
* @param {TxControl} beginTxFunc Callback to begin transmitting * @param {Transmitter} output Transmitter object
* @param {TxControl} endTxFunc Callback to end transmitting
*/ */
constructor(beginTxFunc, endTxFunc) { constructor(output) {
this.beginTxFunc = beginTxFunc this.output = output
this.endTxFunc = endTxFunc
this.Reset() this.Reset()
} }
@ -89,7 +94,7 @@ class StraightKeyer {
* Reset state and stop all transmissions. * Reset state and stop all transmissions.
*/ */
Reset() { Reset() {
this.endTxFunc() this.output.EndTx()
this.txRelays = [] this.txRelays = []
} }
@ -140,9 +145,9 @@ class StraightKeyer {
if (wasClosed != nowClosed) { if (wasClosed != nowClosed) {
if (nowClosed) { if (nowClosed) {
this.beginTxFunc() this.output.BeginTx()
} else { } else {
this.endTxFunc() this.output.EndTx()
} }
} }
} }
@ -471,6 +476,19 @@ const Keyers = {
robo: RoboKeyer.Keyer, robo: RoboKeyer.Keyer,
} }
export { const Numbers = {
Keyers, straight: 1,
cootie: 1,
bug: 2,
elbug: 3,
singledot: 4,
ultimatic: 5,
iambic: 6,
iambica: 7,
iambicb: 8,
keyahead: 9,
}
export {
Keyers, Numbers,
} }

View File

@ -27,7 +27,9 @@ const Second = 1000 * Millisecond
const OscillatorRampDuration = 5*Millisecond const OscillatorRampDuration = 5*Millisecond
console.warn("Chrome will now complain about an AudioContext not being allowed to start. This is normal, and there is no way to make Chrome stop complaining about this.") console.warn("Chrome will now complain about an AudioContext not being allowed to start. This is normal, and there is no way to make Chrome stop complaining about this.")
const BuzzerAudioContext = new AudioContext() const BuzzerAudioContext = new AudioContext({
latencyHint: 0,
})
/** /**
* Compute the special "Audio Context" time * Compute the special "Audio Context" time
* *
@ -42,13 +44,6 @@ function BuzzerAudioContextTime(when) {
return Math.max(when - acOffset, 0) / Second return Math.max(when - acOffset, 0) / Second
} }
/**
* Block until the audio system is able to start making noise.
*/
async function Ready() {
await BuzzerAudioContext.resume()
}
class Oscillator { class Oscillator {
/** /**
* Create a new oscillator, and encase it in a Gain for control. * Create a new oscillator, and encase it in a Gain for control.
@ -142,6 +137,10 @@ class Sample {
* A (mostly) virtual class defining a buzzer. * A (mostly) virtual class defining a buzzer.
*/ */
class Buzzer { class Buzzer {
constructor() {
this.connected = true
}
/** /**
* Signal an error * Signal an error
*/ */
@ -155,7 +154,7 @@ class Buzzer {
* @param {boolean} tx Transmit or receive tone * @param {boolean} tx Transmit or receive tone
* @param {number} when Time to begin, in ms (0=now) * @param {number} when Time to begin, in ms (0=now)
*/ */
Buzz(tx, when=0) { async Buzz(tx, when=0) {
console.log("Buzz", tx, when) console.log("Buzz", tx, when)
} }
@ -165,7 +164,7 @@ class Buzzer {
* @param {boolean} tx Transmit or receive tone * @param {boolean} tx Transmit or receive tone
* @param {number} when Time to end, in ms (0=now) * @param {number} when Time to end, in ms (0=now)
*/ */
Silence(tx, when=0) { async Silence(tx, when=0) {
console.log("Silence", tx, when) console.log("Silence", tx, when)
} }
@ -180,6 +179,15 @@ class Buzzer {
this.Buzz(tx, when) this.Buzz(tx, when)
this.Silence(tx, when + duration) this.Silence(tx, when + duration)
} }
/**
* Set the "connectedness" indicator.
*
* @param {boolean} connected True if connected
*/
SetConnected(connected) {
this.connected = connected
}
} }
class AudioBuzzer extends Buzzer { class AudioBuzzer extends Buzzer {
@ -210,6 +218,10 @@ class ToneBuzzer extends AudioBuzzer {
this.rxOsc = new Oscillator(lowFreq, txGain) this.rxOsc = new Oscillator(lowFreq, txGain)
this.txOsc = new Oscillator(highFreq, txGain) this.txOsc = new Oscillator(highFreq, txGain)
// Keep the speaker going always. This keeps the browser from "swapping out" our audio context.
this.bgOsc = new Oscillator(1, 0.001)
this.bgOsc.SoundAt()
} }
/** /**
@ -218,7 +230,7 @@ class ToneBuzzer extends AudioBuzzer {
* @param {boolean} tx Transmit or receive tone * @param {boolean} tx Transmit or receive tone
* @param {number} when Time to begin, in ms (0=now) * @param {number} when Time to begin, in ms (0=now)
*/ */
Buzz(tx, when = null) { async Buzz(tx, when = null) {
let osc = tx?this.txOsc:this.rxOsc let osc = tx?this.txOsc:this.rxOsc
osc.SoundAt(when) osc.SoundAt(when)
} }
@ -229,7 +241,7 @@ class ToneBuzzer extends AudioBuzzer {
* @param {boolean} tx Transmit or receive tone * @param {boolean} tx Transmit or receive tone
* @param {number} when Time to end, in ms (0=now) * @param {number} when Time to end, in ms (0=now)
*/ */
Silence(tx, when = null) { async Silence(tx, when = null) {
let osc = tx?this.txOsc:this.rxOsc let osc = tx?this.txOsc:this.rxOsc
osc.HushAt(when) osc.HushAt(when)
} }
@ -249,7 +261,7 @@ class TelegraphBuzzer extends AudioBuzzer{
this.openSample = new Sample("telegraph-b.mp3") this.openSample = new Sample("telegraph-b.mp3")
} }
Buzz(tx, when=0) { async Buzz(tx, when=0) {
if (tx) { if (tx) {
this.hum.SoundAt(when) this.hum.SoundAt(when)
} else { } else {
@ -257,7 +269,7 @@ class TelegraphBuzzer extends AudioBuzzer{
} }
} }
Silence(tx ,when=0) { async Silence(tx ,when=0) {
if (tx) { if (tx) {
this.hum.HushAt(when) this.hum.HushAt(when)
} else { } else {
@ -266,29 +278,196 @@ class TelegraphBuzzer extends AudioBuzzer{
} }
} }
class Lamp extends Buzzer { class LampBuzzer extends Buzzer {
constructor(element) { constructor() {
super() super()
this.element = element this.elements = document.querySelectorAll(".recv-lamp")
} }
Buzz(tx, when=0) { async Buzz(tx, when=0) {
if (tx) return if (tx) return
let ms = when?when - Date.now():0 let ms = when?when - Date.now():0
setTimeout( setTimeout(
() =>{ () =>{
this.element.classList.add("rx") for (let e of this.elements) {
e.classList.add("rx")
}
}, },
ms, ms,
) )
} }
Silence(tx, when=0) { async Silence(tx, when=0) {
if (tx) return if (tx) return
let ms = when?when - Date.now():0 let ms = when?when - Date.now():0
setTimeout(() => this.element.classList.remove("rx"), ms) setTimeout(
() => {
for (let e of this.elements) {
e.classList.remove("rx")
}
},
ms,
)
}
SetConnected(connected) {
for (let e of this.elements) {
if (connected) {
e.classList.add("connected")
} else {
e.classList.remove("connected")
}
}
} }
} }
export {Ready, ToneBuzzer, TelegraphBuzzer, Lamp} class MIDIBuzzer extends Buzzer {
constructor() {
super()
this.SetNote(69) // A4; 440Hz
this.midiAccess = {outputs: []} // stub while we wait for async stuff
if (navigator.requestMIDIAccess) {
this.midiInit()
}
}
async midiInit(access) {
this.outputs = new Set()
this.midiAccess = await navigator.requestMIDIAccess()
this.midiAccess.addEventListener("statechange", e => this.midiStateChange(e))
this.midiStateChange()
}
midiStateChange(event) {
let newOutputs = new Set()
for (let output of this.midiAccess.outputs.values()) {
if ((output.state != "connected") || (output.name.includes("Through"))) {
continue
}
newOutputs.add(output)
}
this.outputs = newOutputs
}
sendAt(when, message) {
let ms = when?when - Date.now():0
setTimeout(
() => {
for (let output of this.outputs) {
output.send(message)
}
},
ms,
)
}
async Buzz(tx, when=0) {
if (tx) {
return
}
this.sendAt(when, [0x90, this.note, 0x7f])
}
async Silence(tx, when=0) {
if (tx) {
return
}
this.sendAt(when, [0x80, this.note, 0x7f])
}
/*
* Set note to transmit
*/
SetNote(tx, note) {
if (tx) {
return
}
this.note = note
}
}
/**
* Block until the audio system is able to start making noise.
*/
async function AudioReady() {
await BuzzerAudioContext.resume()
}
class Collection {
constructor() {
this.tone = new ToneBuzzer()
this.telegraph = new TelegraphBuzzer()
this.lamp = new LampBuzzer()
this.midi = new MIDIBuzzer()
this.collection = new Set([this.tone, this.lamp, this.midi])
}
/**
* Set the audio output type.
*
* @param {string} audioType "telegraph" for telegraph mode, otherwise tone mode
*/
SetAudioType(audioType) {
this.collection.delete(this.telegraph)
this.collection.delete(this.tone)
if (audioType == "telegraph") {
this.collection.add(this.telegraph)
} else {
this.collection.add(this.tone)
}
}
/**
* Buzz all outputs.
*
* @param tx True if transmitting
*/
Buzz(tx=False) {
for (let b of this.collection) {
b.Buzz(tx)
}
}
/**
* Silence all outputs.
*
* @param tx True if transmitting
*/
Silence(tx=false) {
for (let b of this.collection) {
b.Silence(tx)
}
}
/**
* Buzz for a certain duration at a certain time
*
* @param tx True if transmitting
* @param when Time to begin
* @param duration How long to buzz
*/
BuzzDuration(tx, when, duration) {
for (let b of this.collection) {
b.BuzzDuration(tx, when, duration)
}
}
/**
* Update the "connected" status display.
*
* For example, turn the receive light to black if the repeater is not connected.
*
* @param {boolean} connected True if we are "connected"
*/
SetConnected(connected) {
for (let b of this.collection) {
b.SetConnected(connected)
}
}
}
export {AudioReady, Collection}

View File

@ -4,6 +4,25 @@ const Millisecond = 1
const Second = 1000 * Millisecond const Second = 1000 * Millisecond
const Minute = 60 * Second const Minute = 60 * Second
/**
* Compare two messages
*
* @param {Object} m1 First message
* @param {Object} m2 Second message
* @returns {Boolean} true if messages are equal
*/
function MessageEqual(m1, m2) {
if ((m1.Timestamp != m2.Timestamp) || (m1.Duration.length != m2.Duration.length)) {
return false
}
for (let i=0; i < m1.Duration.length; i++) {
if (m1.Duration[i] != m2.Duration[i]) {
return false
}
}
return true
}
export class Vail { export class Vail {
constructor(rx, name) { constructor(rx, name) {
this.rx = rx this.rx = rx
@ -11,6 +30,7 @@ export class Vail {
this.lagDurations = [] this.lagDurations = []
this.sent = [] this.sent = []
this.wantConnected = true this.wantConnected = true
this.connected = false
this.wsUrl = new URL("chat", window.location) this.wsUrl = new URL("chat", window.location)
this.wsUrl.protocol = this.wsUrl.protocol.replace("http", "ws") this.wsUrl.protocol = this.wsUrl.protocol.replace("http", "ws")
@ -24,18 +44,25 @@ export class Vail {
if (!this.wantConnected) { if (!this.wantConnected) {
return return
} }
this.rx(0, 0, {connected: false})
console.info("Attempting to reconnect", this.wsUrl.href) console.info("Attempting to reconnect", this.wsUrl.href)
this.clockOffset = 0 this.clockOffset = 0
this.socket = new WebSocket(this.wsUrl) this.socket = new WebSocket(this.wsUrl, ["json.vail.woozle.org"])
this.socket.addEventListener("message", e => this.wsMessage(e)) this.socket.addEventListener("message", e => this.wsMessage(e))
this.socket.addEventListener("close", () => this.reopen()) this.socket.addEventListener(
} "open",
msg => {
stats() { this.connected = true
return { this.rx(0, 0, {connected: true})
averageLag: this.lagDurations.reduce((a,b) => (a+b), 0) / this.lagDurations.length, }
clockOffset: this.clockOffset, )
} this.socket.addEventListener(
"close",
msg => {
console.error("Repeater connection dropped:", msg.reason)
setTimeout(() => this.reopen(), 2*Second)
}
)
} }
wsMessage(event) { wsMessage(event) {
@ -46,48 +73,56 @@ export class Vail {
msg = JSON.parse(jmsg) msg = JSON.parse(jmsg)
} }
catch (err) { catch (err) {
console.error(err, jmsg) console.error(jmsg)
return
}
let stats = {
averageLag: this.lagDurations.reduce((a,b) => (a+b), 0) / this.lagDurations.length,
clockOffset: this.clockOffset,
clients: msg.Clients,
connected: this.connected,
}
if (typeof(msg) == "string") {
console.error(msg)
return return
} }
let beginTxTime = msg[0] // XXX: Why is this happening?
let durations = msg.slice(1) if (msg.Timestamp == 0) {
// Why is this happening?
if (beginTxTime == 0) {
return return
} }
let sent = this.sent.filter(e => e != jmsg) let sent = this.sent.filter(m => !MessageEqual(msg, m))
if (sent.length < this.sent.length) { if (sent.length < this.sent.length) {
// We're getting our own message back, which tells us our lag. // We're getting our own message back, which tells us our lag.
// We shouldn't emit a tone, though. // We shouldn't emit a tone, though.
let totalDuration = durations.reduce((a, b) => a + b) let totalDuration = msg.Duration.reduce((a, b) => a + b)
this.sent = sent this.sent = sent
this.lagDurations.unshift(now - this.clockOffset - beginTxTime - totalDuration) this.lagDurations.unshift(now - this.clockOffset - msg.Timestamp - totalDuration)
this.lagDurations.splice(20, 2) this.lagDurations.splice(20, 2)
this.rx(0, 0, this.stats()) this.rx(0, 0, stats)
return return
} }
// The very first packet is the server telling us the current time // Packets with 0 length tell us what time the server thinks it is,
if (durations.length == 0) { // and how many clients are connected
if (msg.Duration.length == 0) {
if (this.clockOffset == 0) { if (this.clockOffset == 0) {
this.clockOffset = now - beginTxTime this.clockOffset = now - msg.Timestamp
this.rx(0, 0, this.stats()) this.rx(0, 0, stats)
} }
return return
} }
// Adjust playback time to clock offset // Adjust playback time to clock offset
let adjustedTxTime = beginTxTime + this.clockOffset let adjustedTxTime = msg.Timestamp + this.clockOffset
// Every second value is a silence duration // Every second value is a silence duration
let tx = true let tx = true
for (let duration of durations) { for (let duration of msg.Duration) {
duration = Number(duration) duration = Number(duration)
if (tx && (duration > 0)) { if (tx && (duration > 0)) {
this.rx(adjustedTxTime, duration, this.stats()) this.rx(adjustedTxTime, duration, stats)
} }
adjustedTxTime = Number(adjustedTxTime) + duration adjustedTxTime = Number(adjustedTxTime) + duration
tx = !tx tx = !tx
@ -97,13 +132,17 @@ export class Vail {
/** /**
* Send a transmission * Send a transmission
* *
* @param {number} time When to play this transmission * @param {number} timestamp When to play this transmission
* @param {number} duration How long the transmission is * @param {number} duration How long the transmission is
* @param {boolean} squelch True to mute this tone when we get it back from the repeater * @param {boolean} squelch True to mute this tone when we get it back from the repeater
*/ */
Transmit(time, duration, squelch=true) { Transmit(timestamp, duration, squelch=true) {
let msg = [time - this.clockOffset, duration] let msg = {
Timestamp: timestamp,
Duration: [duration],
}
let jmsg = JSON.stringify(msg) let jmsg = JSON.stringify(msg)
if (this.socket.readyState != 1) { if (this.socket.readyState != 1) {
// If we aren't connected, complain. // If we aren't connected, complain.
console.error("Not connected, dropping", jmsg) console.error("Not connected, dropping", jmsg)
@ -111,7 +150,7 @@ export class Vail {
} }
this.socket.send(jmsg) this.socket.send(jmsg)
if (squelch) { if (squelch) {
this.sent.push(jmsg) this.sent.push(msg)
} }
} }
@ -122,13 +161,14 @@ export class Vail {
} }
export class Null { export class Null {
constructor(rx) { constructor(rx, interval=3*Second) {
this.rx = rx this.rx = rx
this.interval = setInterval(() => this.pulse(), 3 * Second) this.interval = setInterval(() => this.pulse(), interval)
this.pulse()
} }
pulse() { pulse() {
this.rx(0, 0, {note: "local"}) this.rx(0, 0, {note: "local", connected: false})
} }
Transmit(time, duration, squelch=true) { Transmit(time, duration, squelch=true) {
@ -139,51 +179,41 @@ export class Null {
} }
} }
export class Echo { export class Echo extends Null {
constructor(rx, delay=0) { constructor(rx, delay=0) {
this.rx = rx super(rx)
this.delay = delay this.delay = delay
this.Transmit(0, 0)
} }
Transmit(time, duration, squelch=true) { Transmit(time, duration, squelch=true) {
this.rx(time + this.delay, duration, {note: "local"}) this.rx(time + this.delay, duration, {note: "local"})
} }
Close() {
}
} }
export class Fortune { export class Fortune extends Null {
/** /**
* *
* @param rx Receive callback * @param rx Receive callback
* @param {Keyer} keyer Keyer object * @param {Keyer} keyer Keyer object
*/ */
constructor(rx, keyer) { constructor(rx, keyer) {
this.rx = rx super(rx, 1*Minute)
this.keyer = keyer this.keyer = keyer
this.interval = setInterval(() => this.pulse(), 1 * Minute)
this.pulse() this.pulse()
} }
pulse() { pulse() {
this.rx(0, 0, {note: "local"}) super.pulse()
if (this.keyer.Busy()) { if (!this.keyer || this.keyer.Busy()) {
return return
} }
let fortune = GetFortune() let fortune = GetFortune()
this.keyer.EnqueueAsciiString(`${fortune}\x04 `) this.keyer.EnqueueAsciiString(`${fortune} \x04 `)
}
Transmit(time, duration, squelch=true) {
// Do nothing.
} }
Close() { Close() {
this.keyer.Flush() this.keyer.Flush()
clearInterval(this.interval) super.Close()
} }
} }

View File

@ -16,8 +16,18 @@
-webkit-user-select: none; /* 2022-04-26 Safari still needs this */ -webkit-user-select: none; /* 2022-04-26 Safari still needs this */
} }
#recv.rx { .tag.recv-lamp {
background-color: #444;
color: white;
}
.tag.recv-lamp.connected {
background-color: #fec;
color: black;
}
.tag.recv-lamp.rx,
.tag.recv-lamp.connected.rx {
background-color: orange; background-color: orange;
color: black;
} }
input[type=range] { input[type=range] {
@ -83,4 +93,4 @@ code {
#charts canvas { #charts canvas {
height: 0.5em; height: 0.5em;
width: 100%; width: 100%;
} }

View File

@ -1,5 +1,5 @@
import {Keyers} from "./keyers.mjs" import * as Keyers from "./keyers.mjs"
import * as Buzzer from "./buzzer.mjs" import * as Outputs from "./outputs.mjs"
import * as Inputs from "./inputs.mjs" import * as Inputs from "./inputs.mjs"
import * as Repeaters from "./repeaters.mjs" import * as Repeaters from "./repeaters.mjs"
import * as Chart from "./chart.mjs" import * as Chart from "./chart.mjs"
@ -10,7 +10,7 @@ const Second = 1000 * Millisecond
const Minute = 60 * Second const Minute = 60 * Second
/** /**
* Pop up a message, using an notification.. * Pop up a message, using an notification.
* *
* @param {string} msg Message to display * @param {string} msg Message to display
*/ */
@ -37,23 +37,24 @@ class VailClient {
this.rxDelay = 0 * Millisecond // Time to add to incoming timestamps this.rxDelay = 0 * Millisecond // Time to add to incoming timestamps
this.beginTxTime = null // Time when we began transmitting this.beginTxTime = null // Time when we began transmitting
// Make helpers // Outputs
this.lamp = new Buzzer.Lamp(document.querySelector("#recv")) this.outputs = new Outputs.Collection()
this.buzzer = new Buzzer.ToneBuzzer()
this.straightKeyer = new Keyers.straight(() => this.beginTx(), () => this.endTx()) // Keyers
this.keyer = new Keyers.straight(() => this.beginTx(), () => this.endTx()) this.straightKeyer = new Keyers.Keyers.straight(this)
this.roboKeyer = new Keyers.robo(() => this.Buzz(), () => this.Silence()) this.keyer = new Keyers.Keyers.straight(this)
this.roboKeyer = new Keyers.Keyers.robo(() => this.Buzz(), () => this.Silence())
// Set up various input methods // Set up various input methods
// Send this as the keyer so we can intercept dit and dah events for charts // Send this as the keyer so we can intercept dit and dah events for charts
this.inputs = Inputs.SetupAll(this) this.inputs = new Inputs.Collection(this)
// Maximize button // Maximize button
for (let e of document.querySelectorAll("button.maximize")) { for (let e of document.querySelectorAll("button.maximize")) {
e.addEventListener("click", e => this.maximize(e)) e.addEventListener("click", e => this.maximize(e))
} }
for (let e of document.querySelectorAll("#ck")) { for (let e of document.querySelectorAll("#ck")) {
e.addEventListener("click", e => this.test()) e.addEventListener("click", e => this.check())
} }
for (let e of document.querySelectorAll("#reset")) { for (let e of document.querySelectorAll("#reset")) {
e.addEventListener("click", e => this.reset()) e.addEventListener("click", e => this.reset())
@ -69,9 +70,7 @@ class VailClient {
} }
this.keyer.SetDitDuration(this.ditDuration) this.keyer.SetDitDuration(this.ditDuration)
this.roboKeyer.SetDitDuration(this.ditDuration) this.roboKeyer.SetDitDuration(this.ditDuration)
for (let i of Object.values(this.inputs)) { this.inputs.SetDitDuration(this.ditDuration)
i.SetDitDuration(this.ditDuration)
}
}) })
this.inputInit("#rx-delay", e => { this.inputInit("#rx-delay", e => {
this.rxDelay = e.target.value * Second this.rxDelay = e.target.value * Second
@ -89,7 +88,7 @@ class VailClient {
this.setTimingCharts(true) this.setTimingCharts(true)
// Turn off the "muted" symbol when we can start making noise // Turn off the "muted" symbol when we can start making noise
Buzzer.Ready() Outputs.AudioReady()
.then(() => { .then(() => {
console.log("Audio context ready") console.log("Audio context ready")
document.querySelector("#muted").classList.add("is-hidden") document.querySelector("#muted").classList.add("is-hidden")
@ -117,12 +116,13 @@ class VailClient {
} }
setKeyer(keyerName) { setKeyer(keyerName) {
let newKeyerClass = Keyers[keyerName] let newKeyerClass = Keyers.Keyers[keyerName]
let newKeyerNumber = Keyers.Numbers[keyerName]
if (!newKeyerClass) { if (!newKeyerClass) {
console.error("Keyer not found", keyerName) console.error("Keyer not found", keyerName)
return return
} }
let newKeyer = new newKeyerClass(() => this.beginTx(), () => this.endTx()) let newKeyer = new newKeyerClass(this)
let i = 0 let i = 0
for (let keyName of newKeyer.KeyNames()) { for (let keyName of newKeyer.KeyNames()) {
let e = document.querySelector(`.key[data-key="${i}"]`) let e = document.querySelector(`.key[data-key="${i}"]`)
@ -132,24 +132,23 @@ class VailClient {
this.keyer.Release() this.keyer.Release()
this.keyer = newKeyer this.keyer = newKeyer
this.inputs.SetKeyerMode(newKeyerNumber)
document.querySelector("#keyer-rate").dispatchEvent(new Event("input")) document.querySelector("#keyer-rate").dispatchEvent(new Event("input"))
} }
Buzz() { Buzz() {
this.buzzer.Buzz() this.outputs.Buzz(false)
this.lamp.Buzz()
if (this.rxChart) this.rxChart.Set(1) if (this.rxChart) this.rxChart.Set(1)
} }
Silence() { Silence() {
this.buzzer.Silence() this.outputs.Silence()
this.lamp.Silence()
if (this.rxChart) this.rxChart.Set(0) if (this.rxChart) this.rxChart.Set(0)
} }
BuzzDuration(tx, when, duration) { BuzzDuration(tx, when, duration) {
this.buzzer.BuzzDuration(tx, when, duration) this.outputs.BuzzDuration(tx, when, duration)
this.lamp.BuzzDuration(tx, when, duration)
let chart = tx?this.txChart:this.rxChart let chart = tx?this.txChart:this.rxChart
if (chart) { if (chart) {
@ -163,10 +162,11 @@ class VailClient {
* *
* Called from the keyer. * Called from the keyer.
*/ */
beginTx() { BeginTx() {
this.beginTxTime = Date.now() this.beginTxTime = Date.now()
this.buzzer.Buzz(true) this.outputs.Buzz(true)
if (this.txChart) this.txChart.Set(1) if (this.txChart) this.txChart.Set(1)
} }
/** /**
@ -174,13 +174,13 @@ class VailClient {
* *
* Called from the keyer * Called from the keyer
*/ */
endTx() { EndTx() {
if (!this.beginTxTime) { if (!this.beginTxTime) {
return return
} }
let endTxTime = Date.now() let endTxTime = Date.now()
let duration = endTxTime - this.beginTxTime let duration = endTxTime - this.beginTxTime
this.buzzer.Silence(true) this.outputs.Silence(true)
this.repeater.Transmit(this.beginTxTime, duration) this.repeater.Transmit(this.beginTxTime, duration)
this.beginTxTime = null this.beginTxTime = null
if (this.txChart) this.txChart.Set(0) if (this.txChart) this.txChart.Set(0)
@ -222,10 +222,10 @@ class VailClient {
*/ */
setTelegraphBuzzer(enable) { setTelegraphBuzzer(enable) {
if (enable) { if (enable) {
this.buzzer = new Buzzer.TelegraphBuzzer() this.outputs.SetAudioType("telegraph")
toast("Telegraphs only make sound when receiving!") toast("Telegraphs only make sound when receiving!")
} else { } else {
this.buzzer = new Buzzer.ToneBuzzer() this.outputs.SetAudioType()
} }
} }
@ -281,7 +281,7 @@ class VailClient {
number = Number(numberMatch[0]) number = Number(numberMatch[0])
} }
if (name.startsWith("Fortunes")) { if (name.startsWith("Fortunesf")) {
this.roboKeyer.SetPauseMultiplier(number || 1) this.roboKeyer.SetPauseMultiplier(number || 1)
this.repeater = new Repeaters.Fortune(rx, this.roboKeyer) this.repeater = new Repeaters.Fortune(rx, this.roboKeyer)
} else if (name.startsWith("Echo")) { } else if (name.startsWith("Echo")) {
@ -343,7 +343,7 @@ class VailClient {
*/ */
error(msg) { error(msg) {
toast(msg) toast(msg)
this.buzzer.Error() this.outputs.Error()
} }
/** /**
@ -375,7 +375,10 @@ class VailClient {
let longestRxDuration = this.rxDurations.reduce((a,b) => Math.max(a,b)) let longestRxDuration = this.rxDurations.reduce((a,b) => Math.max(a,b))
let suggestedDelay = ((averageLag + longestRxDuration) * 1.2).toFixed(0) let suggestedDelay = ((averageLag + longestRxDuration) * 1.2).toFixed(0)
this.updateReading("#note", stats.note || "☁") if (stats.connected !== undefined) {
this.outputs.SetConnected(stats.connected)
}
this.updateReading("#note", stats.note || stats.clients || "😎")
this.updateReading("#lag-value", averageLag) this.updateReading("#lag-value", averageLag)
this.updateReading("#longest-rx-value", longestRxDuration) this.updateReading("#longest-rx-value", longestRxDuration)
this.updateReading("#suggested-delay-value", suggestedDelay) this.updateReading("#suggested-delay-value", suggestedDelay)
@ -416,7 +419,7 @@ class VailClient {
/** /**
* Send "CK" to server, and don't squelch the echo * Send "CK" to server, and don't squelch the echo
*/ */
test() { check() {
let when = Date.now() let when = Date.now()
let dit = this.ditDuration let dit = this.ditDuration
let dah = dit * 3 let dah = dit * 3