34 Commits

Author SHA1 Message Date
bel
4a86d2b6ca update readme for STT setting Quiet mode 2023-03-26 14:27:42 -06:00
bel
c663b1a12c expose PATCH /config 2023-03-26 14:17:33 -06:00
bel
af42db6803 v01.config accepts and applies json patch 2023-03-26 10:20:19 -06:00
bel
f9dc4cff9f todo 2023-03-26 10:08:04 -06:00
bel
37050f3d87 test quiet mode 2023-03-26 10:06:22 -06:00
bel
74717609ec v01.yaml has .quiet=true to cause all button pushes to become releases 2023-03-26 10:00:10 -06:00
bel
24ae45896f todo 2023-03-26 09:57:03 -06:00
bel
8b29648c50 add hotwords file for stt 2023-03-26 09:55:50 -06:00
bel
1eba008efe readme order 2023-03-26 09:49:51 -06:00
bel
d48c545030 update host.README for tts and stt integrated 2023-03-26 09:47:19 -06:00
bel
323ca466ad update configs in host.d for tts 2023-03-26 09:29:02 -06:00
bel
67e504ced6 accept say in headers for more length 2023-03-26 09:25:48 -06:00
bel
ad967d5047 test a longer input and its k 2023-03-26 09:23:20 -06:00
bel
8fd0067ad1 block while tts speaking for singleton 2023-03-26 09:18:31 -06:00
bel
43566be7ae ?say=XYZ to TTS 2023-03-26 09:13:24 -06:00
bel
cb8b254cbb ?say=XYZ to TTS 2023-03-26 09:12:05 -06:00
bel
340ca1d2f5 go test ok 2023-03-26 08:48:46 -06:00
bel
02c49852c0 todo 2023-03-26 08:48:00 -06:00
bel
02c9dce1b3 split server 2023-03-26 08:46:31 -06:00
bel
a3650642ca todo 2023-03-26 08:44:30 -06:00
bel
fbded57807 rename 2023-03-26 08:42:40 -06:00
bel
44cb05487e split out message, config 2023-03-26 08:42:09 -06:00
bel
e1e2ce3eec split out xform 2023-03-26 08:39:50 -06:00
bel
4c7f444887 more splitting v01 2023-03-26 08:38:24 -06:00
bel
0311fc56a3 split v01 into its own pkg 2023-03-26 08:37:13 -06:00
bel
9902684990 todo 2023-03-26 08:34:48 -06:00
bel
967e66bdb3 todo 2023-03-25 23:16:48 -06:00
bel
ff21bfb8b3 todo 2023-03-25 23:08:59 -06:00
bel
c153636e24 locks 2023-03-25 23:04:00 -06:00
bel
efe4adf129 k no deadlock 2023-03-25 22:57:12 -06:00
bel
802266e500 remove wrapToParse dependence 2023-03-25 22:54:23 -06:00
bel
373d8be1a0 split button and parse packages 2023-03-25 22:52:09 -06:00
bel
bd5654128e accept PUT /broadcast to change the broadcast message 2023-03-25 22:37:19 -06:00
bel
9073658e12 update readme with linux build windows 2023-03-25 19:21:46 -06:00
32 changed files with 850 additions and 283 deletions

17
go.mod
View File

@@ -3,7 +3,18 @@ module mayhem-party
go 1.19
require (
github.com/go-yaml/yaml v2.1.0+incompatible // indirect
github.com/micmonay/keybd_event v1.1.1 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
github.com/faiface/beep v1.1.0
github.com/go-yaml/yaml v2.1.0+incompatible
gopkg.in/yaml.v2 v2.4.0
)
require (
github.com/evanphx/json-patch/v5 v5.6.0 // indirect
github.com/hajimehoshi/oto v0.7.1 // indirect
github.com/micmonay/keybd_event v1.1.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 // indirect
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067 // indirect
golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 // indirect
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756 // indirect
)

40
go.sum
View File

@@ -1,7 +1,47 @@
github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo=
github.com/evanphx/json-patch/v5 v5.6.0 h1:b91NhWfaz02IuVxO9faSllyAtNXHMPkC5J8sJCLunww=
github.com/evanphx/json-patch/v5 v5.6.0/go.mod h1:G79N1coSVB93tBe7j6PhzjmR3/2VvlbKOFpnXhI9Bw4=
github.com/faiface/beep v1.1.0 h1:A2gWP6xf5Rh7RG/p9/VAW2jRSDEGQm5sbOb38sf5d4c=
github.com/faiface/beep v1.1.0/go.mod h1:6I8p6kK2q4opL/eWb+kAkk38ehnTunWeToJB+s51sT4=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM=
github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs=
github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498=
github.com/go-audio/wav v1.0.0/go.mod h1:3yoReyQOsiARkvPl3ERCi8JFjihzG6WhjYpZCf5zAWE=
github.com/go-yaml/yaml v2.1.0+incompatible h1:RYi2hDdss1u4YE7GwixGzWwVo47T8UQwnTLB6vQiq+o=
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
github.com/hajimehoshi/go-mp3 v0.3.0/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM=
github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI=
github.com/hajimehoshi/oto v0.7.1 h1:I7maFPz5MBCwiutOrz++DLdbr4rTzBsbBuV2VpgU9kk=
github.com/hajimehoshi/oto v0.7.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6EepeVGnos=
github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A=
github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk=
github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0=
github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s=
github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
github.com/mewkiz/flac v1.0.7/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU=
github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2/go.mod h1:3E2FUC/qYUfM8+r9zAwpeHJzqRVVMIYnpzD/clwWxyA=
github.com/micmonay/keybd_event v1.1.1 h1:rv7omwXWYL9Lgf3PUq6uBgJI2k1yGkL/GD6dxc6nmSs=
github.com/micmonay/keybd_event v1.1.1/go.mod h1:CGMWMDNgsfPljzrAWoybUOSKafQPZpv+rLigt2LzNGI=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8 h1:idBdZTd9UioThJp8KpM/rTSinK/ChZFBE43/WtIy8zg=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067 h1:KYGJGHOQy8oSi1fDlSpcZF0+juKwk/hEMv5SiwHogR0=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6 h1:vyLBGJPIl9ZYbcQFM2USFmJBK6KI+t+z6jL0lbwjrnc=
golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756 h1:9nuHUbU8dRnRRfj9KjWUVrJeoexdbeMjttk6Oh1rD10=
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=

View File

@@ -11,9 +11,16 @@
## Distribute `rusty-pipe`
```bash
# https://www.reddit.com/r/rust/comments/5k8uab/crosscompiling_from_ubuntu_to_windows_with_rustup/
(
echo '[target.x86_64-pc-windows-gnu]'
echo 'linker = "x86_64-w64-mingw32-gcc"'
echo 'ar = "x86_64-w64-mingw32-gcc-ar"'
) >> $HOME/.cargo/config
sudo apt install mingw-w64
rustup target add x86_64-pc-windows-gnu
echo windows
cargo build --target x86_64-pc-windows-gnu && ls target/x86_64-pc-windows-gnu/release/rusty-pipe.exe
cargo build --release --target x86_64-pc-windows-gnu && ls target/x86_64-pc-windows-gnu/release/rusty-pipe.exe
echo local
cargo install --path ./
```
@@ -29,6 +36,14 @@ See `./config.d/rusty-pipe.d`
# Server
## TTS
`cd /home/breel/Go/src/gogs.inhome.blapointe.com/tts/larynx.d; bash run.sh`
## STT
`cd /home/breel/Go/src/gogs.inhome.blapointe.com/stt.d/whisper-2023; HOTWORDS=/home/breel/Go/src/gogs.inhome.blapointe.com/mayhem-party.d/host.d/config.d/stt.d/hotwords.txt MIC_TIMEOUT=2 URL=http://localhost:17071/config HEADERS=say='Eye herd {{hotword}}' BODY='[{"op":"replace", "path":"/Quiet", "value":true}]' python3 ./hotwords.py'
## `mayhem-party`
### Configs
@@ -45,7 +60,3 @@ See `./config.d/rusty-pipe.d`
Foreground
## `stt`
TODO pipe stt detecting relevant strings -> change the `./config.d/mayhem-party.d/remap.d/live.yaml` link -> `SIGUSR1` to `mayhem-party`

View File

@@ -1,7 +1,11 @@
export DEBUG=true
export RAW_UDP=17070
export BUTTON_V01=true
export WRAP_REFRESH_ON_SIGUSR1=true
export MAIN_INTERVAL_DURATION=5ms
export RAW_UDP=17070
export PARSE_V01=true
export V01_CONFIG=./config.d/mayhem-party.d/v01.yaml
export WRAP_REFRESH_ON_SIGUSR1=true
export OUTPUT_KEYBOARD=false
export BUTTON_V01_CONFIG=./config.d/mayhem-party.d/v01.yaml

View File

@@ -1 +1 @@
players_offset_4.yaml
players_offset_2.yaml

View File

@@ -1,5 +1,6 @@
feedback:
addr: :17071
ttsurl: http://localhost:15002
users:
bel:
player: 2
@@ -19,3 +20,4 @@ players:
2: 6
3: 7
4: 8
quiet: false

View File

@@ -0,0 +1 @@
../../../../rusty-pipe.d/target/x86_64-pc-windows-gnu/release/rusty-pipe.exe

View File

@@ -0,0 +1,5 @@
mario
party
yo
win
die

View File

@@ -1,24 +0,0 @@
package button
import (
"context"
"mayhem-party/src/device/input/raw"
"os"
)
var (
FlagButtonV01 = os.Getenv("BUTTON_V01") == "true"
)
type Parser interface {
Read() []Button
Close()
CloseWrap() raw.Raw
}
func New(ctx context.Context, src raw.Raw) Parser {
if FlagButtonV01 {
return NewV01(ctx, src)
}
return NewPlaintext(src)
}

View File

@@ -1,11 +0,0 @@
package button_test
import (
"mayhem-party/src/device/input/button"
"testing"
)
func TestParser(t *testing.T) {
var _ button.Parser = button.Plaintext{}
var _ button.Parser = button.V01{}
}

View File

@@ -1,155 +0,0 @@
package button
import (
"context"
"encoding/json"
"io/ioutil"
"log"
"mayhem-party/src/device/input/raw"
"net/http"
"os"
"time"
"gopkg.in/yaml.v2"
)
var (
FlagDebug = os.Getenv("DEBUG") == "true"
FlagButtonV01Config = os.Getenv("BUTTON_V01_CONFIG")
)
type (
V01 struct {
ctx context.Context
can context.CancelFunc
src raw.Raw
cfg v01Cfg
}
v01Msg struct {
T int64
U string
Y string
N string
}
v01Cfg struct {
Feedback struct {
Addr string
}
Users map[string]struct {
Player int
Message string
}
Players []struct {
Transformation v01Transformation
}
}
v01Transformation map[string]string
)
func NewV01(ctx context.Context, src raw.Raw) V01 {
var cfg v01Cfg
b, _ := ioutil.ReadFile(FlagButtonV01Config)
yaml.Unmarshal(b, &cfg)
ctx, can := context.WithCancel(ctx)
result := V01{
ctx: ctx,
can: can,
src: src,
cfg: cfg,
}
go result.listen()
return result
}
func (v01 V01) listen() {
if v01.cfg.Feedback.Addr == "" {
return
}
s := &http.Server{
Addr: v01.cfg.Feedback.Addr,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(v01.ctx)
user, ok := v01.cfg.Users[r.URL.Query().Get("user")]
if !ok {
user = v01.cfg.Users["broadcast"]
}
w.Write([]byte(user.Message))
}),
}
go func() {
<-v01.ctx.Done()
log.Println("closing v01 server")
s.Close()
}()
log.Println("starting v01 server")
if err := s.ListenAndServe(); err != nil && v01.ctx.Err() == nil {
log.Println("err with v01 server", err)
panic(err)
}
}
func (v01 V01) CloseWrap() raw.Raw {
v01.can()
return v01.src
}
func (v01 V01) Close() {
v01.can()
v01.src.Close()
}
func (v01 V01) Read() []Button {
line := v01.src.Read()
var msg v01Msg
if err := json.Unmarshal(line, &msg); err != nil {
log.Printf("%v: %s", err, line)
}
v01.telemetry(msg)
return v01.cfg.transform(msg).buttons()
}
func (cfg v01Cfg) transform(msg v01Msg) v01Msg {
if len(cfg.Players) == 0 {
return msg
}
user := cfg.Users[msg.U]
if user.Player < 1 {
msg.Y = ""
msg.N = ""
return msg
}
player := cfg.Players[user.Player-1]
msg.Y = player.Transformation.pipe(msg.Y)
msg.N = player.Transformation.pipe(msg.N)
return msg
}
func (t v01Transformation) pipe(s string) string {
for i := range s {
if v := t[s[i:i+1]]; v != "" {
s = s[:i] + v[:1] + s[i+1:]
}
}
return s
}
func (v01 V01) telemetry(msg v01Msg) {
if FlagDebug {
log.Printf("%s|%dms", msg.U, time.Now().UnixNano()/int64(time.Millisecond)-msg.T)
}
}
func (msg v01Msg) buttons() []Button {
buttons := make([]Button, len(msg.Y)+len(msg.N))
for i := range msg.Y {
buttons[i] = Button{Char: msg.Y[i], Down: true}
}
for i := range msg.N {
buttons[len(msg.Y)+i] = Button{Char: msg.N[i], Down: false}
}
if FlagDebug {
log.Printf("%+v", msg)
}
return buttons
}

View File

@@ -1,51 +0,0 @@
package button
import (
"testing"
)
func TestV01TransformationPipe(t *testing.T) {
cases := map[string]struct {
input string
xform map[string]string
want string
}{
"empty input": {
xform: map[string]string{"a": "bc"},
},
"empty xform": {
input: "aa",
want: "aa",
},
"all": {
input: "aa",
xform: map[string]string{"a": "cc"},
want: "cc",
},
"last": {
input: "ba",
xform: map[string]string{"a": "cc"},
want: "bc",
},
"first": {
input: "ab",
xform: map[string]string{"a": "cc"},
want: "cb",
},
"noop": {
input: "bb",
xform: map[string]string{"a": "bc"},
want: "bb",
},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
got := v01Transformation(c.xform).pipe(c.input)
if got != c.want {
t.Errorf("%+v(%s) want %s got %s", c.xform, c.input, c.want, got)
}
})
}
}

View File

@@ -3,6 +3,7 @@ package input
import (
"context"
"mayhem-party/src/device/input/button"
"mayhem-party/src/device/input/parse"
"mayhem-party/src/device/input/raw"
"mayhem-party/src/device/input/wrap"
)
@@ -14,7 +15,7 @@ type Input interface {
func New(ctx context.Context) Input {
src := raw.New(ctx)
return wrap.New(ctx, func() button.Parser {
return button.New(ctx, src)
return wrap.New(ctx, func() wrap.Wrap {
return parse.New(ctx, src)
})
}

View File

@@ -0,0 +1,26 @@
package parse
import (
"context"
"mayhem-party/src/device/input/button"
v01 "mayhem-party/src/device/input/parse/v01"
"mayhem-party/src/device/input/raw"
"os"
)
var (
FlagParseV01 = os.Getenv("PARSE_V01") == "true"
)
type Parser interface {
Read() []button.Button
Close()
CloseWrap() raw.Raw
}
func New(ctx context.Context, src raw.Raw) Parser {
if FlagParseV01 {
return v01.NewV01(ctx, src)
}
return NewPlaintext(src)
}

View File

@@ -0,0 +1,12 @@
package parse_test
import (
"mayhem-party/src/device/input/parse"
v01 "mayhem-party/src/device/input/parse/v01"
"testing"
)
func TestParser(t *testing.T) {
var _ parse.Parser = parse.Plaintext{}
var _ parse.Parser = &v01.V01{}
}

View File

@@ -1,12 +1,13 @@
package button
package parse
import (
"mayhem-party/src/device/input/button"
"mayhem-party/src/device/input/raw"
"os"
)
var (
FlagButtonPlaintextRelease = os.Getenv("BUTTON_PLAINTEXT_RELEASE")
FlagParsePlaintextRelease = os.Getenv("PARSE_PLAINTEXT_RELEASE")
)
type Plaintext struct {
@@ -16,8 +17,8 @@ type Plaintext struct {
func NewPlaintext(src raw.Raw) Plaintext {
releaseChar := byte('!')
if FlagButtonPlaintextRelease != "" {
releaseChar = byte(FlagButtonPlaintextRelease[0])
if FlagParsePlaintextRelease != "" {
releaseChar = byte(FlagParsePlaintextRelease[0])
}
return Plaintext{
src: src,
@@ -29,16 +30,16 @@ func (p Plaintext) Close() { p.src.Close() }
func (p Plaintext) CloseWrap() raw.Raw { return p.src }
func (p Plaintext) Read() []Button {
func (p Plaintext) Read() []button.Button {
b := p.src.Read()
buttons := make([]Button, 0, len(b))
buttons := make([]button.Button, 0, len(b))
down := true
for i := range b {
if b[i] == p.release {
down = false
} else {
if b[i] != '\n' {
buttons = append(buttons, Button{Char: b[i], Down: down})
buttons = append(buttons, button.Button{Char: b[i], Down: down})
}
down = true
}

View File

@@ -1,13 +1,14 @@
package button_test
package parse_test
import (
"mayhem-party/src/device/input/button"
"mayhem-party/src/device/input/parse"
"testing"
)
func TestPlaintext(t *testing.T) {
src := constSrc("c!b")
p := button.NewPlaintext(src)
p := parse.NewPlaintext(src)
got := p.Read()
if len(got) != 2 {
t.Fatal(len(got))

View File

@@ -0,0 +1,48 @@
package v01
import (
"encoding/json"
patch "github.com/evanphx/json-patch/v5"
)
type (
config struct {
Feedback configFeedback
Users map[string]configUser
Players []configPlayer
Quiet bool
}
configFeedback struct {
Addr string
TTSURL string
}
configUser struct {
Player int
Message string
}
configPlayer struct {
Transformation transformation
}
)
func (cfg config) WithPatch(v interface{}) config {
originalData, _ := json.Marshal(cfg)
patchData, _ := json.Marshal(v)
patcher, err := patch.DecodePatch(patchData)
if err != nil {
return cfg
}
patchedData, err := patcher.Apply(originalData)
if err != nil {
return cfg
}
var patched config
if err := json.Unmarshal(patchedData, &patched); err != nil {
return cfg
}
return patched
}

View File

@@ -0,0 +1,79 @@
package v01
import (
"fmt"
"testing"
)
func TestConfigPatch(t *testing.T) {
cases := map[string]struct {
cfg config
patch interface{}
want config
}{
"nil patch": {
cfg: config{Quiet: true},
patch: nil,
want: config{Quiet: true},
},
"[] patch": {
cfg: config{Quiet: true},
patch: []interface{}{},
want: config{Quiet: true},
},
"set fake field": {
cfg: config{Quiet: true},
patch: []interface{}{
map[string]interface{}{"op": "add", "path": "/Fake", "value": true},
},
want: config{Quiet: true},
},
"remove field": {
cfg: config{Quiet: true},
patch: []interface{}{
map[string]interface{}{"op": "remove", "path": "/Quiet"},
},
want: config{Quiet: false},
},
"replace field with valid": {
cfg: config{Quiet: true},
patch: []interface{}{
map[string]interface{}{"op": "replace", "path": "/Quiet", "value": false},
},
want: config{Quiet: false},
},
"replace field with invalid": {
cfg: config{Quiet: true},
patch: []interface{}{
map[string]interface{}{"op": "replace", "path": "/Quiet", "value": "teehee"},
},
want: config{Quiet: true},
},
"test and noop": {
cfg: config{Quiet: true},
patch: []interface{}{
map[string]interface{}{"op": "test", "path": "/Quiet", "value": false},
map[string]interface{}{"op": "replace", "path": "/Quiet", "value": false},
},
want: config{Quiet: true},
},
"test and apply": {
cfg: config{Quiet: true},
patch: []interface{}{
map[string]interface{}{"op": "test", "path": "/Quiet", "value": true},
map[string]interface{}{"op": "replace", "path": "/Quiet", "value": false},
},
want: config{Quiet: false},
},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
got := c.cfg.WithPatch(c.patch)
if fmt.Sprintf("%+v", got) != fmt.Sprintf("%+v", c.want) {
t.Errorf("(%+v).Patch(%+v) want %+v, got %+v", c.cfg, c.patch, c.want, got)
}
})
}
}

View File

@@ -0,0 +1,27 @@
package v01
import (
"log"
"mayhem-party/src/device/input/button"
)
type message struct {
T int64
U string
Y string
N string
}
func (msg message) buttons() []button.Button {
buttons := make([]button.Button, len(msg.Y)+len(msg.N))
for i := range msg.Y {
buttons[i] = button.Button{Char: msg.Y[i], Down: true}
}
for i := range msg.N {
buttons[len(msg.Y)+i] = button.Button{Char: msg.N[i], Down: false}
}
if FlagDebug {
log.Printf("%+v", msg)
}
return buttons
}

View File

@@ -0,0 +1,121 @@
package v01
import (
"encoding/json"
"io"
"log"
"mayhem-party/src/device/input/wrap"
"net/http"
"os"
"sync"
"syscall"
"gopkg.in/yaml.v2"
)
func (v01 *V01) listen() {
if v01.cfg.Feedback.Addr == "" {
return
}
v01._listen()
}
func (v01 *V01) _listen() {
mutex := &sync.RWMutex{}
s := &http.Server{
Addr: v01.cfg.Feedback.Addr,
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
mutex.RLock()
defer mutex.RUnlock()
} else {
mutex.Lock()
defer mutex.Unlock()
}
v01.ServeHTTP(w, r)
}),
}
go func() {
<-v01.ctx.Done()
log.Println("closing v01 server")
s.Close()
}()
log.Println("starting v01 server")
if err := s.ListenAndServe(); err != nil && v01.ctx.Err() == nil {
log.Println("err with v01 server", err)
panic(err)
}
}
func (v01 *V01) ServeHTTP(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(v01.ctx)
v01.serveHTTP(w, r)
v01.globalQueries(r)
}
func (v01 *V01) serveHTTP(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
v01.getUserFeedback(w, r)
case "/broadcast":
v01.putBroadcast(w, r)
case "/config":
v01.patchConfig(w, r)
}
}
func (v01 *V01) getUserFeedback(w http.ResponseWriter, r *http.Request) {
user, ok := v01.cfg.Users[r.URL.Query().Get("user")]
if !ok {
user = v01.cfg.Users["broadcast"]
}
w.Write([]byte(user.Message))
}
func (v01 *V01) putBroadcast(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
v := v01.cfg.Users["broadcast"]
v.Message = string(b)
v01.cfg.Users["broadcast"] = v
}
func (v01 *V01) patchConfig(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
var v []interface{}
if err := json.Unmarshal(b, &v); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
v01.cfg = v01.cfg.WithPatch(v)
if b, err := yaml.Marshal(v01.cfg); err == nil && FlagParseV01Config != "" {
if err := os.WriteFile(FlagParseV01Config, b, os.ModePerm); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func (v01 *V01) globalQueries(r *http.Request) {
v01.globalQuerySay(r)
v01.globalQueryRefresh(r)
}
func (v01 *V01) globalQuerySay(r *http.Request) {
text := r.URL.Query().Get("say")
if text == "" {
text = r.Header.Get("say")
}
if text == "" {
return
}
go v01.tts(text)
}
func (v01 *V01) globalQueryRefresh(r *http.Request) {
if _, ok := r.URL.Query()["refresh"]; !ok {
return
}
select {
case wrap.ChSigUsr1 <- syscall.SIGUSR1:
default:
}
}

View File

@@ -0,0 +1,74 @@
package v01
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path"
"strings"
"testing"
"gopkg.in/yaml.v2"
)
func TestPatchConfig(t *testing.T) {
dir := t.TempDir()
p := path.Join(dir, t.Name()+".yaml")
cases := map[string]struct {
was config
patch string
want config
}{
"replace entire doc": {
was: config{
Feedback: configFeedback{Addr: "a", TTSURL: "a"},
Users: map[string]configUser{"a": configUser{Player: 1, Message: "a"}},
Players: []configPlayer{configPlayer{Transformation: transformation{"a": "a"}}},
Quiet: true,
},
patch: `[{"op": "replace", "path": "", "value": {
"Feedback": {"Addr": "b", "TTSURL": "b"},
"Users": {"b": {"Player": 2, "Message": "b"}},
"Players": [{"Transformation": {"b": "b"}}],
"Quiet": false
}}]`,
want: config{
Feedback: configFeedback{Addr: "b", TTSURL: "b"},
Users: map[string]configUser{"b": configUser{Player: 2, Message: "b"}},
Players: []configPlayer{configPlayer{Transformation: transformation{"b": "b"}}},
Quiet: false,
},
},
}
for name, d := range cases {
c := d
for _, usesdisk := range []bool{false, true} {
t.Run(fmt.Sprintf("%s disk=%v", name, usesdisk), func(t *testing.T) {
b, _ := yaml.Marshal(c.was)
os.WriteFile(p, b, os.ModePerm)
FlagParseV01Config = ""
if usesdisk {
FlagParseV01Config = p
}
v01 := &V01{cfg: c.was}
w := httptest.NewRecorder()
r := httptest.NewRequest(http.MethodPatch, "/config", strings.NewReader(c.patch))
v01.patchConfig(w, r)
if fmt.Sprintf("%+v", c.want) != fmt.Sprintf("%+v", v01.cfg) {
t.Errorf("want \n\t%+v, got \n\t%+v", c.want, v01.cfg)
}
if usesdisk {
b, _ := os.ReadFile(p)
var got config
yaml.Unmarshal(b, &got)
if fmt.Sprintf("%+v", c.want) != fmt.Sprintf("%+v", v01.cfg) {
t.Errorf("want \n\t%+v, got \n\t%+v", c.want, v01.cfg)
}
}
})
}
}
}

View File

@@ -1,3 +1,6 @@
feedback:
addr: :17071
ttsurl: http://localhost:15002
users:
bel:
player: 0
@@ -14,3 +17,4 @@ players:
b: "2"
x: "3"
y: "4"
quiet: false

View File

@@ -0,0 +1,14 @@
package v01
type (
transformation map[string]string
)
func (t transformation) pipe(s string) string {
for i := range s {
if v := t[s[i:i+1]]; v != "" {
s = s[:i] + v[:1] + s[i+1:]
}
}
return s
}

View File

@@ -0,0 +1,73 @@
package v01
import (
"bytes"
"fmt"
"io"
"log"
"net/http"
"net/url"
"sync"
"time"
"github.com/faiface/beep"
"github.com/faiface/beep/effects"
"github.com/faiface/beep/speaker"
"github.com/faiface/beep/wav"
)
var (
ttsLock = &sync.RWMutex{}
)
func (v01 *V01) tts(text string) {
if err := v01._tts(text); err != nil {
log.Printf("failed to tts: %s: %v", text, err)
}
}
func (v01 *V01) _tts(text string) error {
if v01.cfg.Feedback.TTSURL == "" {
return nil
}
url, err := url.Parse(v01.cfg.Feedback.TTSURL)
if err != nil {
return err
}
if len(url.Path) < 2 {
url.Path = "/api/tts"
}
q := url.Query()
if q.Get("voice") == "" {
q.Set("voice", "en-us/glados-glow_tts")
}
if q.Get("lengthScale") == "" {
q.Set("lengthScale", "1")
}
q.Set("text", text)
url.RawQuery = q.Encode()
resp, err := http.Get(url.String())
if err != nil {
return err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK || resp.Header.Get("Content-Type") != "audio/wav" {
return fmt.Errorf("failed to call ttsurl: (%d) %s", resp.StatusCode, b)
}
decoder, format, err := wav.Decode(bytes.NewReader(b))
if err != nil {
return err
}
ttsLock.Lock()
defer ttsLock.Unlock()
speaker.Init(format.SampleRate, format.SampleRate.N(time.Second/30))
speaker.Play(&effects.Volume{Streamer: beep.ResampleRatio(4, 1, &beep.Ctrl{Streamer: beep.Loop(1, decoder)})})
duration := time.Duration(decoder.Len()) * format.SampleRate.D(1)
time.Sleep(duration)
return nil
}

View File

@@ -0,0 +1,92 @@
package v01
import (
"context"
"encoding/json"
"io/ioutil"
"log"
"mayhem-party/src/device/input/button"
"mayhem-party/src/device/input/raw"
"os"
"time"
"gopkg.in/yaml.v2"
)
var (
FlagDebug = os.Getenv("DEBUG") == "true"
FlagParseV01Config = os.Getenv("V01_CONFIG")
)
type (
V01 struct {
ctx context.Context
can context.CancelFunc
src raw.Raw
cfg config
}
)
func NewV01(ctx context.Context, src raw.Raw) *V01 {
var cfg config
b, _ := ioutil.ReadFile(FlagParseV01Config)
yaml.Unmarshal(b, &cfg)
ctx, can := context.WithCancel(ctx)
result := &V01{
ctx: ctx,
can: can,
src: src,
cfg: cfg,
}
go result.listen()
return result
}
func (v01 *V01) CloseWrap() raw.Raw {
v01.can()
return v01.src
}
func (v01 *V01) Close() {
v01.can()
v01.src.Close()
}
func (v01 *V01) Read() []button.Button {
line := v01.src.Read()
var msg message
if err := json.Unmarshal(line, &msg); err != nil {
log.Printf("%v: %s", err, line)
}
v01.telemetry(msg)
buttons := v01.transform(msg).buttons()
if v01.cfg.Quiet {
for i := range buttons {
buttons[i].Down = false
}
}
return buttons
}
func (v01 *V01) telemetry(msg message) {
if FlagDebug {
log.Printf("%s|%dms", msg.U, time.Now().UnixNano()/int64(time.Millisecond)-msg.T)
}
}
func (v01 *V01) transform(msg message) message {
if len(v01.cfg.Players) == 0 {
return msg
}
user := v01.cfg.Users[msg.U]
if user.Player < 1 {
msg.Y = ""
msg.N = ""
return msg
}
player := v01.cfg.Players[user.Player-1]
msg.Y = player.Transformation.pipe(msg.Y)
msg.N = player.Transformation.pipe(msg.N)
return msg
}

View File

@@ -1,13 +1,15 @@
package button_test
package v01_test
import (
"context"
"fmt"
"io"
"mayhem-party/src/device/input/button"
v01 "mayhem-party/src/device/input/parse/v01"
"net/http"
"os"
"path"
"strings"
"testing"
"time"
)
@@ -15,7 +17,7 @@ import (
func TestV01(t *testing.T) {
src := constSrc(fmt.Sprintf(`{"T":%v,"U":"bel","Y":"abc","N":"cde"}`, time.Now().UnixNano()/int64(time.Millisecond)-50))
t.Logf("(%v) %s", len(src), src.Read())
v01 := button.NewV01(context.Background(), src)
v01 := v01.NewV01(context.Background(), src)
defer v01.Close()
got := v01.Read()
want := []button.Button{
@@ -49,10 +51,10 @@ func TestV01WithCfg(t *testing.T) {
- transformation:
w: i
`), os.ModePerm)
button.FlagButtonV01Config = p
v01.FlagParseV01Config = p
t.Run("unknown user ignored", func(t *testing.T) {
v01 := button.NewV01(context.Background(), constSrc(`{"U":"qt","Y":"w"}`))
v01 := v01.NewV01(context.Background(), constSrc(`{"U":"qt","Y":"w"}`))
defer v01.Close()
got := v01.Read()
if len(got) != 0 {
@@ -61,7 +63,7 @@ func TestV01WithCfg(t *testing.T) {
})
t.Run("player2", func(t *testing.T) {
v01 := button.NewV01(context.Background(), constSrc(`{"U":"bel","Y":"w","N":"w"}`))
v01 := v01.NewV01(context.Background(), constSrc(`{"U":"bel","Y":"w","N":"w"}`))
defer v01.Close()
got := v01.Read()
if len(got) != 2 {
@@ -82,6 +84,7 @@ func TestV01Feedback(t *testing.T) {
os.WriteFile(p, []byte(`
feedback:
addr: :27071
ttsurl: http://localhost:15002
users:
bel:
player: 2
@@ -94,11 +97,11 @@ func TestV01Feedback(t *testing.T) {
- transformation:
w: i
`), os.ModePerm)
button.FlagButtonV01Config = p
v01.FlagParseV01Config = p
ctx, can := context.WithCancel(context.Background())
defer can()
v01 := button.NewV01(ctx, constSrc(`{"U":"qt","Y":"w"}`))
v01 := v01.NewV01(ctx, constSrc(`{"U":"qt","Y":"w"}`))
defer v01.Close()
for {
@@ -134,4 +137,54 @@ func TestV01Feedback(t *testing.T) {
t.Error(b)
}
})
t.Run("change broadcast", func(t *testing.T) {
want := `my new broadcast`
r, _ := http.NewRequest(http.MethodPut, "http://localhost:27071/broadcast", strings.NewReader(want))
resp, err := http.DefaultClient.Do(r)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
resp, err = http.Get("http://localhost:27071")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if string(b) != want {
t.Error(string(b))
}
})
t.Run("tts", func(t *testing.T) {
if os.Getenv("INTEGRATION_TTS") != "true" {
t.Skip("$INTEGRATION_TTS is not true")
}
for i := 0; i < 2; i++ {
resp, err := http.Get("http://localhost:27071/?say=hello%20world")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
}
time.Sleep(time.Millisecond * 2500)
r, _ := http.NewRequest(http.MethodGet, "http://localhost:27071", nil)
r.Header.Set("say", "No, HTTP does not define any limit. However most web servers do limit size of headers they accept. For example in Apache default limit is 8KB, in IIS it's 16K. Server will return 413 Entity Too Large error if headers size exceeds that limit.")
resp, err := http.DefaultClient.Do(r)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
time.Sleep(time.Millisecond * 8500)
})
}
type constSrc string
func (c constSrc) Close() {}
func (c constSrc) Read() []byte {
return []byte(c)
}

View File

@@ -0,0 +1,92 @@
package v01
import (
"context"
"testing"
)
func TestV01TransformationPipe(t *testing.T) {
cases := map[string]struct {
input string
xform map[string]string
want string
}{
"empty input": {
xform: map[string]string{"a": "bc"},
},
"empty xform": {
input: "aa",
want: "aa",
},
"all": {
input: "aa",
xform: map[string]string{"a": "cc"},
want: "cc",
},
"last": {
input: "ba",
xform: map[string]string{"a": "cc"},
want: "bc",
},
"first": {
input: "ab",
xform: map[string]string{"a": "cc"},
want: "cb",
},
"noop": {
input: "bb",
xform: map[string]string{"a": "bc"},
want: "bb",
},
}
for name, d := range cases {
c := d
t.Run(name, func(t *testing.T) {
got := transformation(c.xform).pipe(c.input)
if got != c.want {
t.Errorf("%+v(%s) want %s got %s", c.xform, c.input, c.want, got)
}
})
}
}
func TestV01Quiet(t *testing.T) {
ctx, can := context.WithCancel(context.Background())
defer can()
v01 := NewV01(ctx, constSrc(`{"Y":"a", "N":"b"}`))
v01.cfg.Quiet = false
if got := v01.Read(); len(got) != 2 {
t.Error(len(got))
} else if got[0].Char != 'a' {
t.Error(got[0].Char)
} else if got[0].Down != true {
t.Error(got[0].Down)
} else if got[1].Char != 'b' {
t.Error(got[1].Char)
} else if got[1].Down != false {
t.Error(got[1].Down)
}
v01.cfg.Quiet = true
if got := v01.Read(); len(got) != 2 {
t.Error(len(got))
} else if got[0].Char != 'a' {
t.Error(got[0].Char)
} else if got[0].Down != false {
t.Error(got[0].Down)
} else if got[1].Char != 'b' {
t.Error(got[1].Char)
} else if got[1].Down != false {
t.Error(got[1].Down)
}
}
type constSrc string
func (c constSrc) Close() {}
func (c constSrc) Read() []byte {
return []byte(c)
}

View File

@@ -11,7 +11,7 @@ import (
)
var (
chSigUsr1 = func() chan os.Signal {
ChSigUsr1 = func() chan os.Signal {
c := make(chan os.Signal, 1)
signal.Notify(c, syscall.SIGUSR1)
return c
@@ -24,7 +24,7 @@ type Refresh struct {
}
func NewRefresh(ctx context.Context, newWrap func() Wrap) *Refresh {
return NewRefreshWith(ctx, newWrap, chSigUsr1)
return NewRefreshWith(ctx, newWrap, ChSigUsr1)
}
func NewRefreshWith(ctx context.Context, newWrap func() Wrap, ch <-chan os.Signal) *Refresh {

View File

@@ -3,6 +3,7 @@ package wrap
import (
"context"
"mayhem-party/src/device/input/button"
"mayhem-party/src/device/input/raw"
"os"
"syscall"
"testing"
@@ -50,7 +51,7 @@ func TestRefreshDoesntCloseSources(t *testing.T) {
newParsers := 0
newParser := func() Wrap {
newParsers += 1
return button.NewPlaintext(src)
return src
}
ctx, can := context.WithCancel(context.Background())
defer can()
@@ -69,10 +70,10 @@ func TestRefreshDoesntCloseSources(t *testing.T) {
}
for i := 0; i < 5; i++ {
chSigUsr1 <- syscall.SIGINT
ChSigUsr1 <- syscall.SIGINT
}
time.Sleep(time.Millisecond * 250)
if want := (telemetrySrc{reads: 5}); *src != want {
if want := (telemetrySrc{reads: 5, closeWraps: 5}); *src != want {
t.Errorf("want %+v, got %+v", want, *src)
} else if newParsers != 6 {
t.Error(newParsers)
@@ -80,15 +81,21 @@ func TestRefreshDoesntCloseSources(t *testing.T) {
}
type telemetrySrc struct {
closeWraps int
closes int
reads int
}
func (src *telemetrySrc) CloseWrap() raw.Raw {
src.closeWraps += 1
return nil
}
func (src *telemetrySrc) Close() {
src.closes += 1
}
func (src *telemetrySrc) Read() []byte {
func (src *telemetrySrc) Read() []button.Button {
src.reads += 1
return []byte("foo")
return nil
}

View File

@@ -19,9 +19,9 @@ type Wrap interface {
CloseWrap() raw.Raw
}
func New(ctx context.Context, srcFunc func() button.Parser) Wrap {
func New(ctx context.Context, parserFunc func() Wrap) Wrap {
maker := func() Wrap {
return srcFunc()
return parserFunc()
}
if FlagBuffered {
oldMaker := maker

View File

@@ -1,4 +1,8 @@
todo:
- https via home.blapointe and rproxy
- json patch endpoint for config
- endpoint for v01 to start read-only mode so when hotword spoken, players are dcd
without losing players; press a hotkey that is bound to dolphin emulator pause
- todo: rotation triggers
subtasks:
- todo: stdin
@@ -71,3 +75,8 @@ done:
ts: Sat Mar 25 11:29:52 MDT 2023
- todo: we have 7 players oooooof
ts: Sat Mar 25 11:29:52 MDT 2023
- todo: endpoint for v01 to start read-only mode so when hotword spoken, players are
dcd without losing players
ts: Sat Mar 25 23:16:47 MDT 2023
- todo: tts for when someone said the word via larynx docker + http.get + https://pkg.go.dev/github.com/faiface/beep@v1.1.0/wav
ts: Sun Mar 26 09:57:02 MDT 2023