Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 70 additions & 6 deletions internal/coremidi/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,90 @@ package coremidi
#cgo LDFLAGS: -framework CoreMIDI -framework CoreFoundation -framework CoreServices
#include <CoreMIDI/CoreMIDI.h>
#include <CoreServices/CoreServices.h>

// goMidiNotify is the exported Go callback; declared here so notifyBridgeFn can call it.
extern void goMidiNotify(int msgID, void *handle);

// notifyBridgeFn is the C-side bridge registered with MIDIClientCreate.
static void notifyBridgeFn(const MIDINotification *msg, void *refCon) {
goMidiNotify((int)msg->messageID, refCon);
}

// newMIDIClientWithNotify wraps MIDIClientCreate with our bridge callback.
static OSStatus newMIDIClientWithNotify(CFStringRef name, void *refCon, MIDIClientRef *outClient) {
return MIDIClientCreate(name, notifyBridgeFn, refCon, outClient);
}
*/
import "C"
import "fmt"
import (
"fmt"
"runtime/cgo"
"unsafe"
)

// Client wraps a CoreMIDI client reference and an optional notification channel.
type Client struct {
client C.MIDIClientRef
client C.MIDIClientRef
NotifyCh <-chan int32 // receives MIDINotificationMessageID values; nil if unsupported
notifyCh chan int32
notifyHdl cgo.Handle
}

// goMidiNotify is called from the C notifyBridgeFn when CoreMIDI fires a
// setup-change notification. It forwards the message ID to the Go channel
// stored in the cgo.Handle.
//
//export goMidiNotify
func goMidiNotify(msgID C.int, handle unsafe.Pointer) {
if handle == nil {
return
}
h := cgo.Handle(uintptr(handle))
ch, _ := h.Value().(chan int32)
if ch == nil {
return
}
select {
case ch <- int32(msgID):
default:
// drop if consumer is slow — notification channel is best-effort
}
}

// NewClient creates a CoreMIDI client with the given display name and registers
// a notification callback so that device-change events are forwarded to the
// returned Client.NotifyCh channel. The cgo.Handle embedded in Client must be
// released by calling Dispose() when the client is no longer needed.
func NewClient(name string) (client Client, err error) {
var clientRef C.MIDIClientRef
notifyCh := make(chan int32, 16)
h := cgo.NewHandle(notifyCh)

stringToCFString(name, func(cfName C.CFStringRef) {
osStatus := C.MIDIClientCreate(cfName, nil, nil, &clientRef)

var clientRef C.MIDIClientRef
osStatus := C.newMIDIClientWithNotify(cfName, unsafe.Pointer(uintptr(h)), &clientRef)
if osStatus != C.noErr {
err = fmt.Errorf("%d: failed to create a client", int(osStatus))
} else {
client = Client{clientRef}
client = Client{
client: clientRef,
NotifyCh: notifyCh,
notifyCh: notifyCh,
notifyHdl: h,
}
}
})

if err != nil {
h.Delete()
}
return
}

// Dispose releases the cgo.Handle associated with the notification channel.
// It must be called when the client is no longer needed to avoid a memory leak.
func (c *Client) Dispose() {
if c.notifyHdl != 0 {
c.notifyHdl.Delete()
c.notifyHdl = 0
}
}
91 changes: 91 additions & 0 deletions internal/midi/mididarwin/client_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ type internalPortConnection interface {
Disconnect()
}

// ClientMid is the macOS CoreMIDI implementation of contracts.ClientMIDI.
// It wraps a CoreMIDI client + input port and routes packets to a buffered
// output channel. A separate notification channel (coremidi.Client.NotifyCh)
// is used by WatchDevices to detect device hot-plug events.
type ClientMid struct {
logger contracts.Logger
eventChannel atomic.Value
Expand All @@ -43,6 +47,10 @@ type ClientMid struct {
closeChOnce sync.Once
}

// NewMIDIClient creates a CoreMIDI client and returns a ClientMIDI backed by
// the CoreMIDI framework. options.CoreMIDIConfig.ClientName is used as the
// CoreMIDI client name displayed in Audio MIDI Setup; it defaults to
// "GO MIDI Client" when nil.
func NewMIDIClient(options *contracts.ClientOptions) (contracts.ClientMIDI, error) {
if options.CoreMIDIConfig == nil {
options.CoreMIDIConfig = &contracts.CoreMIDIConfig{ClientName: "GO MIDI Client"}
Expand All @@ -61,6 +69,7 @@ func NewMIDIClient(options *contracts.ClientOptions) (contracts.ClientMIDI, erro
}, nil
}

// ListDevices returns all CoreMIDI sources currently visible to the system.
func (m *ClientMid) ListDevices() ([]contracts.DeviceInfo, error) {
sources, err := coremidi.AllSources()
if err != nil {
Expand All @@ -82,6 +91,8 @@ func (m *ClientMid) ListDevices() ([]contracts.DeviceInfo, error) {
return devices, nil
}

// SelectDevice opens an input port connected to the CoreMIDI source at index
// deviceID. Any previous port connection is disconnected first.
func (m *ClientMid) SelectDevice(deviceID int) error {
m.mu.Lock()
defer m.mu.Unlock()
Expand Down Expand Up @@ -117,6 +128,9 @@ func (m *ClientMid) SelectDevice(deviceID int) error {
return nil
}

// handleMIDIMessage is the CoreMIDI input-port callback. It converts a raw
// MIDI packet into a contracts.MIDI event, applies the event filter, and sends
// the event to the output channel. Packets with fewer than 3 bytes are dropped.
func (m *ClientMid) handleMIDIMessage(source coremidi.Source, packet coremidi.Packet) {
m.wg.Add(1)
defer m.wg.Done()
Expand Down Expand Up @@ -159,6 +173,46 @@ func (m *ClientMid) stopLocked() {
m.logger.Info("MIDI capture stopped")
}

// WatchDevices returns a channel that emits a DeviceEvent each time a MIDI
// device is connected or disconnected. It uses CoreMIDI's native notification
// mechanism (kMIDIMsgObjectAdded / kMIDIMsgObjectRemoved / kMIDIMsgSetupChanged)
// so events are delivered with minimal latency.
func (m *ClientMid) WatchDevices(ctx context.Context) (<-chan contracts.DeviceEvent, error) {
evCh := make(chan contracts.DeviceEvent, 16)

notifyCh := m.client.NotifyCh
if notifyCh == nil {
close(evCh)
return evCh, nil
}

prev, _ := m.ListDevices()

go func() {
defer close(evCh)
for {
select {
case <-ctx.Done():
return
case msgID, ok := <-notifyCh:
if !ok {
return
}
// React to setup changes (1), object added (2), object removed (3).
if msgID < 1 || msgID > 3 {
continue
}
curr, _ := m.ListDevices()
diffDevices(prev, curr, evCh)
prev = curr
}
}
}()

return evCh, nil
}

// closeOutCh closes the output channel exactly once.
func (m *ClientMid) closeOutCh() {
m.closeChOnce.Do(func() {
if m.outCh != nil {
Expand All @@ -167,6 +221,8 @@ func (m *ClientMid) closeOutCh() {
})
}

// Stop halts MIDI capture, disconnects the port connection, drains in-flight
// callbacks via wg.Wait, and calls Dispose to release the CoreMIDI cgo.Handle.
func (m *ClientMid) Stop() error {
m.mu.Lock()
if !m.capturing {
Expand All @@ -179,9 +235,14 @@ func (m *ClientMid) Stop() error {

m.wg.Wait()
m.closeOutCh()
m.client.Dispose()
return nil
}

// StartCapture begins streaming MIDI events from the selected device into the
// returned channel. The channel is closed when ctx is cancelled or Stop is
// called. Calling StartCapture while already capturing implicitly calls Stop
// first to reset state.
func (m *ClientMid) StartCapture(ctx context.Context) (<-chan contracts.MIDI, error) {
if err := m.Stop(); err != nil {
return nil, err
Expand Down Expand Up @@ -209,3 +270,33 @@ func (m *ClientMid) StartCapture(ctx context.Context) (<-chan contracts.MIDI, er

return ch, nil
}

// diffDevices compares two device lists and sends DeviceAdded / DeviceRemoved
// events to evCh for each difference.
func diffDevices(prev, curr []contracts.DeviceInfo, evCh chan<- contracts.DeviceEvent) {
for _, d := range curr {
if !containsDevice(prev, d) {
select {
case evCh <- contracts.DeviceEvent{Type: contracts.DeviceAdded, Device: d}:
default:
}
}
}
for _, d := range prev {
if !containsDevice(curr, d) {
select {
case evCh <- contracts.DeviceEvent{Type: contracts.DeviceRemoved, Device: d}:
default:
}
}
}
}

func containsDevice(list []contracts.DeviceInfo, d contracts.DeviceInfo) bool {
for _, item := range list {
if item.Name == d.Name && item.Manufacturer == d.Manufacturer {
return true
}
}
return false
}
10 changes: 10 additions & 0 deletions internal/midi/mididarwin/client_dummy.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"github.com/leandrodaf/midi/v2/sdk/contracts"
)

// dummyMIDIClient is the no-op ClientMIDI used on non-macOS systems when the
// mididarwin package is selected by the build system.
type dummyMIDIClient struct {
logger contracts.Logger
}
Expand Down Expand Up @@ -38,3 +40,11 @@ func (m *dummyMIDIClient) Stop() error {
m.logger.Warn("Stop called on dummy MIDI client")
return nil
}

// WatchDevices returns a channel that is closed when ctx is cancelled.
// No device events are ever emitted — this is a no-op stub.
func (m *dummyMIDIClient) WatchDevices(ctx context.Context) (<-chan contracts.DeviceEvent, error) {
ch := make(chan contracts.DeviceEvent)
go func() { <-ctx.Done(); close(ch) }()
return ch, nil
}
42 changes: 42 additions & 0 deletions internal/midi/mididarwin/client_dummy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"io"
"testing"
"time"

"github.com/leandrodaf/midi/v2/internal/logger"
"github.com/leandrodaf/midi/v2/internal/midi/mididarwin"
Expand Down Expand Up @@ -64,3 +65,44 @@ func TestDummyClient_Stop_ReturnsNil(t *testing.T) {
t.Errorf("expected Stop to return nil, got %v", err)
}
}

func TestDummyDarwinClient_WatchDevices_ClosesOnCancel(t *testing.T) {
client := newDummyDarwinClient(t)
ctx, cancel := context.WithCancel(context.Background())

ch, err := client.WatchDevices(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

cancel()

select {
case _, ok := <-ch:
if ok {
t.Errorf("expected channel to be closed after cancel")
}
case <-time.After(time.Second):
t.Fatal("timed out: channel not closed after context cancel")
}
}

func TestDummyDarwinClient_WatchDevices_NoEventsEmitted(t *testing.T) {
client := newDummyDarwinClient(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

ch, err := client.WatchDevices(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

select {
case ev, ok := <-ch:
if ok {
t.Errorf("expected no events from stub, got %+v", ev)
}
case <-time.After(20 * time.Millisecond):
// good — no events emitted
}
}
10 changes: 10 additions & 0 deletions internal/midi/midilinux/client_dummy.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"github.com/leandrodaf/midi/v2/sdk/contracts"
)

// dummyMIDIClient is the no-op ClientMIDI used on non-Linux systems when the
// midilinux package is selected by the build system.
type dummyMIDIClient struct {
logger contracts.Logger
}
Expand Down Expand Up @@ -38,3 +40,11 @@ func (m *dummyMIDIClient) Stop() error {
m.logger.Warn("Stop called on dummy MIDI client")
return nil
}

// WatchDevices returns a channel that is closed when ctx is cancelled.
// No device events are ever emitted — this is a no-op stub.
func (m *dummyMIDIClient) WatchDevices(ctx context.Context) (<-chan contracts.DeviceEvent, error) {
ch := make(chan contracts.DeviceEvent)
go func() { <-ctx.Done(); close(ch) }()
return ch, nil
}
42 changes: 42 additions & 0 deletions internal/midi/midilinux/client_dummy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"context"
"io"
"testing"
"time"

"github.com/leandrodaf/midi/v2/internal/logger"
"github.com/leandrodaf/midi/v2/internal/midi/midilinux"
Expand Down Expand Up @@ -59,3 +60,44 @@ func TestDummyLinuxClient_Stop_ReturnsNil(t *testing.T) {
t.Errorf("expected Stop to return nil, got %v", err)
}
}

func TestDummyLinuxClient_WatchDevices_ClosesOnCancel(t *testing.T) {
client := newDummyLinuxClient(t)
ctx, cancel := context.WithCancel(context.Background())

ch, err := client.WatchDevices(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

cancel()

select {
case _, ok := <-ch:
if ok {
t.Errorf("expected channel to be closed after cancel")
}
case <-time.After(time.Second):
t.Fatal("timed out: channel not closed after context cancel")
}
}

func TestDummyLinuxClient_WatchDevices_NoEventsEmitted(t *testing.T) {
client := newDummyLinuxClient(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()

ch, err := client.WatchDevices(ctx)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

select {
case ev, ok := <-ch:
if ok {
t.Errorf("expected no events from stub, got %+v", ev)
}
case <-time.After(20 * time.Millisecond):
// good — no events emitted
}
}
Loading
Loading