diff --git a/README.md b/README.md index 2983b1f..89fa0bd 100644 --- a/README.md +++ b/README.md @@ -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: @@ -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 diff --git a/fluidsynth.sh b/fluidsynth.sh new file mode 100755 index 0000000..0fb4c33 --- /dev/null +++ b/fluidsynth.sh @@ -0,0 +1 @@ +fluidsynth -a pulseaudio -g 5 /usr/share/soundfonts/FluidR3_GM.sf2 \ No newline at end of file diff --git a/gui/src/Home.tsx b/gui/src/Home.tsx index 39238f8..5a6f549 100644 --- a/gui/src/Home.tsx +++ b/gui/src/Home.tsx @@ -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"); }; @@ -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`); }; } @@ -220,7 +222,9 @@ function Home() {
wfho-video @@ -268,9 +272,7 @@ function Home() { width={1000} keyboardShortcuts={keyboardShortcuts} /> - +
); diff --git a/internal/client/client.go b/internal/client/client.go index 646c05e..b227f2e 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -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" @@ -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) @@ -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 { @@ -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) @@ -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() @@ -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 { @@ -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(), @@ -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 { diff --git a/internal/player/player.go b/internal/player/player.go index 4bad6be..eed428d 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -3,6 +3,7 @@ package player import ( + "context" "log" "time" @@ -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() } diff --git a/internal/serial/serial.go b/internal/serial/serial.go index 4a29059..f64bfe4 100644 --- a/internal/serial/serial.go +++ b/internal/serial/serial.go @@ -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) diff --git a/internal/server/api.go b/internal/server/api.go index 4921108..3c5515b 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -2,6 +2,7 @@ package server import ( "bufio" + "context" "encoding/json" "log" "net/http" @@ -42,8 +43,6 @@ func (s *Server) handleAPI() http.Handler { }) } -var stopPlayingChan = make(chan bool) - func (s *Server) apiHandlePiston(w http.ResponseWriter, r *http.Request) { // make sure it's a post if r.Method != "POST" { @@ -129,20 +128,17 @@ func (s *Server) apiStops(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusBadRequest) return } - var program int if piston == 0 { program = 7 } else { program = piston - 1 } - s.notesChan <- types.ProgramChange{ Time: time.Now(), Channel: 0, Program: uint8(program), } - s.state.SetPiston(piston, stops) } else { // otherwise if there's no piston this must be the cancel @@ -191,14 +187,15 @@ func (s *Server) handlePanicButton() { } func (s *Server) apiHandleStopButton(w http.ResponseWriter, r *http.Request) { - select { - case stopPlayingChan <- true: - time.Sleep(time.Millisecond * 500) - s.handlePanicButton() - w.WriteHeader(http.StatusOK) - default: - w.WriteHeader(http.StatusInternalServerError) + if s.stopPlaying == nil { + w.WriteHeader(http.StatusBadRequest) + return } + s.stopPlaying() + s.stopPlaying = nil + time.Sleep(time.Millisecond * 500) + s.handlePanicButton() + w.WriteHeader(http.StatusOK) } func (s *Server) apiHandlePanic(w http.ResponseWriter, r *http.Request) { @@ -213,12 +210,6 @@ func (s *Server) apiHandlePlay(w http.ResponseWriter, r *http.Request) { return } - // stop anything in progress - select { - case stopPlayingChan <- true: - default: - } - // get the filename from the body scanner := bufio.NewScanner(r.Body) if !scanner.Scan() { @@ -227,7 +218,16 @@ func (s *Server) apiHandlePlay(w http.ResponseWriter, r *http.Request) { } filename := scanner.Text() // start a player that opens the filename specified - go player.PlayMidiFile(s.notesChan, "midi/"+filename, stopPlayingChan, true) + var ctx context.Context + // stop the current player before starting a new one + if s.stopPlaying != nil { + s.stopPlaying() + time.Sleep(time.Millisecond * 500) + s.handlePanicButton() + time.Sleep(time.Second * 3) + } + ctx, s.stopPlaying = context.WithCancel(context.Background()) + go player.PlayMidiFile(ctx, s.notesChan, "midi/"+filename, true) // send a success message w.WriteHeader(http.StatusOK) } diff --git a/internal/server/server.go b/internal/server/server.go index 83796a8..3a194fa 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -36,6 +36,7 @@ type Server struct { MidiTuxChan chan types.MidiTuxMessage out midi.Out in midi.In + stopPlaying context.CancelFunc } func (s *Server) startHTTP() { diff --git a/main.go b/main.go index 8396252..f95aab8 100644 --- a/main.go +++ b/main.go @@ -24,12 +24,13 @@ func main() { list := flag.Bool("list", false, "list available ports") mode := flag.String("mode", "local", "client, server, or local (runs both)") protocol := flag.String("protocol", "tcp", "tcp only (udp not implemented yet)") - profile := flag.String("profile", "profiles/wosp/", "profiles path") + profile := flag.String("profile", "profiles/default/", "profiles path") stdinMode := flag.Bool("stdin", false, "read from stdin") delay := flag.Int("delay", 0, "artificial delay in ms") - file := flag.String("file", "", "midi file to play") dontControlVolume := flag.Bool("novolume", false, "have WFHO control client volume") dontRecord := flag.Bool("norecord", false, "continuously record midi") + serialPath := flag.String("serialPath", "", "serial port path") + serialBaud := flag.Int("serialBaud", 115200, "serial port baud rate") flag.Parse() @@ -79,13 +80,13 @@ func main() { case "server": go server.Run() case "client": - go client.Client(*midiPort, *serverIP, *serverPort, *protocol, *stdinMode, *delay, *file, midiTuxChan, *profile, *dontControlVolume) + go client.Client(*midiPort, *serverIP, *serverPort, *protocol, *stdinMode, *delay, midiTuxChan, *profile, *dontControlVolume, *serialPath, *serialBaud) case "local": // run both (unless serverIP is set, and sleep forever if *serverIP == "localhost" { go server.Run() } - go client.Client(*midiPort, *serverIP, *serverPort, *protocol, *stdinMode, *delay, *file, midiTuxChan, *profile, *dontControlVolume) + go client.Client(*midiPort, *serverIP, *serverPort, *protocol, *stdinMode, *delay, midiTuxChan, *profile, *dontControlVolume, *serialPath, *serialBaud) default: log.Fatalf("Unknown mode: %s. Must be 'server' or 'client'\n", *mode) } diff --git a/profiles/default/channels.csv b/profiles/default/channels.csv new file mode 100644 index 0000000..adb8d67 --- /dev/null +++ b/profiles/default/channels.csv @@ -0,0 +1,17 @@ +# input, output, offset +1,1,0 +2,2,0 +3,3,0 +4,4,0 +5,5,0 +6,6,0 +7,7,0 +8,8,0 +9,9,0 +10,10,0 +11,11,0 +12,12,0 +13,13,0 +14,14,0 +15,15,0 +16,16,0 diff --git a/profiles/default/stops.yaml b/profiles/default/stops.yaml new file mode 100644 index 0000000..c6c80c6 --- /dev/null +++ b/profiles/default/stops.yaml @@ -0,0 +1,87 @@ +# last bit on code is assumed to be 00 for off and 7f for on +stops: + - Swell: + - name: "Bourdon 16'" + code: "b0 63 00 b0 62 0b b0 06" + - name: "Gedackt 8'" + code: "b0 63 00 b0 62 28 b0 06" + - name: "Viola 8'" + code: "b0 63 00 b0 62 22 b0 06" + - name: "Viola Celeste 8'" + code: "b0 63 00 b0 62 23 b0 06" + - name: "Spitz prinzipal 4'" + code: "b0 63 00 b0 62 38 b0 06" + - name: "Koppel flote 4'" + code: "b0 63 00 b0 62 3f b0 06" + - name: "Nasat 2-2/3'" + code: "b0 63 00 b0 62 4c b0 06" + - name: "Blockflote 2'" + code: "b0 63 00 b0 62 54 b0 06" + - name: "Basson 16'" + code: "b0 63 00 b0 62 72 b0 06" + - name: "Trompette 8'" + code: "b0 63 01 b0 62 00 b0 06" + - name: "Tremulant" + code: "b0 63 01 b0 62 30 b0 06" + - name: "MIDI to Swell" + code: "b0 63 01 b0 62 5e b0 06" + - Great: + - name: "Principal 8'" + code: "b1 63 00 b1 62 1f b1 06" + - name: "Gedackt 8'" + code: "b1 63 00 b1 62 28 b1 06" + - name: "Octave 4'" + code: "b1 63 00 b1 62 38 b1 06" + - name: "Koppel flote 4'" + code: "b1 63 00 b1 62 3f b1 06" + - name: "Super Octave 2'" + code: "b1 63 00 b1 62 50 b1 06" + - name: "Mixture IV" + code: "b1 63 00 b1 62 64 b1 06" + - name: "Chimes" + code: "b1 63 01 b1 62 21 b1 06" + - name: "Tremulant" + code: "b1 63 01 b1 62 30 b1 06" + - name: "Swell to Great" + code: "b1 63 01 b1 62 77 b1 06" + - name: "MIDI to Great" + code: "b1 63 01 b1 62 5f b1 06" + - Pedal: + - name: "Bourdon 16'" + code: "b2 63 00 b2 62 0c b2 06" + - name: "Lieb lich gedackt 16'" + code: "b2 63 00 b2 62 0f b2 06" + - name: "Octave 8'" + code: "b2 63 00 b2 62 1f b2 06" + - name: "Gedackt 8'" + code: "b2 63 00 b2 62 28 b2 06" + - name: "Choral bass 4'" + code: "b2 63 00 b2 62 38 b2 06" + - name: "Mixture II" + code: "b2 63 00 b2 62 64 b2 06" + - name: "Basson 16'" + code: "b2 63 00 b2 62 72 b2 06" + - name: "Trompette 8'" + code: "b2 63 01 b2 62 00 b2 06" + - name: "Great to Pedal" + code: "b2 63 01 b2 62 78 b2 06" + - name: "Swell to Pedal" + code: "b2 63 01 b2 62 77 b2 06" + - name: "MIDI to Pedal" + code: "b2 63 01 b2 62 60 b2 06" + - General: + - name: "Memory B" + - name: "Add Stops" + - name: "Bass Coupler" + code: "b7 63 01 b7 62 44 b7 06" + - name: "Melody Coupler" + code: "b7 63 01 b7 62 45 b7 06" + - name: "Romantic Tuning Off" + code: "b7 63 01 b7 62 65 b7 06" + - name: "Reverb" + - name: "." + code: "b7 63 01 b7 62 31 b7 06" + - name: "Console Speakers Off" + code: "b7 63 01 b7 62 69 b7 06" + - name: "External Speakers Off" + code: "b7 63 01 b7 62 74 b7 06" diff --git a/screenshots/gui01.png b/screenshots/gui01.png new file mode 100644 index 0000000..8d6efc7 Binary files /dev/null and b/screenshots/gui01.png differ diff --git a/wfh-organist.service b/wfh-organist.service new file mode 100644 index 0000000..554d35f --- /dev/null +++ b/wfh-organist.service @@ -0,0 +1,13 @@ +[Unit] +Description=wfh-organist +After=network.target + +[Service] +User=pi +WorkingDirectory=/home/pi/wfh-organist +ExecStart=/home/pi/wfh-organist/wfh-organist -mode server +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target