This commit is contained in:
Neale Pickett 2022-12-14 12:42:32 -07:00
commit dc54709732
37 changed files with 1306 additions and 581 deletions

2
.gitignore vendored
View File

@ -1 +1 @@
i18n

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.Duration(time.Now().UnixMilli()-m.Timestamp) * time.Millisecond
if timeDelta < 0 {
timeDelta = -timeDelta
}
if timeDelta > 10*time.Second {
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"
) )
// VailMessage is a single Vail message. // MessageSender can send Messages
type Message struct { type MessageSender interface {
// Relative time in ms of this message. Send(m Message) error
// These timestamps need to be consistent, but the offset can be anything.
// ECMAScript `performance.now()` is ideal.
Timestamp int64
// Message timing in ms.
// Timings alternate between tone and silence.
// For example, `A` could be sent as [80, 80, 240]
Duration []uint8
} }
func NewMessage(ts time.Time, durations []time.Duration) Message { // MessageReceiver can receive Messages
type MessageReceiver interface {
Receive() (Message, error)
}
// MessageSocket can send and receive Messages
type MessageSocket interface {
MessageSender
MessageReceiver
}
// Message is a single Vail message.
type Message struct {
// Timestamp of this message. Milliseconds since epoch.
Timestamp int64
// Number of connected clients.
Clients uint16
// Message timing in milliseconds.
// Timings alternate between tone and silence.
// For example, `A` could be sent as [80, 80, 240]
Duration []uint16
}
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")
} }
} }

6
go.mod
View File

@ -2,4 +2,8 @@ module github.com/nealey/vail
go 1.12 go 1.12
require golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5 require (
github.com/gin-gonic/gin v1.8.1 // indirect
github.com/klauspost/compress v1.15.6 // indirect
nhooyr.io/websocket v1.8.7
)

137
go.sum
View File

@ -1,6 +1,133 @@
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5 h1:WQ8q63x+f/zpC8Ac1s9wLElVoHhm32p6tudrU72n1QA= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/gin-gonic/gin v1.8.1 h1:4+fr/el88TOO3ewCmQr8cx/CtZ/umlIRIs5M4NTNjf8=
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
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/locales v0.14.0 h1:u50s323jtVGugKlcYeyzC0etD1HifMjqmJqb8WugfUU=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
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/universal-translator v0.18.0 h1:82dyy6p4OuJq4/CByFNOn/jYrnRPArHwAcmLoJZxyho=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
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/go-playground/validator/v10 v10.10.0 h1:I7mrTYv78z8k8VXa/qJlOlEXn/nBh+BF8dHX5nt/dr0=
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
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/goccy/go-json v0.9.7 h1:IcB+Aqpx/iMHu5Yooh7jEzJk1JZ7Pjtmys2ukPr7EeM=
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
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/golang/protobuf v1.5.0 h1:LUVKkCeviFUMKqHa4tXIIij/lbhnMbP7Fn5wKdKkRh4=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
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/go-cmp v0.5.5/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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.10.3 h1:OP96hzwJVBIHYU52pVTI6CczrxPvrGfgqF9N5eTO0Q8=
github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
github.com/klauspost/compress v1.15.6 h1:6D9PcO8QWu0JyaQ2zUMmu16T1T+zjjEpP91guRsvDfY=
github.com/klauspost/compress v1.15.6/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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/leodido/go-urn v1.2.1 h1:BqpAaACuzVSgi/VLzGZIobT2z4v53pjosyNd9Yv6n/w=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
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/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.0.1 h1:8e3L2cCQzLFi2CR4g7vGFuFxX7Jl1kKX8gW+iV0GUKU=
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
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/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
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/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
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 v1.2.7 h1:qYhyWUUd6WbiM+C6JZAUkIJt/1WrjzNHY9+KCIjVqTo=
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
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=
github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 h1:/UOmuWzQfxxo9UtlXMwuQU8CMgg1eZXqTRwkSQJWKOI=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
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/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069 h1:siQdpVirKtzPhKl3lZWozZraCFObP8S1v6PRp0bLrtU=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6 h1:aRYxNxv6iGQlyVaZmk6ZgYEDa+Jg18DxebPSrd6bg1M=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
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=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
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/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
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=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nhooyr.io/websocket v1.8.7 h1:usjR2uOr/zjjkVMy0lW+PPohFok7PCow5sDjLgX4P4g=
nhooyr.io/websocket v1.8.7/go.mod h1:B70DZP8IakI65RVQ51MsWP/8jndNma26DVA/nFSCgW0=

View File

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="-1 -1 15 15" xmlns="http://www.w3.org/2000/svg">
<circle cx= "6" cy="10" r="2" style="stroke: #000;"/>
<circle cx="10" cy= "6" r="2" style="stroke: #000; fill: none;"/>
<circle cx= "2" cy= "6" r="2" style="stroke: #000; fill: none;"/>
<circle cx= "6" cy= "2" r="2" style="stroke: #000; fill: none;"/>
</svg>

Before

Width:  |  Height:  |  Size: 369 B

View File

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="-1 -1 15 15" xmlns="http://www.w3.org/2000/svg">
<circle cx= "6" cy="10" r="2" style="stroke: #000; fill: none;"/>
<circle cx="10" cy= "6" r="2" style="stroke: #000;"/>
<circle cx= "2" cy= "6" r="2" style="stroke: #000; fill: none;"/>
<circle cx= "6" cy= "2" r="2" style="stroke: #000; fill: none;"/>
</svg>

Before

Width:  |  Height:  |  Size: 369 B

View File

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="-1 -1 15 15" xmlns="http://www.w3.org/2000/svg">
<circle cx= "6" cy="10" r="2" style="stroke: #000; fill: none;"/>
<circle cx="10" cy= "6" r="2" style="stroke: #000; fill: none;"/>
<circle cx= "2" cy= "6" r="2" style="stroke: #000;"/>
<circle cx= "6" cy= "2" r="2" style="stroke: #000; fill: none;"/>
</svg>

Before

Width:  |  Height:  |  Size: 369 B

View File

@ -1,7 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="-1 -1 15 15" xmlns="http://www.w3.org/2000/svg">
<circle cx= "6" cy="10" r="2" style="stroke: #000; fill: none;"/>
<circle cx="10" cy= "6" r="2" style="stroke: #000; fill: none;"/>
<circle cx= "2" cy= "6" r="2" style="stroke: #000; fill: none;"/>
<circle cx= "6" cy= "2" r="2" style="stroke: #000;"/>
</svg>

Before

Width:  |  Height:  |  Size: 369 B

View File

@ -1,11 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Chart-O-Matic</title>
<script type="module" src="chart.mjs"></script>
</head>
<body>
<h1>The Amazing Chart-O-Matic</h1>
<canvas id="chart" style="border: solid black 1px; width: 100%;"></canvas>
</body>
</html>

View File

@ -14,20 +14,20 @@
<!-- Bulma CSS --> <!-- Bulma CSS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.3/css/bulma.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@6.5.95/css/materialdesignicons.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@6.5.95/css/materialdesignicons.min.css">
<!-- Vail stuff --> <!-- Vail stuff -->
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<link rel="icon" href="vail.png" sizes="256x256" type="image/png"> <link rel="icon" href="assets/vail.png" sizes="256x256" type="image/png">
<link rel="icon" href="vail.svg" sizes="any" type="image/svg+xml"> <link rel="icon" href="assets/vail.svg" sizes="any" type="image/svg+xml">
<script type="module" src="vail.mjs"></script> <script type="module" src="scripts/vail.mjs"></script>
<script type="module" src="ui.mjs"></script> <script type="module" src="scripts/ui.mjs"></script>
<link rel="stylesheet" href="vail.css"> <link rel="stylesheet" href="vail.css">
</head> </head>
<body> <body>
<nav class="navbar is-dark"> <nav class="navbar is-dark">
<div class="navbar-brand"> <div class="navbar-brand">
<a class="navbar-item"> <a class="navbar-item">
<img class="" src="vail.svg" alt=""> <img class="" src="assets/vail.svg" alt="">
<div class="block">Vail</div> <div class="block">Vail</div>
</a> </a>
</div> </div>
@ -40,45 +40,19 @@
</nav> </nav>
<section class="section"> <section class="section">
<div class="columns"> <div class="columns is-multiline">
<div class="column"> <div class="column">
<div class="box" id="transciever"> <div class="box" id="transciever">
<h1 class="title">Repeater</h1>
<div class="level"> <div class="level">
<div class="level-left"> <div class="level-left">
<div class="level-item"> <div class="level-item">
<input class="input" type="text" id="repeater" list="repeater-list"> <h1 class="title" data-i18n="heading.repeater">Repeater</h1>
<datalist id="repeater-list"></datalist>
<div class="dropdown is-right is-hoverable">
<div class="dropdown-trigger">
<button class="button" aria-haspopup="true" aria-controls="stock-repeaters">
<span class="icon is-small">
<i class="mdi mdi-chevron-down" aria-hidden="true"></i>
</span>
</button>
</div>
<div class="dropdown-menu" id="stock-repeaters" role="menu">
<div class="dropdown-content">
<a class="dropdown-item" data-value="">General</a>
<a class="dropdown-item" data-value="1">Channel 1</a>
<a class="dropdown-item" data-value="2">Channel 2</a>
<a class="dropdown-item" data-value="3">Channel 3</a>
<hr class="dropdown-divider">
<a class="dropdown-item" data-value="Null">Null (no transmit)</a>
<a class="dropdown-item">Echo</a>
<a class="dropdown-item">Fortunes</a>
<a class="dropdown-item">Fortunes: Pauses ×2</a>
<a class="dropdown-item">Fortunes: Pauses ×4</a>
<a class="dropdown-item">Fortunes: Pauses ×8</a>
</div>
</div>
</div>
</div> </div>
</div> </div>
<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>
@ -86,6 +60,36 @@
</div> </div>
</div> </div>
<div class="block is-flex">
<input class="input" type="text" id="repeater" list="repeater-list">
<datalist id="repeater-list"></datalist>
<div class="dropdown is-right is-hoverable">
<div class="dropdown-trigger">
<button class="button" aria-haspopup="true" aria-controls="stock-repeaters">
<span class="icon is-small">
<i class="mdi mdi-chevron-down" aria-hidden="true"></i>
</span>
</button>
</div>
<div class="dropdown-menu" id="stock-repeaters" role="menu">
<div class="dropdown-content">
<a class="dropdown-item" data-value="">General</a>
<a class="dropdown-item" data-value="1">Channel 1</a>
<a class="dropdown-item" data-value="2">Channel 2</a>
<a class="dropdown-item" data-value="3">Channel 3</a>
<hr class="dropdown-divider">
<a class="dropdown-item" data-value="Null">Null (no transmit)</a>
<a class="dropdown-item">Echo</a>
<a class="dropdown-item">Fortunes</a>
<a class="dropdown-item">Fortunes: Pauses ×2</a>
<a class="dropdown-item">Fortunes: Pauses ×4</a>
<a class="dropdown-item">Fortunes: Pauses ×8</a>
</div>
</div>
</div>
</div>
<div class="block"> <div class="block">
<div class="" id="charts"> <div class="" id="charts">
<canvas class="chart" id="rxChart" data-color="orange"></canvas> <canvas class="chart" id="rxChart" data-color="orange"></canvas>
@ -94,7 +98,7 @@
<canvas class="chart" id="key1Chart" data-color="purple"></canvas> <canvas class="chart" id="key1Chart" data-color="purple"></canvas>
</div> </div>
</div> </div>
<div class="block"> <div class="block">
<table class="wide"> <table class="wide">
<tr> <tr>
@ -125,22 +129,22 @@
<div> <div>
<div class="field is-horizontal"> <div class="field is-horizontal">
<div class="field-label"> <div class="field-label">
<label class="label">Mode</label> <label class="label" data-i18n="heading.mode">Mode</label>
</div> </div>
<div class="field-body"> <div class="field-body">
<div class="field"> <div class="field">
<div class="control"> <div class="control">
<div class="select"> <div class="select">
<select id="keyer-mode"> <select id="keyer-mode">
<option value="cootie">Straight Key / Cootie</option> <option value="cootie" data-i18n="keyer.cootie">Straight Key / Cootie</option>
<option value="bug">Bug</option> <option value="bug" data-i18n="keyer.bug">Bug</option>
<option value="elbug">ElBug</option> <option value="elbug" data-i18n="keyer.elbug">ElBug</option>
<option value="singledot">Single Dot</option> <option value="singledot" data-i18n="keyer.singledot">Single Dot</option>
<option value="ultimatic">Ultimatic</option> <option value="ultimatic" data-i18n="keyer.ultimatic">Ultimatic</option>
<option value="iambic">Iambic (Plain)</option> <option value="iambic" data-i18n="keyer.iambic">Iambic (Plain)</option>
<option value="iambica">Iambic A</option> <option value="iambica" data-i18n="keyer.iambica">Iambic A</option>
<option value="iambicb">Iambic B</option> <option value="iambicb" data-i18n="keyer.iambicb">Iambic B</option>
<option value="keyahead">Keyahead</option> <option value="keyahead" data-i18n="keyer.keyahead">Keyahead</option>
</select> </select>
</div> </div>
</div> </div>
@ -151,9 +155,9 @@
<div class="field is-horizontal"> <div class="field is-horizontal">
<div class="field-label"> <div class="field-label">
<label class="label"> <label class="label">
<output for="keyer-rate"></output>&nbsp;WPM <output for="keyer-rate"></output>&nbsp;<span data-i18n="label.wpm">WPM</span>
/ /
<span data-fill="keyer-ms"></span>ms <span data-fill="keyer-ms"></span><span data-i18n="label:ms">ms</span>
</label> </label>
</div> </div>
<div class="field-body"> <div class="field-body">
@ -163,7 +167,7 @@
id="keyer-rate" id="keyer-rate"
type="range" type="range"
min="5" min="5"
max="40" max="50"
step="1" step="1"
value="12"> value="12">
</div> </div>
@ -171,30 +175,33 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div class="column"> <div class="column">
<div class="box"> <div class="box">
<h2 class="title">Knobs</h2> <h2 class="title" data-i18n="heading.notes" data-i18n="heading.notes"></h2>
<textarea class="textarea" data-i18n-placeholder="description.notes" id="notes"></textarea>
<nav class="breadcrumb has-bullet-separator">
<ul>
<li><a href="https://discord.gg/GBzj8cBat7" target="_blank" data-i18n-title="title.discord"><i class="mdi mdi-discord"></i></a></li>
<li><a href="https://github.com/nealey/vail/wiki" target="_blank" data-i18n-title="title.wiki">Help</a></li>
</ul>
</nav>
</div>
</div>
<div class="column">
<div class="box">
<h2 class="title" data-i18n="heading.knobs">Knobs</h2>
<div class="block"> <div class="block">
<div class="control"> <div class="control">
<button id="ck" class="button is-primary"> <button id="reset" class="button" data-i18n="label.reset">
CK
</button>
</div>
<div class="">
Send <code>CK</code> (check) to the repeater, and play when it comes back.
</div>
</div>
<div class="block">
<div class="control">
<button id="reset" class="button">
Reset Reset
</button> </button>
</div> </div>
<div> <div data-i18n="description.reset">
Reset all Vail preferences to default. Reset all Vail preferences to default.
</div> </div>
</div> </div>
@ -204,7 +211,7 @@
<div class="field-label"> <div class="field-label">
<label class="label"> <label class="label">
<output for="rx-delay"></output>s <output for="rx-delay"></output>s
rx delay <span data-i18n="label.rx-delay">rx delay</span>
</label> </label>
</div> </div>
<div class="field-body"> <div class="field-body">
@ -239,7 +246,7 @@
<p> <p>
<label class="checkbox"> <label class="checkbox">
<input type="checkbox" id="telegraph-buzzer"> <input type="checkbox" id="telegraph-buzzer">
Telegraph sounds <span data-i18n="label.telegraph-sounds">Telegraph sounds</span>
</label> </label>
</p> </p>
@ -247,17 +254,6 @@
</div> </div>
</div> </div>
</section> </section>
<div class="box">
<h2 class="title">Notes</h2>
<textarea class="textarea" placeholder="Enter your own notes here" id="notes"></textarea>
<nav class="breadcrumb has-bullet-separator">
<ul>
<li><a href="https://discord.gg/GBzj8cBat7" target="_blank" title="Text/voice chat on Discord"><i class="mdi mdi-discord"></i></a></li>
<li><a href="https://github.com/nealey/vail/wiki" target="_blank" title="Vail Wiki">Help</a></li>
</ul>
</nav>
</div>
<div class="columns is-centered bottom"> <div class="columns is-centered bottom">
<div class="column is-half" id="errors"></div> <div class="column is-half" id="errors"></div>

View File

@ -7,7 +7,7 @@
"theme_color": "#009688", "theme_color": "#009688",
"description": "Internet Morse Code client", "description": "Internet Morse Code client",
"icons": [ "icons": [
{"src": "vail.png", "sizes": "250x250"}, {"src": "assets/vail.png", "sizes": "250x250"},
{"src": "vail.svg", "sizes": "150x150"} {"src": "assets/vail.svg", "sizes": "150x150"}
] ]
} }

View File

@ -1,189 +0,0 @@
import {GetFortune} from "./fortunes.mjs"
const Millisecond = 1
const Second = 1000 * Millisecond
const Minute = 60 * Second
export class Vail {
constructor(rx, name) {
this.rx = rx
this.name = name
this.lagDurations = []
this.sent = []
this.wantConnected = true
this.wsUrl = new URL("chat", window.location)
this.wsUrl.protocol = this.wsUrl.protocol.replace("http", "ws")
this.wsUrl.pathname = this.wsUrl.pathname.replace("testing/", "") // Allow staging deploys
this.wsUrl.searchParams.set("repeater", name)
this.reopen()
}
reopen() {
if (!this.wantConnected) {
return
}
console.info("Attempting to reconnect", this.wsUrl.href)
this.clockOffset = 0
this.socket = new WebSocket(this.wsUrl)
this.socket.addEventListener("message", e => this.wsMessage(e))
this.socket.addEventListener("close", () => this.reopen())
}
stats() {
return {
averageLag: this.lagDurations.reduce((a,b) => (a+b), 0) / this.lagDurations.length,
clockOffset: this.clockOffset,
}
}
wsMessage(event) {
let now = Date.now()
let jmsg = event.data
let msg
try {
msg = JSON.parse(jmsg)
}
catch (err) {
console.error(err, jmsg)
return
}
let beginTxTime = msg[0]
let durations = msg.slice(1)
// Why is this happening?
if (beginTxTime == 0) {
return
}
let sent = this.sent.filter(e => e != jmsg)
if (sent.length < this.sent.length) {
// We're getting our own message back, which tells us our lag.
// We shouldn't emit a tone, though.
let totalDuration = durations.reduce((a, b) => a + b)
this.sent = sent
this.lagDurations.unshift(now - this.clockOffset - beginTxTime - totalDuration)
this.lagDurations.splice(20, 2)
this.rx(0, 0, this.stats())
return
}
// The very first packet is the server telling us the current time
if (durations.length == 0) {
if (this.clockOffset == 0) {
this.clockOffset = now - beginTxTime
this.rx(0, 0, this.stats())
}
return
}
// Adjust playback time to clock offset
let adjustedTxTime = beginTxTime + this.clockOffset
// Every second value is a silence duration
let tx = true
for (let duration of durations) {
duration = Number(duration)
if (tx && (duration > 0)) {
this.rx(adjustedTxTime, duration, this.stats())
}
adjustedTxTime = Number(adjustedTxTime) + duration
tx = !tx
}
}
/**
* Send a transmission
*
* @param {number} time When to play this transmission
* @param {number} duration How long the transmission is
* @param {boolean} squelch True to mute this tone when we get it back from the repeater
*/
Transmit(time, duration, squelch=true) {
let msg = [time - this.clockOffset, duration]
let jmsg = JSON.stringify(msg)
if (this.socket.readyState != 1) {
// If we aren't connected, complain.
console.error("Not connected, dropping", jmsg)
return
}
this.socket.send(jmsg)
if (squelch) {
this.sent.push(jmsg)
}
}
Close() {
this.wantConnected = false
this.socket.close()
}
}
export class Null {
constructor(rx) {
this.rx = rx
this.interval = setInterval(() => this.pulse(), 3 * Second)
}
pulse() {
this.rx(0, 0, {note: "local"})
}
Transmit(time, duration, squelch=true) {
}
Close() {
clearInterval(this.interval)
}
}
export class Echo {
constructor(rx, delay=0) {
this.rx = rx
this.delay = delay
this.Transmit(0, 0)
}
Transmit(time, duration, squelch=true) {
this.rx(time + this.delay, duration, {note: "local"})
}
Close() {
}
}
export class Fortune {
/**
*
* @param rx Receive callback
* @param {Keyer} keyer Keyer object
*/
constructor(rx, keyer) {
this.rx = rx
this.keyer = keyer
this.interval = setInterval(() => this.pulse(), 1 * Minute)
this.pulse()
}
pulse() {
this.rx(0, 0, {note: "local"})
if (this.keyer.Busy()) {
return
}
let fortune = GetFortune()
this.keyer.EnqueueAsciiString(`${fortune}\x04 `)
}
Transmit(time, duration, squelch=true) {
// Do nothing.
}
Close() {
this.keyer.Flush()
clearInterval(this.interval)
}
}

47
static/scripts/i18n.mjs Normal file
View File

@ -0,0 +1,47 @@
import {Xlat} from "./xlat.mjs"
class I18n {
constructor() {
for (let lang of navigator.languages) {
this.table = Xlat[lang]
if (this.table) {
break
}
}
}
Fill() {
if (!this.table) {
return
}
for (let e of document.querySelectorAll("[data-i18n]")) {
e.innerHTML = this.lookup(e.dataset.i18n, e.innerHTML)
}
for (let e of document.querySelectorAll("[data-i18n-placeholder]")) {
e.placeholder = this.lookup(e.dataset.i18nPlaceholder, e.placeholder)
}
for (let e of document.querySelectorAll("[data-i18n-title")) {
e.title = this.lookup(e.dataset.i18nTitle, e.title)
}
}
lookup(key, dfl=null) {
let obj = this.table
for (let k of key.split(".")) {
obj = obj[k]
}
return obj || dfl
}
}
async function Setup() {
let i = new I18n()
i.Fill()
}
export {
Setup,
}

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{
@ -48,9 +53,7 @@ export class Keyboard extends Input{
// Listen for keystrokes // Listen for keystrokes
document.addEventListener("keydown", e => this.keyboard(e)) document.addEventListener("keydown", e => this.keyboard(e))
document.addEventListener("keyup", e => this.keyboard(e)) document.addEventListener("keyup", e => this.keyboard(e))
window.addEventListener("blur", e => this.loseFocus(e))
// VBand: the keyboard input needs to know whether vband's "left" should be dit or straight
this.iambic = false
} }
keyboard(event) { keyboard(event) {
@ -98,11 +101,28 @@ export class Keyboard extends Input{
} }
} }
} }
loseFocus(event) {
if (this.ditDown) {
this.keyer.Key(0, false)
this.ditDown = false
}
if (this.dahDown) {
this.keyer.Key(1, false)
this.dahDown = false
}
if (this.straightDown) {
this.keyer.key(2, false)
this.straightDown = false
}
}
} }
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 +137,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 dit duration 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 +171,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 +263,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,12 @@ 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.
if (false) {
this.bgOsc = new Oscillator(1, 0.001)
this.bgOsc.SoundAt()
}
} }
/** /**
@ -218,7 +232,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 +243,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)
} }
@ -245,11 +259,11 @@ class TelegraphBuzzer extends AudioBuzzer{
this.hum = new Oscillator(140, 0.005, "sawtooth") this.hum = new Oscillator(140, 0.005, "sawtooth")
this.closeSample = new Sample("telegraph-a.mp3") this.closeSample = new Sample("../assets/telegraph-a.mp3")
this.openSample = new Sample("telegraph-b.mp3") this.openSample = new Sample("../assets/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 +271,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 +280,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

@ -0,0 +1,229 @@
import {GetFortune} from "./fortunes.mjs"
const Millisecond = 1
const Second = 1000 * Millisecond
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 {
constructor(rx, name) {
this.rx = rx
this.name = name
this.lagDurations = []
this.sent = []
this.wantConnected = true
this.connected = false
this.wsUrl = new URL("chat", window.location)
this.wsUrl.protocol = this.wsUrl.protocol.replace("http", "ws")
this.wsUrl.pathname = this.wsUrl.pathname.replace("testing/", "") // Allow staging deploys
this.wsUrl.searchParams.set("repeater", name)
this.reopen()
}
reopen() {
if (!this.wantConnected) {
return
}
this.rx(0, 0, {connected: false})
console.info("Attempting to reconnect", this.wsUrl.href)
this.clockOffset = 0
this.socket = new WebSocket(this.wsUrl, ["json.vail.woozle.org"])
this.socket.addEventListener("message", e => this.wsMessage(e))
this.socket.addEventListener(
"open",
msg => {
this.connected = true
this.rx(0, 0, {connected: true, notice: "Repeater connected"})
}
)
this.socket.addEventListener(
"close",
msg => {
this.rx(0, 0, {connected: false, notice: `Repeater disconnected: ${msg.reason}`})
console.error("Repeater connection dropped:", msg.reason)
setTimeout(() => this.reopen(), 2*Second)
}
)
}
wsMessage(event) {
let now = Date.now()
let jmsg = event.data
let msg
try {
msg = JSON.parse(jmsg)
}
catch (err) {
console.error(err, 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,
}
// XXX: Why is this happening?
if (msg.Timestamp == 0) {
console.debug("Got timestamp=0", msg)
return
}
let sent = this.sent.filter(m => !MessageEqual(msg, m))
if (sent.length < this.sent.length) {
// We're getting our own message back, which tells us our lag.
// We shouldn't emit a tone, though.
let totalDuration = msg.Duration.reduce((a, b) => a + b)
this.sent = sent
this.lagDurations.unshift(now - this.clockOffset - msg.Timestamp - totalDuration)
this.lagDurations.splice(20, 2)
this.rx(0, 0, stats)
return
}
// Packets with 0 length tell us what time the server thinks it is,
// and how many clients are connected
if (msg.Duration.length == 0) {
this.clockOffset = now - msg.Timestamp
this.rx(0, 0, stats)
return
}
// Adjust playback time to clock offset
let adjustedTxTime = msg.Timestamp + this.clockOffset
// Every second value is a silence duration
let tx = true
for (let duration of msg.Duration) {
duration = Number(duration)
if (tx && (duration > 0)) {
this.rx(adjustedTxTime, duration, stats)
}
adjustedTxTime = Number(adjustedTxTime) + duration
tx = !tx
}
}
/**
* Send a transmission
*
* @param {number} timestamp When to play this transmission
* @param {number} duration How long the transmission is
* @param {boolean} squelch True to mute this tone when we get it back from the repeater
*/
Transmit(timestamp, duration, squelch=true) {
let msg = {
Timestamp: timestamp - this.clockOffset,
Duration: [duration],
}
let jmsg = JSON.stringify(msg)
if (this.socket.readyState != 1) {
// If we aren't connected, complain.
console.error("Not connected, dropping", jmsg)
return
}
this.socket.send(jmsg)
if (squelch) {
this.sent.push(msg)
}
}
Close() {
this.wantConnected = false
this.socket.close()
}
}
export class Null {
constructor(rx, interval=3*Second) {
this.rx = rx
this.init()
}
notice(msg) {
this.rx(0, 0, {connected: false, notice: msg})
}
init() {
this.notice("Null repeater: nobody will hear you.")
}
Transmit(time, duration, squelch=true) {}
Close() {}
}
export class Echo extends Null {
constructor(rx, delay=0) {
super(rx)
this.delay = delay
}
init () {
this.notice("Echo repeater: you can only hear yourself.")
}
Transmit(time, duration, squelch=true) {
this.rx(time + this.delay, duration, {note: "local"})
}
}
export class Fortune extends Null {
/**
*
* @param rx Receive callback
* @param {Keyer} keyer Robokeyer
*/
constructor(rx, keyer) {
super(rx)
this.keyer = keyer
}
init() {
this.notice("Say something, and I will tell you your fortune.")
}
pulse() {
this.timeout = null
if (!this.keyer || this.keyer.Busy()) {
return
}
let fortune = GetFortune()
this.keyer.EnqueueAsciiString(`${fortune} \x04 `)
}
Transmit(time, duration, squelch=true) {
if (this.timeout) {
clearTimeout(this.timeout)
}
this.timeout = setTimeout(() => this.pulse(), 3 * Second)
}
Close() {
this.keyer.Flush()
super.Close()
}
}

View File

@ -1,8 +1,9 @@
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"
import * as I18n from "./i18n.mjs"
const DefaultRepeater = "General" const DefaultRepeater = "General"
const Millisecond = 1 const Millisecond = 1
@ -10,7 +11,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,24 +38,22 @@ 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")) {
e.addEventListener("click", e => this.test())
}
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())
} }
@ -63,15 +62,13 @@ class VailClient {
this.inputInit("#keyer-mode", e => this.setKeyer(e.target.value)) this.inputInit("#keyer-mode", e => this.setKeyer(e.target.value))
this.inputInit("#keyer-rate", e => { this.inputInit("#keyer-rate", e => {
let rate = e.target.value let rate = e.target.value
this.ditDuration = Minute / rate / 50 this.ditDuration = Math.round(Minute / rate / 50)
for (let e of document.querySelectorAll("[data-fill='keyer-ms']")) { for (let e of document.querySelectorAll("[data-fill='keyer-ms']")) {
e.textContent = this.ditDuration.toFixed(0) e.textContent = this.ditDuration
} }
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 +86,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 +114,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 +130,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 +160,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 +172,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 +220,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()
} }
} }
@ -291,8 +289,6 @@ class VailClient {
} else { } else {
this.repeater = new Repeaters.Vail(rx, name) this.repeater = new Repeaters.Vail(rx, name)
} }
toast(`Now using repeater: ${name}`)
} }
/** /**
@ -343,7 +339,7 @@ class VailClient {
*/ */
error(msg) { error(msg) {
toast(msg) toast(msg)
this.buzzer.Error() this.outputs.Error()
} }
/** /**
@ -371,11 +367,18 @@ class VailClient {
this.rxDurations.splice(20, 2) this.rxDurations.splice(20, 2)
} }
if (stats.notice) {
toast(stats.notice)
}
let averageLag = (stats.averageLag || 0).toFixed(2) let averageLag = (stats.averageLag || 0).toFixed(2)
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)
@ -413,30 +416,6 @@ class VailClient {
console.log(element) console.log(element)
} }
/**
* Send "CK" to server, and don't squelch the echo
*/
test() {
let when = Date.now()
let dit = this.ditDuration
let dah = dit * 3
let s = dit
let message = [
dah, s, dit, s, dah, s, dit,
s * 3,
dah, s, dit, s, dah
]
this.repeater.Transmit(when, 0) // Get round-trip time
for (let i in message) {
let duration = message[i]
if (i % 2 == 0) {
this.repeater.Transmit(when, duration, false)
}
when += duration
}
}
/** Reset to factory defaults */ /** Reset to factory defaults */
reset() { reset() {
localStorage.clear() localStorage.clear()
@ -448,6 +427,7 @@ function init() {
if (navigator.serviceWorker) { if (navigator.serviceWorker) {
navigator.serviceWorker.register("sw.js") navigator.serviceWorker.register("sw.js")
} }
I18n.Setup()
try { try {
window.app = new VailClient() window.app = new VailClient()
} catch (err) { } catch (err) {

122
static/scripts/xlat.mjs Normal file
View File

@ -0,0 +1,122 @@
export const Xlat = {
"fr": {
"heading": {
"repeater": "Répéteur",
"mode": "Mode",
"notes": "Remarques",
"knobs": "Réglages"
},
"key": {
"key": "Key",
"dit": "Dit",
"dah": "Dah"
},
"keyer": {
"cootie": "Straight Key / Cootie",
"bug": "Bug",
"elbug": "Electronic Bug",
"singledot": "Single Dot",
"ultimatic": "Ultimatic",
"iambic": "Iambic (plain)",
"iambica": "Iambic A",
"iambicb": "Iambic B",
"keyahead": "Keyahead"
},
"label": {
"wpm": "WPM",
"ms": "ms",
"wiki": "Aide",
"rx-delay": "retard de réception",
"telegraph-sounds": "Sons télégraphiques"
},
"title": {
"discord": "Chat textuel et du voix, sur Discord",
"wiki": "Vail Wiki"
},
"description": {
"ck": "Transmettre <code>CK</code> (check) au répéteur, et sonner le réponse.",
"reset": "Réinitialiser les paramètres par défaut.",
"notes": "Écrivez vos nôtes ici."
}
},
"en": {
"heading": {
"repeater": "Repeater",
"mode": "Mode",
"notes": "Notes",
"knobs": "Knobs"
},
"key": {
"key": "Key",
"dit": "Dit",
"dah": "Dah"
},
"keyer": {
"cootie": "Straight Key / Cootie",
"bug": "Bug",
"elbug": "Electronic Bug",
"singledot": "Single Dot",
"ultimatic": "Ultimatic",
"iambic": "Iambic (plain)",
"iambica": "Iambic A",
"iambicb": "Iambic B",
"keyahead": "Keyahead"
},
"label": {
"wpm": "WPM",
"ms": "ms",
"wiki": "Help",
"rx-delay": "receive delay",
"telegraph-sounds": "Telegraph sounds"
},
"title": {
"discord": "Text/Voice chat on Discord",
"wiki": "Vail Wiki"
},
"description": {
"ck": "Send <code>CK</code> (check) to the repeater, and play when it comes back.",
"reset": "Reset all Vail preferences to default.",
"notes": "Enter your own notes here."
}
},
"se": {
"heading": {
"repeater": "Repeater",
"mode": "Läge",
"notes": "Anteckningar",
"knobs": "Rattar"
},
"key": {
"key": "Nyckel",
"dit": "Dit",
"dah": "Dah"
},
"keyer": {
"cootie": "Handpump / Cootie",
"bug": "Bug",
"elbug": "Elektronisk Bug",
"singledot": "Enkelpunktig",
"ultimatic": "Ultimatisk",
"iambic": "Iambic (normal)",
"iambica": "Iambic-läge A",
"iambicb": "Iambic-läge B",
"keyahead": "Förtelegrafera"
},
"label": {
"wpm": "Ord per minut",
"ms": "Millisekunder",
"wiki": "Hjälp",
"rx-delay": "Mottagningsfördröjning",
"telegraph-sounds": "Telegrafljud"
},
"title": {
"discord": "Text- eller röstkommunicera på Discord",
"wiki": "Vail Wiki"
},
"description": {
"ck": "Sänd <code>CK</code> (kontroll) till repeater:en, och få uppspelat vid återkomst.",
"reset": "Återställ till standardinställningarna.",
"notes": "Angiv Dina egna anteckningar här."
}
}
}

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%;
} }