Merge branch 'main' of https://github.com/nealey/vail
|
@ -1 +1 @@
|
||||||
|
i18n
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
150
cmd/vail/main.go
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
@ -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
|
@ -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=
|
||||||
|
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.5 KiB |
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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 |
|
@ -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>
|
|
|
@ -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> WPM
|
<output for="keyer-rate"></output> <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>
|
||||||
|
|
|
@ -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"}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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,
|
||||||
|
}
|
|
@ -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}
|
|
@ -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,
|
||||||
}
|
}
|
|
@ -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}
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
|
@ -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) {
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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%;
|
||||||
}
|
}
|
||||||
|
|