Skip to content

Commit

Permalink
more contexts, update readme and remove warnings
Browse files Browse the repository at this point in the history
  • Loading branch information
jrcichra committed Jul 28, 2022
1 parent c447335 commit 4e26c2d
Show file tree
Hide file tree
Showing 13 changed files with 264 additions and 121 deletions.
64 changes: 52 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,6 @@

Be a Work-From-Home Organist. Written in Go. Send MIDI over regular TCP/IP to your local church.

# Disclaimer

This is a work-in-progress that is constantly evolving on the `main` branch. For known working versions, see the [releases](https://github.com/jrcichra/wfh-organist/releases) page. There will be breaking changes between releases.

# Introduction

This program listens to MIDI input and sends the notes over TCP. The program used in server or client mode, or both at the same time. This leads to some interesting use cases:
Expand All @@ -23,38 +19,82 @@ This program listens to MIDI input and sends the notes over TCP. The program use

# Build notes

I used Go 1.17 for this project, but older versions will probably work. There are external cgo dependencies so you'll need a few packages from your distro's package manager. This also means I can't easily provide cross-architecture targets
I used Go 1.17 for this project, but older versions will probably work. There are external cgo dependencies so you'll need a few packages from your distro's package manager. This also means I can't easily provide cross-architecture targets.

# Usage
# Server Install

- Download a recent version of [Go](https://go.dev/dl/) for your operating system
- Download a recent version of [Go](https://go.dev/dl/), [node](https://nodejs.org/en/), and [yarn](https://yarnpkg.com/) for your operating system. Replace `VITE_VIDEO_URL` with the URL of an mjpeg stream at the church (if configured).
- `git clone https://github.com/jrcichra/wfh-organist.git`
- `go build`
- `cd gui`
- `yarn install && VITE_VIDEO_URL=http://localhost:8080/video yarn build`
- `./wfh-organist -help`

I've included a sample systemd service for the server.

WFHO will serve static HTTP content under `gui/dist`, where it should have compiled the Vite app.

Organs are configurable through the `profiles` directory. `channels.csv` is a CSV file which translates messages on the client-side before sending them over the network. This allows your home organ's MIDI channels to be mapped to a church's MIDI channels or transposed. When remotely playing at different churches, different profiles can be selected client-side with the `-profile` flag.

`stops.yaml` defines the stop groups shown on the website and byte sequences for each stop. By sniffing the MIDI output of the organ, you can build a list of virtual stops. The example set of stops is for an [Allen Organ MDS-1](https://www.allenorgan.com/support/ownersmanuals/033-0050.pdf).

The `default` profile makes no modification to the MIDI notes.

# Client Install

- Same steps as above, except node and yarn are not required. Only `go build` and run the binary.
- The client does support listening to an expression pedal sending messages over a serial port. Currently this is not configurable.

An expression pedal can be used over serial. Just specify the TTY and baud rate. Currently the intensity is non-configurable, but you can modify `expressionPercentage` in `internal/serial.go`.

```
Usage of ./wfh-organist:
-delay int
artificial delay in ms
-list
list available ports
-midi int
midi port (default 0)
midi port (default 1)
-mode string
client, server, or local (runs both) (default "local")
-norecord
continuously record midi
-novolume
have WFHO control client volume
-port int
server port (default 3131)
-profile string
profiles path (default "profiles/wosp/")
-protocol string
tcp only (udp not implemented yet) (default "tcp")
-serialBaud int
serial port baud rate (default 115200)
-serialPath string
serial port path
-server string
server IP (default "localhost")
-stdin
read from stdin
```

# Web GUI

The web server is accessible on port `8080`. Through the GUI, stops from `stops.yaml` will appear. General pistons are currently hard-coded for the MDS-1. MIDI files located in `./midi` on the server will be available to play. The red PANIC button will stop any stuck notes. The on-screen keyboard will send notes to the channel in the drop-down. ![GUI example](screenshots/gui01.png)

# Recording

WFHO is set to always record on the MIDI-IN port from your USB to MIDI adapter. It will separate recordings based on a small timeout. It saves the midi files with as `$EPOCH.mid`

# Design choices

- ~~Simplicity - It should be easy to understand what the code is doing~~ <-- the code needs refactored
- TCP - This program was implimented with TCP but could also use UDP. I chose TCP to avoid 'stuck notes' in the event a NoteOff packet was dropped. TCP has the downside of effectively 'losing notes'. When a lag spike hits, the TCP stream will catch up and all the MIDI events will happen as fast as possible. This leads to gaps because the NoteOn and NoteOff happen almost instantaneously.
- TCP - This program was implimented with TCP but could also use UDP. I chose TCP to avoid 'stuck notes' in the event a NoteOff packet was dropped. TCP has the downside of effectively 'losing notes'. When a lag spike hits, the TCP stream will catch up and all the MIDI events will happen as fast as possible. This leads to gaps because the NoteOn and NoteOff happen almost instantaneously. TCP over the Internet in 2022 has been stable enough where real-time MIDI protocols haven't been a priority for the project.
- Single Binary - Instead of managing two binaries, "server/client", I combined them into a single binary. I felt the space increase was worth the flexability and simplicity of managing one binary. The mode is controlled with a single flag. There was also a lot of shared code between the server and client, so making it a single binary was easy.

# Disclaimer
# Recommended Applications

This program is not intended for production use. I do not claim that this will work flawlessly for remote performances. Please anaylze the code and determine if your connection stability and latency will work with the way I have implimented this program.
- I'm using Tailscale to simplify the connection process. Both the server and client can be behind NATs and moved between churches without having to configure anything.
- I'm also using [trx](http://www.pogo.org.uk/~mark/trx/) over the Tailscale connection for two-way audio. Since it's sending unencrypted audio streams it's a little easier to deal with inside a VPN.
- For video, I'm using [motion](https://motion-project.github.io/) with a low framerate on a Raspberry Pi.

# Testing

Expand Down
1 change: 1 addition & 0 deletions fluidsynth.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
fluidsynth -a pulseaudio -g 5 /usr/share/soundfonts/FluidR3_GM.sf2
20 changes: 11 additions & 9 deletions gui/src/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,12 +115,14 @@ function Home() {
// set up the websocket
if (!websocket.current) {
let wsProtoco = "";
if (location.protocol === 'https:') {
wsProtoco = "wss"
if (location.protocol === "https:") {
wsProtoco = "wss";
} else {
wsProtoco = "ws"
wsProtoco = "ws";
}
websocket.current = new WebSocket(`${wsProtoco}://${document.location.host}/ws`);
websocket.current = new WebSocket(
`${wsProtoco}://${document.location.host}/ws`
);
websocket.current.onopen = () => {
console.log("Successfully Connected");
};
Expand All @@ -132,7 +134,7 @@ function Home() {
};
websocket.current.onmessage = (event) => {
console.log("Socket Message: ", event.data);
setMidiLog(midiLog + "\n" + event.data)
setMidiLog(`${midiLog}\n${event.data}\nbob`);
};
}

Expand Down Expand Up @@ -220,7 +222,9 @@ function Home() {
</div>
<div className="col">
<img
src="https://wfho-video.jrcichra.dev/"
src={
import.meta.env.VITE_VIDEO_URL ?? "https://wfho-video.jrcichra.dev/"
}
alt="wfho-video"
className="remoteVideo"
/>
Expand Down Expand Up @@ -268,9 +272,7 @@ function Home() {
width={1000}
keyboardShortcuts={keyboardShortcuts}
/>
<textarea id="midilog">
{midiLog}
</textarea>
<textarea id="midilog" value={midiLog}></textarea>
</div>
</div>
);
Expand Down
21 changes: 4 additions & 17 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import (
"github.com/fatih/color"
"github.com/jrcichra/wfh-organist/internal/common"
"github.com/jrcichra/wfh-organist/internal/parser/channels"
"github.com/jrcichra/wfh-organist/internal/player"
"github.com/jrcichra/wfh-organist/internal/serial"
"github.com/jrcichra/wfh-organist/internal/types"
"github.com/jrcichra/wfh-organist/internal/volume"
Expand Down Expand Up @@ -44,15 +43,14 @@ func dial(serverIP string, serverPort int, protocol string) net.Conn {
}
}

func Client(midiPort int, serverIP string, serverPort int, protocol string, stdinMode bool, delay int, file string, midiTuxChan chan types.MidiTuxMessage, profile string, dontControlVolume bool) {

// TODO: this function could be cleaner with a struct
func Client(midiPort int, serverIP string, serverPort int, protocol string, stdinMode bool, delay int, midiTuxChan chan types.MidiTuxMessage, profile string, dontControlVolume bool, serialPath string, serialBaud int) {
ctx, cancel := context.WithCancel(context.Background())

// read the csv
csvRecords := channels.ReadFile(profile + "channels.csv")

notesChan := make(chan interface{})
stopChan := make(chan bool)

drv, err := driver.New()
common.Must(err)
Expand Down Expand Up @@ -80,17 +78,13 @@ func Client(midiPort int, serverIP string, serverPort int, protocol string, stdi
go http.ListenAndServe(":8081", nil)

// in either mode read the serial for now
go serial.ReadSerial(notesChan)
go serial.ReadSerial(serialPath, serialBaud, notesChan)

if stdinMode {
go stdinClient(notesChan)
}

if file == "" {
go midiClient(midiPort, delay, notesChan, in)
} else {
go player.PlayMidiFile(notesChan, file, stopChan, false)
}
go midiClient(midiPort, delay, notesChan, in)

// things that would need a new connection if the connection was lost
for {
Expand All @@ -106,9 +100,7 @@ func Client(midiPort int, serverIP string, serverPort int, protocol string, stdi
}

func stdinClient(notesChan chan interface{}) {

channel := make(chan types.Raw)

//get stdin in a goroutine
go func() {
scanner := bufio.NewScanner(os.Stdin)
Expand Down Expand Up @@ -160,7 +152,6 @@ func stdinClient(notesChan chan interface{}) {
}

func sendNotesClient(ctx context.Context, conn net.Conn, delay int, notesChan chan interface{}, csvRecords []types.MidiCSVRecord, dontControlVolume bool) {

t := timer.NewTimer(5 * time.Second)
if !dontControlVolume {
t.Start()
Expand Down Expand Up @@ -268,7 +259,6 @@ func sendNotesClient(ctx context.Context, conn net.Conn, delay int, notesChan ch
reconnect = true
}
}

case channel.ControlChange:
channel := channels.CheckChannel(v.Channel(), csvRecords)
if channel != 255 {
Expand Down Expand Up @@ -349,7 +339,6 @@ func sendNotesClient(ctx context.Context, conn net.Conn, delay int, notesChan ch
}

func midiClient(midiPort int, delay int, notesChan chan interface{}, in midi.In) {

// listen for MIDI messages
rd := reader.New(
reader.NoLogger(),
Expand All @@ -366,10 +355,8 @@ func midiClient(midiPort int, delay int, notesChan chan interface{}, in midi.In)

// Listen for midi notes coming back so they can be printed
func midiClientFeedback(cancel context.CancelFunc, conn net.Conn, writers []*writer.Writer, out midi.Out, midiTuxChan chan types.MidiTuxMessage) {

var t types.TCPMessage
dec := gob.NewDecoder(conn)

for {
err := dec.Decode(&t)
if err == io.EOF {
Expand Down
107 changes: 49 additions & 58 deletions internal/player/player.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package player

import (
"context"
"log"
"time"

Expand All @@ -13,76 +14,66 @@ import (
)

// https://pkg.go.dev/gitlab.com/gomidi/midi/player#Player
func PlayMidiFile(notesChan chan interface{}, file string, stopPlayingChan chan bool, wrap bool) {

func PlayMidiFile(ctx context.Context, notesChan chan interface{}, file string, wrap bool) {
log.Println("Playing midi file:", file)
stopRoutine := make(chan struct{})
stopBool := false
player, err := player.SMF(file)
if err != nil {
log.Println(err)
return
}
go func() {
// wait for when we should stop
<-stopPlayingChan
// these GetMessages might still be around so we need to update a bool to not sound them and finish the file
stopBool = true
stopRoutine <- struct{}{} // this frees up the player resources
}()

player.GetMessages(func(wait time.Duration, m midi.Message, track int16) {

if !stopBool {
// sleep for the wait amount
time.Sleep(wait)
// send the message to the channel if it's a noteon or noteoff
switch v := m.(type) {
case channel.NoteOn:
if wrap {
notesChan <- types.NoteOn{
Channel: v.Channel(),
Key: v.Key(),
Velocity: v.Velocity(),
Time: time.Now(),
}
} else {
notesChan <- v
select {
case <-ctx.Done():
return
default:
}
// sleep for the wait amount
time.Sleep(wait)
// send the message to the channel if it's a noteon or noteoff
switch v := m.(type) {
case channel.NoteOn:
if wrap {
notesChan <- types.NoteOn{
Channel: v.Channel(),
Key: v.Key(),
Velocity: v.Velocity(),
Time: time.Now(),
}
case channel.NoteOff:
if wrap {
notesChan <- types.NoteOff{
Channel: v.Channel(),
Key: v.Key(),
Time: time.Now(),
}
} else {
notesChan <- v
} else {
notesChan <- v
}
case channel.NoteOff:
if wrap {
notesChan <- types.NoteOff{
Channel: v.Channel(),
Key: v.Key(),
Time: time.Now(),
}
case channel.ProgramChange:
if wrap {
notesChan <- types.ProgramChange{
Channel: v.Channel(),
Program: v.Program(),
Time: time.Now(),
}
} else {
notesChan <- v
} else {
notesChan <- v
}
case channel.ProgramChange:
if wrap {
notesChan <- types.ProgramChange{
Channel: v.Channel(),
Program: v.Program(),
Time: time.Now(),
}
case channel.ControlChange:
if wrap {
notesChan <- types.ControlChange{
Channel: v.Channel(),
Controller: v.Controller(),
Value: v.Value(),
Time: time.Now(),
}
} else {
notesChan <- v
} else {
notesChan <- v
}
case channel.ControlChange:
if wrap {
notesChan <- types.ControlChange{
Channel: v.Channel(),
Controller: v.Controller(),
Value: v.Value(),
Time: time.Now(),
}
} else {
notesChan <- v
}
}
})
// sleep until asked to stop
<-stopRoutine
<-ctx.Done()
}
7 changes: 5 additions & 2 deletions internal/serial/serial.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ looks like 42 decimal is the lowest value. Seeing numbers separated by about 4.
*/

// read the serial port assuming it's the expression pedal
func ReadSerial(notesChan chan interface{}) {
c := &serial.Config{Name: "/dev/ttyACM0", Baud: 115200}
func ReadSerial(path string, baud int, notesChan chan interface{}) {
if path == "" {
return
}
c := &serial.Config{Name: path, Baud: baud}
s, err := serial.OpenPort(c)
if err != nil {
common.Cont(err)
Expand Down
Loading

0 comments on commit 4e26c2d

Please sign in to comment.