From c338655894c0881793deeb2c1d97b43897fbdf05 Mon Sep 17 00:00:00 2001 From: Leandro Ferreira Date: Thu, 14 May 2026 09:07:16 -0300 Subject: [PATCH 1/4] feat: modernize API, fix bugs, and add test suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Bug fixes - Fix Darwin deadlock: extract stopLocked() (no mutex), Stop() waits outside lock - Fix sync.Once preventing restart: replaced with capturing bool guard - Fix goroutine leak in coremidi NewDestination: close writeFd on creation failure - Fix Windows syscall error handling: use r1 != 0 only, ignore spurious GetLastError - Replace errors.New(fmt.Sprintf(...)) with fmt.Errorf throughout coremidi ## API modernization - Field: interface → plain struct {Key string; Value any} with standalone constructors (contracts.IntField, contracts.StringField, contracts.ErrField, etc.) - StartCapture: (chan MIDI) → (ctx context.Context) (<-chan MIDI, error) Client now creates and owns the channel; cancelled context triggers Stop()+close - Add WithChannelBufferSize(n) option (default 100) - Add WithLogDestination(dest, filePath...) option for console/file log routing - Fix LogLevel default bug: add logLevelSet bool + LogLevelIsSet() method - Fix LogLevel ordering: Debug < Info < Warn < Error < Fatal - Extract IsCommandAllowed to contracts.IsCommandAllowed (removes duplication) - Unexport DummyMIDIClient → dummyMIDIClient in darwin and windows stubs - Add sentinel error variables in mididarwin and midiwindows ## Code quality - Replace uber-zap with lightweight stdLogger using encoding/json fields - Add NewLoggerWithWriter(w io.Writer) for testability - NewLogger() / NewZapLogger() / NewStandardLogger() all available ## Tests (37 new test cases, all passing) - sdk/contracts/filter_test.go — IsCommandAllowed (5 cases) - sdk/contracts/mock.go + mock_test.go — MockMIDIClient for consumers (3 cases) - sdk/midi/options_setup_test.go — applyDefaultOptions (8 cases) - internal/logger/logger_wrapper_test.go — level filtering, fields, output (9 cases) ## Docs - Add .github/copilot-instructions.md - Add CLAUDE.md with build/test commands and architecture overview - Update README with new API usage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 53 +++++ CLAUDE.md | 54 +++++ README.md | 162 +++++++------- example/simple_use.go | 78 ++----- internal/coremidi/client.go | 32 +++ internal/coremidi/destination.go | 121 +++++++++++ internal/coremidi/device.go | 62 ++++++ internal/coremidi/entity.go | 63 ++++++ internal/coremidi/object.go | 64 ++++++ internal/coremidi/packet.go | 53 +++++ internal/coremidi/port.go | 127 +++++++++++ internal/coremidi/source.go | 67 ++++++ internal/coremidi/util.go | 67 ++++++ internal/logger/logger_wrapper.go | 177 ++++++---------- internal/logger/logger_wrapper_test.go | 110 ++++++++++ internal/midi/mididarwin/client_darwin.go | 199 +++++++++--------- internal/midi/mididarwin/client_dummy.go | 16 +- internal/midi/mididarwin/client_dummy_test.go | 66 ++++++ internal/midi/midiwindows/client_dummy.go | 13 +- .../midi/midiwindows/client_dummy_test.go | 66 ++++++ internal/midi/midiwindows/client_windows.go | 185 ++++++++-------- sdk/contracts/config.go | 21 ++ sdk/contracts/filter.go | 15 ++ sdk/contracts/filter_test.go | 45 ++++ sdk/contracts/logger.go | 48 ++--- sdk/contracts/midi.go | 28 ++- sdk/contracts/mock.go | 48 +++++ sdk/contracts/mock_test.go | 141 +++++++++++++ sdk/contracts/options.go | 67 +++--- sdk/midi/options_setup.go | 26 ++- sdk/midi/options_setup_test.go | 103 +++++++++ 31 files changed, 1821 insertions(+), 556 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 CLAUDE.md create mode 100644 internal/coremidi/client.go create mode 100644 internal/coremidi/destination.go create mode 100644 internal/coremidi/device.go create mode 100644 internal/coremidi/entity.go create mode 100644 internal/coremidi/object.go create mode 100644 internal/coremidi/packet.go create mode 100644 internal/coremidi/port.go create mode 100644 internal/coremidi/source.go create mode 100644 internal/coremidi/util.go create mode 100644 internal/logger/logger_wrapper_test.go create mode 100644 internal/midi/mididarwin/client_dummy_test.go create mode 100644 internal/midi/midiwindows/client_dummy_test.go create mode 100644 sdk/contracts/config.go create mode 100644 sdk/contracts/filter.go create mode 100644 sdk/contracts/filter_test.go create mode 100644 sdk/contracts/mock.go create mode 100644 sdk/contracts/mock_test.go create mode 100644 sdk/midi/options_setup_test.go diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..8d6895c --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,53 @@ +# Copilot Instructions + +## Commands + +```bash +# Build +go build ./... + +# Test all +go test ./... + +# Run a single test (adjust package path and test name) +go test ./internal/midi/mididarwin/... -run TestName + +# Lint (requires pre-commit) +pre-commit run --all-files + +# Run example +go run example/simple_use.go +``` + +## Architecture + +A native Go library for capturing MIDI events on macOS and Windows without external DLLs. + +### Layer structure + +``` +sdk/contracts/ → Public interfaces and types (ClientMIDI, Logger, Field, Option, MIDI, etc.) +sdk/midi/ → Public entry point: NewMIDIClient() applies options, dispatches to platform impl +internal/midi/ → Platform implementations (mididarwin, midiwindows) +internal/coremidi/ → Low-level CoreMIDI bindings (Darwin only) +internal/logger/ → ZapLogger: default Logger implementation +example/ → Usage example +``` + +### Platform dispatch + +`sdk/midi/midi_client_factory.go` maps `runtime.GOOS` to an initializer function via `clientInitializers`. To add a new platform, register a new `func(*contracts.ClientOptions) (contracts.ClientMIDI, error)` entry in that map. + +Build tags control compilation: `//go:build darwin` / `//go:build windows`. Every platform package has a paired `*_dummy.go` (no build tag constraint) with stub implementations so the package compiles on other OSes. + +### Key conventions + +**Options pattern** — `contracts.Option` is `func(*ClientOptions)`. All configuration flows through functional options (`WithLogger`, `WithLogLevel`, `WithLogDestination`, `WithChannelBufferSize`, `WithMIDIEventFilter`, `WithCoreMIDIConfig`) passed to `NewMIDIClient`. + +**Event delivery** — `StartCapture(context.Context)` returns a client-owned receive-only channel. The channel is stored in an `atomic.Value` for goroutine-safe access from MIDI callbacks, closed on context cancellation or `Stop()`, and sized with `WithChannelBufferSize`. When the buffer is full, events are silently dropped with a warning log. + +**Graceful shutdown** — Darwin uses `sync.WaitGroup` + `sync.Once`; `Stop()` disconnects the port, swaps in a dummy channel to absorb any in-flight writes, then calls `wg.Wait()`. Windows uses `midiInStop`/`midiInClose` syscalls. + +**Logger abstraction** — `contracts.Logger` is an interface and `contracts.Field` is a plain struct with standalone constructors such as `contracts.StringField` and `contracts.ErrField`. The default logger (`internal/logger`) emits plain-string output with JSON-encoded fields appended. + +**MIDI event filtering** — `MIDIEventFilter.Commands` is an allowlist of `MIDICommand` bytes (`NoteOn = 0x90`, `NoteOff = 0x80`). A `nil` filter passes all commands via `contracts.IsCommandAllowed`. The Windows implementation strips the channel nibble (`status & 0xF0`) before comparing; Darwin compares the raw byte. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7f24b7b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,54 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +# Build +go build ./... + +# Run tests +go test ./... + +# Run a single test +go test ./internal/midi/mididarwin/... -run TestName + +# Lint (via pre-commit) +pre-commit run --all-files + +# Run example +go run example/simple_use.go +``` + +Build tags are platform-specific: `darwin` for macOS (uses CoreMIDI via `go-coremidi`), `windows` for Windows (uses `winmm.dll` via syscalls). Dummy stubs in `*_dummy.go` files allow cross-compilation. + +## Architecture + +This is a Go library for capturing MIDI events natively on macOS and Windows without external DLLs. + +### Layer structure + +``` +sdk/contracts/ → Public interfaces and types (ClientMIDI, Logger, Field, Option, MIDI, etc.) +sdk/midi/ → Public entry point: NewMIDIClient() applies options and dispatches to platform impl +internal/midi/ → Platform implementations (mididarwin, midiwindows) +internal/logger/ → ZapLogger: default Logger implementation backed by uber-zap +example/ → Usage example +``` + +### Key design decisions + +**Platform dispatch** — `sdk/midi/midi_client_factory.go` maps `runtime.GOOS` to an initializer function. Adding a new platform means registering a new entry in `clientInitializers`. + +**Options pattern** — `contracts.Option` is a `func(*ClientOptions)`. All user-facing config (logger, log level, log destination, channel buffer size, event filter, CoreMIDI client name) flows through functional options passed to `NewMIDIClient`. + +**Event delivery** — `StartCapture(context.Context)` creates and returns a receive-only channel owned by the client. The channel is stored via `atomic.Value` for goroutine-safe access in the MIDI callback, closed on context cancellation or `Stop()`, and sized by `WithChannelBufferSize`. When the buffer is full, events are silently dropped with a warning log. + +**Graceful shutdown** — The Darwin client uses `sync.WaitGroup` + `sync.Once` to ensure in-flight callbacks complete before `Stop()` returns. The Windows client uses `midiInStop`/`midiInClose` syscalls. + +**Logger abstraction** — `contracts.Logger` is an interface and `contracts.Field` is a plain struct with standalone constructors like `contracts.StringField` and `contracts.ErrField`. The default logger writes plain-string output with JSON-encoded fields appended. + +### MIDI event filtering + +`MIDIEventFilter.Commands` is an allowlist of `MIDICommand` values (`NoteOn = 0x90`, `NoteOff = 0x80`). If `MIDIEventFilter` is nil, all commands pass through. The Windows implementation also strips the channel nibble (`status & 0xF0`) before filtering. diff --git a/README.md b/README.md index f6de425..e9bc461 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MIDI Client Library -A native Go library for capturing and manipulating MIDI events, supporting macOS and Windows operating systems without the need for external libraries or DLLs. +A native Go library for capturing and manipulating MIDI events on macOS and Windows without external DLLs. ## Table of Contents @@ -14,124 +14,114 @@ A native Go library for capturing and manipulating MIDI events, supporting macOS ## Introduction -This project provides a **fully native** interface for working with MIDI devices, enabling the capture of events and filtering of MIDI commands without relying on any external libraries or dependencies. The library is designed to be easy to use and extensible, making it a straightforward choice for applications that require MIDI manipulation. +This project provides a fully native interface for working with MIDI devices, enabling event capture and MIDI command filtering without external dependencies. ## Features -- **Native Support**: Works seamlessly on macOS and Windows without the need for additional libraries or DLLs. -- **Device Listing**: Easily list available MIDI devices connected to your system. -- **Device Selection**: Select MIDI devices for capturing events with simple function calls. -- **Event Capturing**: Capture MIDI events with support for filtering commands, allowing you to focus on the events that matter. -- **Built-in Logging**: Implemented logging for monitoring and debugging, providing insights into the MIDI event flow. +- Native support for macOS and Windows. +- List available MIDI input devices. +- Select a device and capture MIDI events. +- Filter incoming MIDI commands. +- Structured logging with configurable level, destination, and channel buffer size. ## Installation -To install the library, you can use the following Go command: - ```bash go get github.com/leandrodaf/midi ``` ## Quick Usage -Here is a simple example of how to use the library to capture MIDI events: - ```go package main import ( - "fmt" - - "github.com/leandrodaf/midi/internal/logger" - "github.com/leandrodaf/midi/sdk/contracts" - "github.com/leandrodaf/midi/sdk/midi" + "context" + "fmt" + "os/signal" + "syscall" + + "github.com/leandrodaf/midi/internal/logger" + "github.com/leandrodaf/midi/sdk/contracts" + "github.com/leandrodaf/midi/sdk/midi" ) func main() { - log := logger.NewStandardLogger() - - client, err := midi.NewMIDIClient( - contracts.WithLogger(log), - contracts.WithLogLevel(contracts.InfoLevel), - contracts.WithMIDIEventFilter(contracts.MIDIEventFilter{ - Commands: []contracts.MIDICommand{contracts.NoteOn, contracts.NoteOff}, - }), - ) - if err != nil { - log.Error("Failed to initialize MIDI client", log.Field().Error("error", err)) - return - } - - devices, err := client.ListDevices() - if err != nil || len(devices) == 0 { - log.Error("No MIDI devices found or error listing devices", log.Field().Error("error", err)) - return - } - fmt.Println("Available MIDI devices:", devices) - - if err = client.SelectDevice(0); err != nil { - log.Error("Failed to select MIDI device", log.Field().Error("error", err)) - return - } - - eventChannel := make(chan contracts.MIDI, 100) - go func() { - for event := range eventChannel { - log.Info("MIDI Event", - log.Field().Uint64("Timestamp", event.Timestamp), - log.Field().Int("Command", int(event.Command)), - log.Field().Int("Note", int(event.Note)), - log.Field().Int("Velocity", int(event.Velocity)), - ) - } - }() - - client.StartCapture(eventChannel) - defer client.Stop() - - fmt.Println("Capturing MIDI events... Press Ctrl+C to exit.") - select {} // Run indefinitely + log := logger.NewLogger() + + client, err := midi.NewMIDIClient( + contracts.WithLogger(log), + contracts.WithLogLevel(contracts.InfoLevel), + contracts.WithChannelBufferSize(100), + contracts.WithMIDIEventFilter(contracts.MIDIEventFilter{ + Commands: []contracts.MIDICommand{contracts.NoteOn, contracts.NoteOff}, + }), + ) + if err != nil { + log.Error("Failed to initialize MIDI client", contracts.ErrField("error", err)) + return + } + + devices, err := client.ListDevices() + if err != nil || len(devices) == 0 { + log.Error("No MIDI devices found", contracts.ErrField("error", err)) + return + } + fmt.Println("Available MIDI devices:", devices) + + if err = client.SelectDevice(0); err != nil { + log.Error("Failed to select MIDI device", contracts.ErrField("error", err)) + return + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + events, err := client.StartCapture(ctx) + if err != nil { + log.Error("Failed to start MIDI capture", contracts.ErrField("error", err)) + return + } + + fmt.Println("Capturing MIDI events... Press Ctrl+C to exit.") + + for event := range events { + log.Info("MIDI Event", + contracts.Uint64Field("Timestamp", event.Timestamp), + contracts.IntField("Command", int(event.Command)), + contracts.IntField("Note", int(event.Note)), + contracts.IntField("Velocity", int(event.Velocity)), + ) + } } ``` ## Configuration -The library allows for various configuration options when creating a MIDI client. Here are some of the available options: - -- **Logger**: A custom logger can be provided. -- **LogLevel**: Logging level (Info, Debug, Error, etc.). -- **MIDIEventFilter**: A filter to specify which MIDI commands to capture. +Available options include: -Example configuration: +- `WithLogger` to inject a custom logger. +- `WithLogLevel` to control the minimum log level. +- `WithLogDestination` to write logs to console or file. +- `WithChannelBufferSize` to size the event channel returned by `StartCapture`. +- `WithMIDIEventFilter` to allow only selected MIDI commands. +- `WithCoreMIDIConfig` to customize the CoreMIDI client name on macOS. ```go client, err := midi.NewMIDIClient( - contracts.WithLogger(log), - contracts.WithLogLevel(contracts.InfoLevel), - contracts.WithMIDIEventFilter(contracts.MIDIEventFilter{ - Commands: []contracts.MIDICommand{contracts.NoteOn, contracts.NoteOff}, - }), + contracts.WithLogger(log), + contracts.WithLogLevel(contracts.InfoLevel), + contracts.WithLogDestination(contracts.ConsoleLog), + contracts.WithChannelBufferSize(256), + contracts.WithMIDIEventFilter(contracts.MIDIEventFilter{ + Commands: []contracts.MIDICommand{contracts.NoteOn, contracts.NoteOff}, + }), ) ``` ## Contribution -Contributions are welcome! To contribute to the project, please follow these steps: - -1. **Fork the repository**. -2. **Create a new branch** for your feature or fix: - ```bash - git checkout -b feature-your-feature-name - ``` -3. **Make your changes** and commit: - ```bash - git commit -m "Adds new feature" - ``` -4. **Push your changes to the remote repository**: - ```bash - git push origin feature-your-feature-name - ``` -5. **Create a Pull Request**. +Contributions are welcome. Fork the repository, create a branch, make your changes, and open a pull request. ## License diff --git a/example/simple_use.go b/example/simple_use.go index 5ea99a2..8ba332f 100644 --- a/example/simple_use.go +++ b/example/simple_use.go @@ -1,12 +1,10 @@ package main import ( + "context" "fmt" - "os" "os/signal" - "sync" "syscall" - "time" "github.com/leandrodaf/midi/internal/logger" "github.com/leandrodaf/midi/sdk/contracts" @@ -14,84 +12,52 @@ import ( ) func main() { - log := logger.NewZapLogger() + log := logger.NewLogger() client, err := midi.NewMIDIClient( contracts.WithLogger(log), contracts.WithLogLevel(contracts.InfoLevel), + contracts.WithChannelBufferSize(100), contracts.WithMIDIEventFilter(contracts.MIDIEventFilter{ Commands: []contracts.MIDICommand{contracts.NoteOn, contracts.NoteOff}, }), ) if err != nil { - log.Error("Failed to initialize MIDI client", log.Field().Error("error", err)) + log.Error("Failed to initialize MIDI client", contracts.ErrField("error", err)) return } devices, err := client.ListDevices() if err != nil || len(devices) == 0 { - log.Error("No MIDI devices found or error listing devices", log.Field().Error("error", err)) + log.Error("No MIDI devices found", contracts.ErrField("error", err)) return } fmt.Println("Available MIDI devices:", devices) if err = client.SelectDevice(0); err != nil { - log.Error("Failed to select MIDI device", log.Field().Error("error", err)) + log.Error("Failed to select MIDI device", contracts.ErrField("error", err)) return } - eventChannel := make(chan contracts.MIDI, 100) - var wg sync.WaitGroup + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() - // Goroutine para processar eventos MIDI - wg.Add(1) - go func() { - defer wg.Done() - for event := range eventChannel { - log.Info("MIDI Event", - log.Field().Uint64("Timestamp", event.Timestamp), - log.Field().Int("Command", int(event.Command)), - log.Field().Int("Note", int(event.Note)), - log.Field().Int("Velocity", int(event.Velocity)), - ) - } - }() - - client.StartCapture(eventChannel) - - // Configurar canais para sinal de interrupção e conclusão - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - done := make(chan struct{}) // Canal para sinalizar que devemos encerrar - closeOnce := sync.Once{} // Garantir que o canal done seja fechado apenas uma vez - - // Função para encerrar a captura e sinalizar conclusão - stopCapture := func(reason string) { - log.Info(reason) - client.Stop() - closeOnce.Do(func() { - close(eventChannel) // Fecha o canal de eventos para parar o goroutine de processamento - close(done) // Sinaliza que devemos encerrar - }) + events, err := client.StartCapture(ctx) + if err != nil { + log.Error("Failed to start MIDI capture", contracts.ErrField("error", err)) + return } - // Goroutine para lidar com sinais de interrupção - go func() { - <-sigChan - stopCapture("Received shutdown signal, stopping capture...") - }() - - // Goroutine para lidar com o timeout - go func() { - time.Sleep(5 * time.Second) // Simula um período de captura curto - stopCapture("Timeout reached, stopping capture...") - }() - fmt.Println("Capturing MIDI events... Press Ctrl+C to exit.") - <-done // Aguarda até que o canal done seja fechado - // Aguarda a conclusão do processamento de eventos - wg.Wait() - log.Info("Program terminated gracefully.") + for event := range events { + log.Info("MIDI Event", + contracts.Uint64Field("Timestamp", event.Timestamp), + contracts.IntField("Command", int(event.Command)), + contracts.IntField("Note", int(event.Note)), + contracts.IntField("Velocity", int(event.Velocity)), + ) + } + + log.Info("Capture ended.") } diff --git a/internal/coremidi/client.go b/internal/coremidi/client.go new file mode 100644 index 0000000..e5c0254 --- /dev/null +++ b/internal/coremidi/client.go @@ -0,0 +1,32 @@ +//go:build darwin +// +build darwin + +package coremidi + +/* +#cgo LDFLAGS: -framework CoreMIDI -framework CoreFoundation -framework CoreServices +#include +#include +*/ +import "C" +import "fmt" + +type Client struct { + client C.MIDIClientRef +} + +func NewClient(name string) (client Client, err error) { + var clientRef C.MIDIClientRef + + stringToCFString(name, func(cfName C.CFStringRef) { + osStatus := C.MIDIClientCreate(cfName, nil, nil, &clientRef) + + if osStatus != C.noErr { + err = fmt.Errorf("%d: failed to create a client", int(osStatus)) + } else { + client = Client{clientRef} + } + }) + + return +} diff --git a/internal/coremidi/destination.go b/internal/coremidi/destination.go new file mode 100644 index 0000000..db2bc68 --- /dev/null +++ b/internal/coremidi/destination.go @@ -0,0 +1,121 @@ +//go:build darwin +// +build darwin + +package coremidi + +/* +#cgo LDFLAGS: -framework CoreMIDI -framework CoreFoundation +#include +#include +#include + +static void MIDIDestinationInputProc(const MIDIPacketList *pktlist, void *readProcRefCon, void *srcConnRefCon) +{ + MIDIPacket *packet = (MIDIPacket *)&(pktlist->packet[0]); + UInt32 packetCount = pktlist->numPackets; + int i, n; + Byte *data; + + int lengthBytes = 2; + int timeStampBytes = 8; + + for (i = 0; i < packetCount; i++) { + data = calloc(sizeof(Byte), packet->length + lengthBytes + timeStampBytes); + + memcpy(data, &(packet->length), lengthBytes); + memcpy(data + lengthBytes, &(packet->timeStamp), timeStampBytes); + memcpy(data + lengthBytes + timeStampBytes, packet->data, packet->length); + + n = write(*(int *)readProcRefCon, data, packet->length + lengthBytes + timeStampBytes); + packet = MIDIPacketNext(packet); + free(data); + } +} + +typedef void (*midi_destination_input_proc)(const MIDIPacketList *pktlist, void *readProcRefCon, void *srcConnRefCon); + +static midi_destination_input_proc getMidiDestinationProc() +{ + return *MIDIDestinationInputProc; +} + +*/ +import "C" +import ( + "errors" + "fmt" + "syscall" + "unsafe" +) + +type Destination struct { + endpoint C.MIDIEndpointRef + *Object +} + +func AllDestinations() (destinations []Destination, err error) { + numberOfDestinations := numberOfDestinations() + destinations = make([]Destination, numberOfDestinations) + + for i := range destinations { + destination := C.MIDIGetDestination(C.ItemCount(i)) + + if destination == (C.MIDIEndpointRef)(0) { + err = errors.New("failed to get destination") + + return + } + + destinations[i] = Destination{ + destination, + &Object{C.MIDIObjectRef(destination)}} + } + + return +} + +func NewDestination(client Client, name string, readProc func(packet Packet)) (destination Destination, err error) { + var endpointRef C.MIDIEndpointRef + + fd := make([]int, 2) + if pipeErr := syscall.Pipe(fd); pipeErr != nil { + return destination, fmt.Errorf("failed to create pipe: %w", pipeErr) + } + readFd := fd[0] + writeFd := C.int(fd[1]) + + go processIncomingPacket( + readFd, + func(data []byte, timeStamp uint64) { + readProc(NewPacket(data, timeStamp)) + }, + ) + + stringToCFString(name, func(cfName C.CFStringRef) { + osStatus := C.MIDIDestinationCreate( + client.client, + cfName, + (C.MIDIReadProc)(C.getMidiDestinationProc()), + unsafe.Pointer(&writeFd), + &endpointRef, + ) + + if osStatus != C.noErr { + // Close the write end so the reader sees EOF and exits. + syscall.Close(int(writeFd)) + err = fmt.Errorf("%d: failed to create a destination", int(osStatus)) + } else { + destination = Destination{endpointRef, &Object{C.MIDIObjectRef(endpointRef)}} + } + }) + + return +} + +func (dest Destination) Dispose() { + C.MIDIEndpointDispose(dest.endpoint) +} + +func numberOfDestinations() int { + return int(C.ItemCount(C.MIDIGetNumberOfDestinations())) +} diff --git a/internal/coremidi/device.go b/internal/coremidi/device.go new file mode 100644 index 0000000..96557fe --- /dev/null +++ b/internal/coremidi/device.go @@ -0,0 +1,62 @@ +//go:build darwin +// +build darwin + +package coremidi + +/* +#cgo LDFLAGS: -framework CoreMIDI -framework CoreFoundation +#include +*/ +import "C" +import ( + "errors" +) + +type Device struct { + device C.MIDIDeviceRef + *Object +} + +func AllDevices() (devices []Device, err error) { + numberOfDevices := numberOfDevices() + devices = make([]Device, numberOfDevices) + + for i := range devices { + device := C.MIDIGetDevice(C.ItemCount(i)) + + if device == (C.MIDIDeviceRef)(0) { + err = errors.New("failed to get device") + + return + } + + devices[i] = Device{ + device, + &Object{C.MIDIObjectRef(device)}} + } + + return +} + +func (device Device) Entities() (entities []Entity, err error) { + numberOfEntitiles := int(C.ItemCount(C.MIDIDeviceGetNumberOfEntities(device.device))) + entities = make([]Entity, numberOfEntitiles) + + for i := range entities { + entity := C.MIDIDeviceGetEntity(device.device, C.ItemCount(i)) + + if entity == (C.MIDIEntityRef)(0) { + err = errors.New("failed to get entity") + + return + } + + entities[i] = Entity{entity, &Object{C.MIDIObjectRef(entity)}} + } + + return +} + +func numberOfDevices() int { + return int(C.ItemCount(C.MIDIGetNumberOfDevices())) +} diff --git a/internal/coremidi/entity.go b/internal/coremidi/entity.go new file mode 100644 index 0000000..3d833ed --- /dev/null +++ b/internal/coremidi/entity.go @@ -0,0 +1,63 @@ +//go:build darwin +// +build darwin + +package coremidi + +/* +#cgo LDFLAGS: -framework CoreMIDI -framework CoreFoundation +#include +*/ +import "C" +import "errors" + +type Entity struct { + entity C.MIDIEntityRef + *Object +} + +func (entity Entity) Sources() (sources []Source, err error) { + numberOfSources := int(C.ItemCount(C.MIDIEntityGetNumberOfSources(entity.entity))) + sources = make([]Source, numberOfSources) + + for i := range sources { + source := C.MIDIEntityGetSource(entity.entity, C.ItemCount(i)) + + if source == (C.MIDIEndpointRef)(0) { + err = errors.New("failed to get source") + + return + } + + sources[i] = Source{source, &Object{C.MIDIObjectRef(source)}} + } + + return +} + +func (entity Entity) Destinations() (destinations []Destination, err error) { + numberOfDestinations := int(C.ItemCount(C.MIDIEntityGetNumberOfDestinations(entity.entity))) + destinations = make([]Destination, numberOfDestinations) + + for i := range destinations { + destination := C.MIDIEntityGetDestination(entity.entity, C.ItemCount(i)) + + if destination == (C.MIDIEndpointRef)(0) { + err = errors.New("failed to get destination") + + return + } + + destinations[i] = Destination{destination, &Object{C.MIDIObjectRef(destination)}} + } + + return +} + +func (entity Entity) Device() (device Device) { + var deviceRef C.MIDIDeviceRef + + C.MIDIEntityGetDevice(entity.entity, &deviceRef) + device = Device{deviceRef, &Object{C.MIDIObjectRef(deviceRef)}} + + return +} diff --git a/internal/coremidi/object.go b/internal/coremidi/object.go new file mode 100644 index 0000000..2376c00 --- /dev/null +++ b/internal/coremidi/object.go @@ -0,0 +1,64 @@ +//go:build darwin +// +build darwin + +package coremidi + +/* +#cgo LDFLAGS: -framework CoreMIDI -framework CoreFoundation +#include + +// based on https://stackoverflow.com/a/9166500 +char * MyCFStringCopyCString(CFStringRef aString, CFStringEncoding encoding) { + if (aString == NULL) { + return NULL; + } + + CFIndex length = CFStringGetLength(aString); + CFIndex maxSize = + CFStringGetMaximumSizeForEncoding(length, encoding) + 1; + char *buffer = (char *)malloc(maxSize); + + if (CFStringGetCString(aString, buffer, maxSize, + encoding)) { + return buffer; + } + + // If we failed + free(buffer); + return NULL; +} + +*/ +import "C" +import "unsafe" + +type Object struct { + object C.MIDIObjectRef +} + +func (object Object) Name() string { + return object.getStringProperty(C.kMIDIPropertyName) +} + +func (object Object) Manufacturer() string { + return object.getStringProperty(C.kMIDIPropertyManufacturer) +} + +func (object Object) getStringProperty(key C.CFStringRef) (propValue string) { + var result C.CFStringRef + + osStatus := C.MIDIObjectGetStringProperty(object.object, key, &result) + + if osStatus != C.noErr { + return + } + + defer C.CFRelease((C.CFTypeRef)(result)) + + value := C.MyCFStringCopyCString(result, C.kCFStringEncodingUTF8) + defer C.free(unsafe.Pointer(value)) + + propValue = C.GoString(value) + + return +} diff --git a/internal/coremidi/packet.go b/internal/coremidi/packet.go new file mode 100644 index 0000000..36cf212 --- /dev/null +++ b/internal/coremidi/packet.go @@ -0,0 +1,53 @@ +//go:build darwin +// +build darwin + +package coremidi + +/* +#cgo LDFLAGS: -framework CoreMIDI +#include +*/ +import "C" +import "fmt" +import "unsafe" + +type Packet struct { + Data []byte + TimeStamp uint64 +} + +func NewPacket(data []byte, timeStamp uint64) Packet { + return Packet{data, timeStamp} +} + +func (packet *Packet) createPacketList() C.MIDIPacketList { + var packetList C.MIDIPacketList + var data = (*C.Byte)(unsafe.Pointer(&packet.Data[0])) + + p := C.MIDIPacketListInit(&packetList) + p = C.MIDIPacketListAdd(&packetList, 1024, p, C.MIDITimeStamp(packet.TimeStamp), C.ByteCount(len(packet.Data)), data) + + return packetList +} + +func (packet *Packet) Send(port *OutputPort, destination *Destination) (err error) { + packetList := packet.createPacketList() + osStatus := C.MIDISend(port.port, destination.endpoint, &packetList) + + if osStatus != C.noErr { + err = fmt.Errorf("%d: failed to send MIDI", int(osStatus)) + } + + return +} + +func (packet *Packet) Received(source *Source) (err error) { + packetList := packet.createPacketList() + osStatus := C.MIDIReceived(source.endpoint, &packetList) + + if osStatus != C.noErr { + err = fmt.Errorf("%d: failed to transmit MIDI", int(osStatus)) + } + + return +} diff --git a/internal/coremidi/port.go b/internal/coremidi/port.go new file mode 100644 index 0000000..019295c --- /dev/null +++ b/internal/coremidi/port.go @@ -0,0 +1,127 @@ +//go:build darwin +// +build darwin + +package coremidi + +import ( + "fmt" + "syscall" + "unsafe" +) + +/* +#cgo LDFLAGS: -framework CoreMIDI -framework CoreFoundation +#include +#include +#include + +static void MIDIInputProc(const MIDIPacketList *pktlist, void *readProcRefCon, void *srcConnRefCon) +{ + MIDIPacket *packet = (MIDIPacket *)&(pktlist->packet[0]); + UInt32 packetCount = pktlist->numPackets; + int i, n; + Byte *data; + + int lengthBytes = 2; + int timeStampBytes = 8; + + for (i = 0; i < packetCount; i++) { + data = calloc(sizeof(Byte), packet->length + lengthBytes + timeStampBytes); + + memcpy(data, &(packet->length), lengthBytes); + memcpy(data + lengthBytes, &(packet->timeStamp), timeStampBytes); + memcpy(data + lengthBytes + timeStampBytes, packet->data, packet->length); + + n = write(*(int *)srcConnRefCon, data, packet->length + lengthBytes + timeStampBytes); + packet = MIDIPacketNext(packet); + free(data); + } +} + +typedef void (*midi_input_proc)(const MIDIPacketList *pktlist, void *readProcRefCon, void *srcConnRefCon); + +static midi_input_proc getProc() +{ + return *MIDIInputProc; +} + +*/ +import "C" + +type OutputPort struct { + port C.MIDIPortRef +} + +func NewOutputPort(client Client, name string) (outputPort OutputPort, err error) { + var port C.MIDIPortRef + + stringToCFString(name, func(cfName C.CFStringRef) { + osStatus := C.MIDIOutputPortCreate(client.client, cfName, &port) + + if osStatus != C.noErr { + err = fmt.Errorf("%d: failed to create a port", int(osStatus)) + } else { + outputPort = OutputPort{port} + } + }) + + return +} + +type ReadProc func(source Source, packet Packet) + +type InputPort struct { + port C.MIDIPortRef + readProc ReadProc + writeFds []*C.int +} + +func NewInputPort(client Client, name string, readProc ReadProc) (inputPort InputPort, err error) { + var port C.MIDIPortRef + + stringToCFString(name, func(cfName C.CFStringRef) { + osStatus := C.MIDIInputPortCreate(client.client, + cfName, + (C.MIDIReadProc)(C.getProc()), + unsafe.Pointer(uintptr(0)), + &port) + + if osStatus != C.noErr { + err = fmt.Errorf("%d: failed to create a port", int(osStatus)) + } else { + inputPort = InputPort{port, readProc, make([]*C.int, 0)} + } + }) + + return +} + +func (port InputPort) Connect(source Source) (PortConnection, error) { + fd := make([]int, 2) + syscall.Pipe(fd) + readFd := fd[0] + writeFd := C.int(fd[1]) + port.writeFds = append(port.writeFds, &writeFd) + + C.MIDIPortConnectSource(port.port, source.endpoint, unsafe.Pointer(&writeFd)) + + go processIncomingPacket( + readFd, + func(data []byte, timeStamp uint64) { + port.readProc(source, NewPacket(data, timeStamp)) + }, + ) + + return PortConnection{port, source, &writeFd}, nil +} + +type PortConnection struct { + port InputPort + source Source + writeFd *C.int +} + +func (connection PortConnection) Disconnect() { + syscall.Close(int(*connection.writeFd)) + C.MIDIPortDisconnectSource(connection.port.port, connection.source.endpoint) +} diff --git a/internal/coremidi/source.go b/internal/coremidi/source.go new file mode 100644 index 0000000..9297003 --- /dev/null +++ b/internal/coremidi/source.go @@ -0,0 +1,67 @@ +//go:build darwin +// +build darwin + +package coremidi + +/* +#cgo LDFLAGS: -framework CoreMIDI -framework CoreFoundation +#include +*/ +import "C" +import "errors" +import "fmt" + +type Source struct { + endpoint C.MIDIEndpointRef + *Object +} + +func NewSource(client Client, name string) (source Source, err error) { + var endpointRef C.MIDIEndpointRef + + stringToCFString(name, func(cfName C.CFStringRef) { + osStatus := C.MIDISourceCreate(client.client, cfName, &endpointRef) + + if osStatus != C.noErr { + err = fmt.Errorf("%d: failed to create a source", int(osStatus)) + } else { + source = Source{endpointRef, &Object{C.MIDIObjectRef(endpointRef)}} + } + }) + + return +} + +func AllSources() (sources []Source, err error) { + numberOfSources := numberOfSources() + sources = make([]Source, numberOfSources) + + for i := range sources { + source := C.MIDIGetSource(C.ItemCount(i)) + + if source == (C.MIDIEndpointRef)(0) { + err = errors.New("failed to get source") + + return + } + + sources[i] = Source{ + source, + &Object{C.MIDIObjectRef(source)}} + } + + return +} + +func (source *Source) Entity() (entity Entity) { + var entityRef C.MIDIEntityRef + + C.MIDIEndpointGetEntity(source.endpoint, &entityRef) + entity = Entity{entityRef, &Object{C.MIDIObjectRef(entityRef)}} + + return +} + +func numberOfSources() int { + return int(C.ItemCount(C.MIDIGetNumberOfSources())) +} diff --git a/internal/coremidi/util.go b/internal/coremidi/util.go new file mode 100644 index 0000000..70cc8a7 --- /dev/null +++ b/internal/coremidi/util.go @@ -0,0 +1,67 @@ +//go:build darwin +// +build darwin + +package coremidi + +/* +#cgo LDFLAGS: -framework CoreFoundation +#include +*/ +import "C" +import ( + "bytes" + "encoding/binary" + "syscall" + "unsafe" +) + +func stringToCFString(str string, callback func(cfStr C.CFStringRef)) { + cStr := C.CString(str) + cfStr := C.CFStringCreateWithCString(C.kCFAllocatorDefault, cStr, C.kCFStringEncodingUTF8) + + defer C.free(unsafe.Pointer(cStr)) + defer C.CFRelease((C.CFTypeRef)(cfStr)) + + callback(cfStr) +} + +func processIncomingPacket(readFd int, onMessage func(data []byte, timeStamp uint64)) { + var length uint16 + var timeStamp uint64 + + defer syscall.Close(readFd) + + for { + lengthBytes := make([]byte, 2) + timeStampBytes := make([]byte, 8) + + n, err := syscall.Read(readFd, lengthBytes) + if err != nil || n != 2 { + break + } + + err = binary.Read(bytes.NewBuffer(lengthBytes[:]), binary.LittleEndian, &length) + if err != nil { + break + } + + n, err = syscall.Read(readFd, timeStampBytes) + if err != nil || n != 8 { + break + } + + err = binary.Read(bytes.NewBuffer(timeStampBytes[:]), binary.LittleEndian, &timeStamp) + if err != nil { + break + } + + data := make([]byte, length) + + n, err = syscall.Read(readFd, data) + if err != nil || n != int(length) { + break + } + + onMessage(data, timeStamp) + } +} diff --git a/internal/logger/logger_wrapper.go b/internal/logger/logger_wrapper.go index 0f78eef..894420c 100644 --- a/internal/logger/logger_wrapper.go +++ b/internal/logger/logger_wrapper.go @@ -3,166 +3,119 @@ package logger import ( "encoding/json" "fmt" + "io" "os" "path/filepath" "runtime" "time" "github.com/leandrodaf/midi/sdk/contracts" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" ) -// ZapLogger é uma implementação do contrato de Logger que usa o logger do Uber. -type ZapLogger struct { - logger *zap.Logger - level contracts.LogLevel // Nível de log +type stdLogger struct { + level contracts.LogLevel + output io.Writer } -// NewZapLogger cria um novo logger do Uber. -func NewZapLogger() contracts.Logger { - logger, _ := zap.NewProduction() // Ou zap.NewDevelopment() para desenvolvimento - return &ZapLogger{logger: logger, level: contracts.InfoLevel} +// NewLogger creates a new structured logger writing to stderr. +func NewLogger() contracts.Logger { + return NewLoggerWithWriter(os.Stderr) } -// Info logs a message at the INFO level -func (z *ZapLogger) Info(msg string, fields ...contracts.Field) { - z.log(zapcore.InfoLevel, msg, fields...) +// NewLoggerWithWriter creates a new structured logger writing to the provided writer. +func NewLoggerWithWriter(w io.Writer) contracts.Logger { + if w == nil { + w = os.Stderr + } + return &stdLogger{ + level: contracts.InfoLevel, + output: w, + } +} + +// NewZapLogger is an alias for NewLogger kept for compatibility. +func NewZapLogger() contracts.Logger { return NewLogger() } + +// NewStandardLogger is an alias for NewLogger kept for compatibility. +func NewStandardLogger() contracts.Logger { return NewLogger() } + +func (l *stdLogger) Info(msg string, fields ...contracts.Field) { + l.emit(contracts.InfoLevel, "INFO", msg, fields...) } -// Error logs a message at the ERROR level -func (z *ZapLogger) Error(msg string, fields ...contracts.Field) { - z.log(zapcore.ErrorLevel, msg, fields...) +func (l *stdLogger) Error(msg string, fields ...contracts.Field) { + l.emit(contracts.ErrorLevel, "ERROR", msg, fields...) } -// Debug logs a message at the DEBUG level -func (z *ZapLogger) Debug(msg string, fields ...contracts.Field) { - z.log(zapcore.DebugLevel, msg, fields...) +func (l *stdLogger) Debug(msg string, fields ...contracts.Field) { + l.emit(contracts.DebugLevel, "DEBUG", msg, fields...) } -// Warn logs a message at the WARN level -func (z *ZapLogger) Warn(msg string, fields ...contracts.Field) { - z.log(zapcore.WarnLevel, msg, fields...) +func (l *stdLogger) Warn(msg string, fields ...contracts.Field) { + l.emit(contracts.WarnLevel, "WARN", msg, fields...) } -// Fatal logs a message at the FATAL level and terminates the application -func (z *ZapLogger) Fatal(msg string, fields ...contracts.Field) { - z.log(zapcore.FatalLevel, msg, fields...) +func (l *stdLogger) Fatal(msg string, fields ...contracts.Field) { + l.emit(contracts.FatalLevel, "FATAL", msg, fields...) os.Exit(1) } -// Field returns a new instance of Field -func (z *ZapLogger) Field() contracts.Field { - return &zapField{} +func (l *stdLogger) SetLevel(level contracts.LogLevel) { + l.level = level +} + +func (l *stdLogger) SetDestination(dest contracts.LogDestination, filePath ...string) { + if dest == contracts.FileLog && len(filePath) > 0 { + f, err := os.OpenFile(filePath[0], os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err == nil { + l.output = f + } + } } -// SetLevel sets the logging level -func (z *ZapLogger) SetLevel(level contracts.LogLevel) { - z.level = level +var levelOrder = map[contracts.LogLevel]int{ + contracts.DebugLevel: 0, + contracts.InfoLevel: 1, + contracts.WarnLevel: 2, + contracts.ErrorLevel: 3, + contracts.FatalLevel: 4, } -// SetDestination sets the logging destination (não aplicável para ZapLogger). -func (z *ZapLogger) SetDestination(dest contracts.LogDestination, filePath ...string) { - // O ZapLogger não tem suporte a filePath, então não implementamos essa funcionalidade. +func (l *stdLogger) shouldLog(level contracts.LogLevel) bool { + return levelOrder[level] >= levelOrder[l.level] } -// log é a função interna para registrar mensagens -func (z *ZapLogger) log(level zapcore.Level, msg string, fields ...contracts.Field) { - if z.level > contracts.LogLevel(level) { +func (l *stdLogger) emit(level contracts.LogLevel, levelStr, msg string, fields ...contracts.Field) { + if !l.shouldLog(level) { return } - - // Captura o nome do arquivo e a linha onde o log foi chamado _, file, line, ok := runtime.Caller(2) if !ok { - file = "unknown" - line = 0 + file, line = "unknown", 0 } else { file = filepath.Base(file) } - timestamp := time.Now().UTC().Format(time.RFC3339) - formattedFields := formatFields(fields...) - logMessage := fmt.Sprintf("%s [%s] %s:%d: %s%s", timestamp, level.String(), file, line, msg, formattedFields) - - // Usar o logger do Uber - switch level { - case zapcore.InfoLevel: - z.logger.Info(logMessage) - case zapcore.ErrorLevel: - z.logger.Error(logMessage) - case zapcore.DebugLevel: - z.logger.Debug(logMessage) - case zapcore.WarnLevel: - z.logger.Warn(logMessage) - case zapcore.FatalLevel: - z.logger.Fatal(logMessage) - } + suffix := formatFields(fields...) + fmt.Fprintf(l.output, "%s [%s] %s:%d: %s%s\n", timestamp, levelStr, file, line, msg, suffix) } -// formatFields formats additional fields func formatFields(fields ...contracts.Field) string { if len(fields) == 0 { return "" } - - fieldMap := make(map[string]interface{}) - for _, field := range fields { - if f, ok := field.(*zapField); ok { - fieldMap[f.key] = f.value + m := make(map[string]any, len(fields)) + for _, f := range fields { + if f.Key != "" { + m[f.Key] = f.Value } } - - if len(fieldMap) == 0 { + if len(m) == 0 { return "" } - - jsonBytes, err := json.Marshal(fieldMap) + b, err := json.Marshal(m) if err != nil { return fmt.Sprintf(" [failed to format fields: %v]", err) } - - return " " + string(jsonBytes) -} - -// zapField implements contracts.Field -type zapField struct { - key string - value interface{} -} - -func (f *zapField) Bool(key string, val bool) contracts.Field { - return &zapField{key, val} -} - -func (f *zapField) Int(key string, val int) contracts.Field { - return &zapField{key, val} -} - -func (f *zapField) Float64(key string, val float64) contracts.Field { - return &zapField{key, val} -} - -func (f *zapField) String(key string, val string) contracts.Field { - return &zapField{key, val} -} - -func (f *zapField) Time(key string, val time.Time) contracts.Field { - return &zapField{key, val} -} - -func (f *zapField) Int64(key string, val int64) contracts.Field { - return &zapField{key, val} -} - -func (f *zapField) Error(key string, val error) contracts.Field { - return &zapField{key, val} -} - -func (f *zapField) Uint64(key string, val uint64) contracts.Field { - return &zapField{key, val} -} - -func (f *zapField) Uint8(key string, val uint8) contracts.Field { - return &zapField{key, val} + return " " + string(b) } diff --git a/internal/logger/logger_wrapper_test.go b/internal/logger/logger_wrapper_test.go new file mode 100644 index 0000000..213e994 --- /dev/null +++ b/internal/logger/logger_wrapper_test.go @@ -0,0 +1,110 @@ +package logger_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/leandrodaf/midi/internal/logger" + "github.com/leandrodaf/midi/sdk/contracts" +) + +func TestNewLogger_NotNil(t *testing.T) { + if logger.NewLogger() == nil { + t.Errorf("expected NewLogger to return a non-nil logger") + } +} + +func TestLogger_DebugSuppressedAtInfoLevel(t *testing.T) { + var buf bytes.Buffer + log := logger.NewLoggerWithWriter(&buf) + + log.Debug("debug hidden") + + if buf.Len() != 0 { + t.Errorf("expected debug output to be suppressed at info level, got %q", buf.String()) + } +} + +func TestLogger_InfoShownAtInfoLevel(t *testing.T) { + var buf bytes.Buffer + log := logger.NewLoggerWithWriter(&buf) + + log.Info("info visible") + + if !strings.Contains(buf.String(), "info visible") { + t.Errorf("expected info message to be written, got %q", buf.String()) + } +} + +func TestLogger_WarnShownAtInfoLevel(t *testing.T) { + var buf bytes.Buffer + log := logger.NewLoggerWithWriter(&buf) + + log.Warn("warn visible") + + if !strings.Contains(buf.String(), "warn visible") { + t.Errorf("expected warn message to be written, got %q", buf.String()) + } +} + +func TestLogger_ErrorShownAtInfoLevel(t *testing.T) { + var buf bytes.Buffer + log := logger.NewLoggerWithWriter(&buf) + + log.Error("error visible") + + if !strings.Contains(buf.String(), "error visible") { + t.Errorf("expected error message to be written, got %q", buf.String()) + } +} + +func TestLogger_DebugShownAtDebugLevel(t *testing.T) { + var buf bytes.Buffer + log := logger.NewLoggerWithWriter(&buf) + log.SetLevel(contracts.DebugLevel) + + log.Debug("debug visible") + + if !strings.Contains(buf.String(), "debug visible") { + t.Errorf("expected debug message to be written at debug level, got %q", buf.String()) + } +} + +func TestLogger_InfoSuppressedAtErrorLevel(t *testing.T) { + var buf bytes.Buffer + log := logger.NewLoggerWithWriter(&buf) + log.SetLevel(contracts.ErrorLevel) + + log.Info("info hidden") + + if buf.Len() != 0 { + t.Errorf("expected info output to be suppressed at error level, got %q", buf.String()) + } +} + +func TestLogger_FieldsAppearedInOutput(t *testing.T) { + var buf bytes.Buffer + log := logger.NewLoggerWithWriter(&buf) + + log.Info("with fields", contracts.StringField("key", "value")) + + if !strings.Contains(buf.String(), "\"key\"") { + t.Errorf("expected output to contain field key, got %q", buf.String()) + } +} + +func TestLogger_EmptyFieldsNoSuffix(t *testing.T) { + var buf bytes.Buffer + log := logger.NewLoggerWithWriter(&buf) + + log.Info("plain message") + + output := buf.String() + if !strings.Contains(output, "plain message") { + t.Fatalf("expected output to contain message, got %q", output) + } + if strings.Contains(output, "{") { + t.Errorf("expected output without fields to omit JSON suffix, got %q", output) + } +} diff --git a/internal/midi/mididarwin/client_darwin.go b/internal/midi/mididarwin/client_darwin.go index 8f6a804..ee6edfa 100644 --- a/internal/midi/mididarwin/client_darwin.go +++ b/internal/midi/mididarwin/client_darwin.go @@ -4,17 +4,17 @@ package mididarwin import ( + "context" "errors" "fmt" "sync" "sync/atomic" "time" + "github.com/leandrodaf/midi/internal/coremidi" "github.com/leandrodaf/midi/sdk/contracts" - "github.com/youpy/go-coremidi" ) -// Error definitions for MIDI connection and handling issues. var ( ErrNoMIDIDevices = errors.New("no MIDI devices found") ErrInvalidMIDIDevice = errors.New("invalid MIDI device") @@ -23,47 +23,44 @@ var ( ErrIncompleteMIDIPacket = errors.New("incomplete MIDI packet") ) -// internalPortConnection is an interface for handling disconnection from a MIDI port. type internalPortConnection interface { Disconnect() } -// ClientMid manages MIDI operations on Darwin (macOS) systems. -// This struct handles connections to MIDI devices, manages event capturing, -// and ensures safe concurrency handling. type ClientMid struct { - logger contracts.Logger - eventChannel atomic.Value // Atomic storage for the event channel to ensure thread safety. - client coremidi.Client // CoreMIDI client instance for MIDI operations. - inputPort coremidi.InputPort // Input port for receiving MIDI events. - portConn internalPortConnection // Connection to the MIDI port. - midiEventFilter *contracts.MIDIEventFilter // Filter for specific MIDI events. - coreMIDIConfig *contracts.CoreMIDIConfig // Configuration for MIDI client. - mu sync.Mutex // Mutex for thread safety on shared resources. - capturing bool // Indicates if event capturing is currently active. - wg sync.WaitGroup // WaitGroup for managing concurrent MIDI event processing. - stopOnce sync.Once // Ensures Stop() is executed only once. + logger contracts.Logger + eventChannel atomic.Value + client coremidi.Client + inputPort coremidi.InputPort + portConn internalPortConnection + midiEventFilter *contracts.MIDIEventFilter + coreMIDIConfig *contracts.CoreMIDIConfig + mu sync.Mutex + capturing bool + wg sync.WaitGroup + channelBufferSize int + outCh chan contracts.MIDI + closeChOnce sync.Once } -// NewMIDIClient initializes a new ClientMid for handling MIDI events on macOS. -// Applies logging and configurations based on the provided options. func NewMIDIClient(options *contracts.ClientOptions) (contracts.ClientMIDI, error) { + if options.CoreMIDIConfig == nil { + options.CoreMIDIConfig = &contracts.CoreMIDIConfig{ClientName: "GO MIDI Client"} + } client, err := coremidi.NewClient(options.CoreMIDIConfig.ClientName) if err != nil { return nil, err } options.Logger.Info("MIDI client successfully created") - return &ClientMid{ - logger: options.Logger, - client: client, - midiEventFilter: options.MIDIEventFilter, - coreMIDIConfig: options.CoreMIDIConfig, + logger: options.Logger, + client: client, + midiEventFilter: options.MIDIEventFilter, + coreMIDIConfig: options.CoreMIDIConfig, + channelBufferSize: options.ChannelBufferSize, }, nil } -// ListDevices retrieves and returns available MIDI devices. -// If no devices are found, an error is logged and returned. func (m *ClientMid) ListDevices() ([]contracts.DeviceInfo, error) { sources, err := coremidi.AllSources() if err != nil { @@ -73,21 +70,18 @@ func (m *ClientMid) ListDevices() ([]contracts.DeviceInfo, error) { m.logger.Warn(ErrNoMIDIDevices.Error()) return nil, ErrNoMIDIDevices } - devices := make([]contracts.DeviceInfo, len(sources)) for i, source := range sources { - sourceEntity := source.Entity() + entity := source.Entity() devices[i] = contracts.DeviceInfo{ Name: source.Name(), - EntityName: sourceEntity.Name(), - Manufacturer: sourceEntity.Manufacturer(), + EntityName: entity.Name(), + Manufacturer: entity.Manufacturer(), } } return devices, nil } -// SelectDevice selects a MIDI device by ID and connects to it. -// If a device is already connected, it disconnects first. func (m *ClientMid) SelectDevice(deviceID int) error { m.mu.Lock() defer m.mu.Unlock() @@ -100,123 +94,118 @@ func (m *ClientMid) SelectDevice(deviceID int) error { m.logger.Error(ErrInvalidMIDIDevice.Error()) return ErrInvalidMIDIDevice } - if m.portConn != nil { m.portConn.Disconnect() m.portConn = nil } - source := sources[deviceID] m.logger.Info("MIDI device selected", - m.logger.Field().Int("deviceID", deviceID), - m.logger.Field().String("deviceName", source.Name())) + contracts.IntField("deviceID", deviceID), + contracts.StringField("deviceName", source.Name())) m.inputPort, err = coremidi.NewInputPort(m.client, "Input Port", m.handleMIDIMessage) if err != nil { m.logger.Error(ErrCreateInputPort.Error()) return fmt.Errorf("%w: %v", ErrCreateInputPort, err) } - m.portConn, err = m.inputPort.Connect(source) if err != nil { m.logger.Error(ErrMIDIConnectionError.Error()) return fmt.Errorf("%w: %v", ErrMIDIConnectionError, err) } - m.logger.Info("MIDI device successfully connected") return nil } -// handleMIDIMessage processes incoming MIDI messages and applies filtering. -// If an event channel is valid and the message meets filter criteria, it is sent to the channel. -// Adds to WaitGroup to ensure safe concurrent processing. func (m *ClientMid) handleMIDIMessage(source coremidi.Source, packet coremidi.Packet) { m.wg.Add(1) defer m.wg.Done() eventChannel, _ := m.eventChannel.Load().(chan contracts.MIDI) if eventChannel == nil { - m.logger.Warn("eventChannel not initialized or of invalid type") return } - - if len(packet.Data) >= 3 { - event := contracts.MIDI{ - Timestamp: uint64(time.Now().UTC().UnixNano()), - Command: packet.Data[0], - Note: packet.Data[1], - Velocity: packet.Data[2], - } - - if m.midiEventFilter != nil && !isCommandAllowed(event.Command, m.midiEventFilter.Commands) { - return - } - select { - case eventChannel <- event: - default: - m.logger.Warn("Event buffer full; dropping MIDI event") - } - } else { + if len(packet.Data) < 3 { m.logger.Warn(ErrIncompleteMIDIPacket.Error()) + return } -} - -// isCommandAllowed verifies if a MIDI command is allowed based on the event filter configuration. -func isCommandAllowed(command byte, allowedCommands []contracts.MIDICommand) bool { - for _, allowedCommand := range allowedCommands { - if command == byte(allowedCommand) { - return true - } + event := contracts.MIDI{ + Timestamp: uint64(time.Now().UTC().UnixNano()), + Command: packet.Data[0], + Note: packet.Data[1], + Velocity: packet.Data[2], + } + if !contracts.IsCommandAllowed(event.Command, m.midiEventFilter) { + return + } + select { + case eventChannel <- event: + default: + m.logger.Warn("Event buffer full; dropping MIDI event") } - return false } -// StartCapture begins capturing MIDI events by storing the event channel and marking capturing as active. -// Ensures any ongoing capture is stopped before starting a new one. -func (m *ClientMid) StartCapture(eventChannel chan contracts.MIDI) { - m.mu.Lock() - defer m.mu.Unlock() - - if eventChannel == nil { - m.logger.Error("StartCapture called with nil eventChannel") +// stopLocked performs stop cleanup. Caller MUST hold m.mu. +func (m *ClientMid) stopLocked() { + if !m.capturing { return } + m.capturing = false + if m.portConn != nil { + m.portConn.Disconnect() + m.portConn = nil + } + m.eventChannel.Store(make(chan contracts.MIDI)) // unbuffered dummy; default case in handleMIDIMessage prevents blocking + m.logger.Info("MIDI capture stopped") +} - if m.capturing { - m.logger.Warn("Capture already started; attempting to stop existing capture") - if err := m.Stop(); err != nil { - m.logger.Error("Failed to stop existing capture", m.logger.Field().Error("error", err)) +func (m *ClientMid) closeOutCh() { + m.closeChOnce.Do(func() { + if m.outCh != nil { + close(m.outCh) } + }) +} + +func (m *ClientMid) Stop() error { + m.mu.Lock() + if !m.capturing { + m.mu.Unlock() + return nil } + m.logger.Info("Stopping MIDI capture") + m.stopLocked() + m.mu.Unlock() - m.logger.Info("Starting MIDI event capture") - m.eventChannel.Store(eventChannel) - m.capturing = true + m.wg.Wait() + m.closeOutCh() + return nil } -// Stop halts MIDI event capturing, disconnects from the device, and waits for ongoing processing to complete. -// This function ensures it only executes once, even if called multiple times. -func (m *ClientMid) Stop() error { - m.stopOnce.Do(func() { - m.logger.Info("Stopping MIDI capture") - m.mu.Lock() - defer m.mu.Unlock() +func (m *ClientMid) StartCapture(ctx context.Context) (<-chan contracts.MIDI, error) { + if err := m.Stop(); err != nil { + return nil, err + } - if m.capturing { - m.capturing = false + size := m.channelBufferSize + if size <= 0 { + size = 100 + } + ch := make(chan contracts.MIDI, size) - if m.portConn != nil { - m.portConn.Disconnect() - m.portConn = nil - } + m.mu.Lock() + m.outCh = ch + m.closeChOnce = sync.Once{} + m.eventChannel.Store((chan contracts.MIDI)(ch)) + m.capturing = true + m.mu.Unlock() - // Store a closed dummy channel to prevent further writes and avoid any panic. - dummyChannel := make(chan contracts.MIDI) - m.eventChannel.Store(dummyChannel) + m.logger.Info("Starting MIDI event capture") - m.logger.Info("MIDI capture stopped") - m.wg.Wait() // Wait for all ongoing MIDI event processing to complete - } - }) - return nil + go func() { + <-ctx.Done() + _ = m.Stop() + }() + + return ch, nil } diff --git a/internal/midi/mididarwin/client_dummy.go b/internal/midi/mididarwin/client_dummy.go index 2ab774d..0046eb4 100644 --- a/internal/midi/mididarwin/client_dummy.go +++ b/internal/midi/mididarwin/client_dummy.go @@ -4,37 +4,37 @@ package mididarwin import ( + "context" "fmt" "github.com/leandrodaf/midi/sdk/contracts" ) -type DummyMIDIClient struct { +type dummyMIDIClient struct { logger contracts.Logger } func NewMIDIClient(options *contracts.ClientOptions) (contracts.ClientMIDI, error) { options.Logger.Info("Using dummy MIDI client for non-macOS system") - return &DummyMIDIClient{ - logger: options.Logger, - }, nil + return &dummyMIDIClient{logger: options.Logger}, nil } -func (m *DummyMIDIClient) ListDevices() ([]contracts.DeviceInfo, error) { +func (m *dummyMIDIClient) ListDevices() ([]contracts.DeviceInfo, error) { m.logger.Warn("ListDevices called on dummy MIDI client") return nil, fmt.Errorf("MIDI functionality is not available on this platform") } -func (m *DummyMIDIClient) SelectDevice(deviceID int) error { +func (m *dummyMIDIClient) SelectDevice(deviceID int) error { m.logger.Warn("SelectDevice called on dummy MIDI client") return fmt.Errorf("MIDI functionality is not available on this platform") } -func (m *DummyMIDIClient) StartCapture(eventChannel chan contracts.MIDI) { +func (m *dummyMIDIClient) StartCapture(ctx context.Context) (<-chan contracts.MIDI, error) { m.logger.Warn("StartCapture called on dummy MIDI client") + return nil, fmt.Errorf("MIDI functionality is not available on this platform") } -func (m *DummyMIDIClient) Stop() error { +func (m *dummyMIDIClient) Stop() error { m.logger.Warn("Stop called on dummy MIDI client") return nil } diff --git a/internal/midi/mididarwin/client_dummy_test.go b/internal/midi/mididarwin/client_dummy_test.go new file mode 100644 index 0000000..59ef83a --- /dev/null +++ b/internal/midi/mididarwin/client_dummy_test.go @@ -0,0 +1,66 @@ +//go:build !darwin +// +build !darwin + +package mididarwin_test + +import ( + "context" + "io" + "testing" + + "github.com/leandrodaf/midi/internal/logger" + "github.com/leandrodaf/midi/internal/midi/mididarwin" + "github.com/leandrodaf/midi/sdk/contracts" +) + +func newDummyDarwinClient(t *testing.T) contracts.ClientMIDI { + t.Helper() + + client, err := mididarwin.NewMIDIClient(&contracts.ClientOptions{Logger: logger.NewLoggerWithWriter(io.Discard)}) + if err != nil { + t.Fatalf("NewMIDIClient returned error: %v", err) + } + + return client +} + +func TestDummyClient_ListDevices_ReturnsError(t *testing.T) { + client := newDummyDarwinClient(t) + + devices, err := client.ListDevices() + if err == nil { + t.Fatalf("expected ListDevices to return an error") + } + if devices != nil { + t.Errorf("expected ListDevices to return nil devices, got %#v", devices) + } +} + +func TestDummyClient_SelectDevice_ReturnsError(t *testing.T) { + client := newDummyDarwinClient(t) + + err := client.SelectDevice(0) + if err == nil { + t.Fatalf("expected SelectDevice to return an error") + } +} + +func TestDummyClient_StartCapture_ReturnsError(t *testing.T) { + client := newDummyDarwinClient(t) + + channel, err := client.StartCapture(context.Background()) + if err == nil { + t.Fatalf("expected StartCapture to return an error") + } + if channel != nil { + t.Errorf("expected StartCapture to return a nil channel") + } +} + +func TestDummyClient_Stop_ReturnsNil(t *testing.T) { + client := newDummyDarwinClient(t) + + if err := client.Stop(); err != nil { + t.Errorf("expected Stop to return nil, got %v", err) + } +} diff --git a/internal/midi/midiwindows/client_dummy.go b/internal/midi/midiwindows/client_dummy.go index 57bb7d8..f7ee61d 100644 --- a/internal/midi/midiwindows/client_dummy.go +++ b/internal/midi/midiwindows/client_dummy.go @@ -4,6 +4,7 @@ package midiwindows import ( + "context" "fmt" "github.com/leandrodaf/midi/sdk/contracts" @@ -13,32 +14,26 @@ type dummyMIDIClient struct { logger contracts.Logger } -// NewMIDIClient initializes a dummy MIDI client for non-Windows systems. func NewMIDIClient(options *contracts.ClientOptions) (contracts.ClientMIDI, error) { options.Logger.Info("Using dummy MIDI client for non-Windows system") - return &dummyMIDIClient{ - logger: options.Logger, - }, nil + return &dummyMIDIClient{logger: options.Logger}, nil } -// ListDevices logs a warning and returns an error indicating that MIDI functionality is unavailable on this platform. func (m *dummyMIDIClient) ListDevices() ([]contracts.DeviceInfo, error) { m.logger.Warn("ListDevices called on dummy MIDI client") return nil, fmt.Errorf("MIDI functionality is not available on this platform") } -// SelectDevice logs a warning and returns an error indicating that MIDI functionality is unavailable on this platform. func (m *dummyMIDIClient) SelectDevice(deviceID int) error { m.logger.Warn("SelectDevice called on dummy MIDI client") return fmt.Errorf("MIDI functionality is not available on this platform") } -// StartCapture logs a warning indicating that StartCapture was called on the dummy MIDI client. -func (m *dummyMIDIClient) StartCapture(eventChannel chan contracts.MIDI) { +func (m *dummyMIDIClient) StartCapture(ctx context.Context) (<-chan contracts.MIDI, error) { m.logger.Warn("StartCapture called on dummy MIDI client") + return nil, fmt.Errorf("MIDI functionality is not available on this platform") } -// Stop logs a warning indicating that Stop was called on the dummy MIDI client. func (m *dummyMIDIClient) Stop() error { m.logger.Warn("Stop called on dummy MIDI client") return nil diff --git a/internal/midi/midiwindows/client_dummy_test.go b/internal/midi/midiwindows/client_dummy_test.go new file mode 100644 index 0000000..03971e5 --- /dev/null +++ b/internal/midi/midiwindows/client_dummy_test.go @@ -0,0 +1,66 @@ +//go:build !windows +// +build !windows + +package midiwindows_test + +import ( + "context" + "io" + "testing" + + "github.com/leandrodaf/midi/internal/logger" + "github.com/leandrodaf/midi/internal/midi/midiwindows" + "github.com/leandrodaf/midi/sdk/contracts" +) + +func newDummyWindowsClient(t *testing.T) contracts.ClientMIDI { + t.Helper() + + client, err := midiwindows.NewMIDIClient(&contracts.ClientOptions{Logger: logger.NewLoggerWithWriter(io.Discard)}) + if err != nil { + t.Fatalf("NewMIDIClient returned error: %v", err) + } + + return client +} + +func TestDummyClient_ListDevices_ReturnsError(t *testing.T) { + client := newDummyWindowsClient(t) + + devices, err := client.ListDevices() + if err == nil { + t.Fatalf("expected ListDevices to return an error") + } + if devices != nil { + t.Errorf("expected ListDevices to return nil devices, got %#v", devices) + } +} + +func TestDummyClient_SelectDevice_ReturnsError(t *testing.T) { + client := newDummyWindowsClient(t) + + err := client.SelectDevice(0) + if err == nil { + t.Fatalf("expected SelectDevice to return an error") + } +} + +func TestDummyClient_StartCapture_ReturnsError(t *testing.T) { + client := newDummyWindowsClient(t) + + channel, err := client.StartCapture(context.Background()) + if err == nil { + t.Fatalf("expected StartCapture to return an error") + } + if channel != nil { + t.Errorf("expected StartCapture to return a nil channel") + } +} + +func TestDummyClient_Stop_ReturnsNil(t *testing.T) { + client := newDummyWindowsClient(t) + + if err := client.Stop(); err != nil { + t.Errorf("expected Stop to return nil, got %v", err) + } +} diff --git a/internal/midi/midiwindows/client_windows.go b/internal/midi/midiwindows/client_windows.go index 6e9f784..3349b41 100644 --- a/internal/midi/midiwindows/client_windows.go +++ b/internal/midi/midiwindows/client_windows.go @@ -4,6 +4,7 @@ package midiwindows import ( + "context" "errors" "fmt" "sync" @@ -15,26 +16,31 @@ import ( "golang.org/x/sys/windows" ) -// Type definitions for MIDI handles +var ( + ErrNoMIDIDevices = errors.New("no MIDI devices found") + ErrInvalidDeviceHandle = errors.New("invalid MIDI device handle") + ErrOpenFailed = errors.New("failed to open MIDI device") + ErrStartCaptureFailed = errors.New("failed to start MIDI capture") + ErrStopCaptureFailed = errors.New("failed to stop MIDI capture") + ErrCloseDeviceFailed = errors.New("failed to close MIDI device") +) + type HMIDIIN windows.Handle -// Constants for callback flags const ( - CALLBACK_FUNCTION = 0x00030000 // Indicates that the callback is a function - MIDI_IO_STATUS = 0x00000020 // MIDI input/output status + CALLBACK_FUNCTION = 0x00030000 + MIDI_IO_STATUS = 0x00000020 ) -// Constants for MIDI message types const ( - MIM_OPEN = 0x3C1 // MIDI device opened - MIM_CLOSE = 0x3C2 // MIDI device closed - MIM_DATA = 0x3C3 // MIDI data received - MIM_ERROR = 0x3C5 // MIDI error - MIM_LONGERROR = 0x3C6 // Long MIDI error - MIM_MOREDATA = 0x3CC // More MIDI data available + MIM_OPEN = 0x3C1 + MIM_CLOSE = 0x3C2 + MIM_DATA = 0x3C3 + MIM_ERROR = 0x3C5 + MIM_LONGERROR = 0x3C6 + MIM_MOREDATA = 0x3CC ) -// Struct representing MIDI device capabilities type midiInCaps struct { wMid uint16 wPid uint16 @@ -43,19 +49,20 @@ type midiInCaps struct { dwSupport uint32 } -// ClientMid manages MIDI on Windows type ClientMid struct { - logger contracts.Logger - eventChannel atomic.Value - handle HMIDIIN - portConn bool - mu sync.Mutex - callback uintptr - midiEventFilter *contracts.MIDIEventFilter - coreMIDIConfig *contracts.CoreMIDIConfig + logger contracts.Logger + eventChannel atomic.Value + handle HMIDIIN + portConn bool + mu sync.Mutex + callback uintptr + midiEventFilter *contracts.MIDIEventFilter + coreMIDIConfig *contracts.CoreMIDIConfig + channelBufferSize int + outCh chan contracts.MIDI + closeChOnce sync.Once } -// Load the winmm.dll library and required functions var ( winmm = windows.NewLazySystemDLL("winmm.dll") procMidiInGetNumDevs = winmm.NewProc("midiInGetNumDevs") @@ -66,49 +73,41 @@ var ( procMidiInClose = winmm.NewProc("midiInClose") ) -// NewMIDIClient creates a MIDI client for Windows func NewMIDIClient(options *contracts.ClientOptions) (contracts.ClientMIDI, error) { options.Logger.Info("MIDI client created for Windows") - return &ClientMid{ - logger: options.Logger, - midiEventFilter: options.MIDIEventFilter, - coreMIDIConfig: options.CoreMIDIConfig, + logger: options.Logger, + midiEventFilter: options.MIDIEventFilter, + coreMIDIConfig: options.CoreMIDIConfig, + channelBufferSize: options.ChannelBufferSize, }, nil } -// ListDevices lists the available MIDI devices func (m *ClientMid) ListDevices() ([]contracts.DeviceInfo, error) { r0, _, _ := procMidiInGetNumDevs.Call() - numDevices := uint32(r0) - if numDevices == 0 { - m.logger.Warn("No MIDI devices found") - return nil, errors.New("no MIDI devices found") + if uint32(r0) == 0 { + m.logger.Warn(ErrNoMIDIDevices.Error()) + return nil, ErrNoMIDIDevices } - + numDevices := uint32(r0) devices := make([]contracts.DeviceInfo, numDevices) for i := uint32(0); i < numDevices; i++ { var caps midiInCaps - r1, _, _ := procMidiInGetDevCaps.Call( - uintptr(i), - uintptr(unsafe.Pointer(&caps)), - unsafe.Sizeof(caps), - ) + r1, _, _ := procMidiInGetDevCaps.Call(uintptr(i), uintptr(unsafe.Pointer(&caps)), unsafe.Sizeof(caps)) if r1 != 0 { m.logger.Warn(fmt.Sprintf("Failed to get information for MIDI device %d", i)) continue } - deviceName := windows.UTF16ToString(caps.szPname[:]) + name := windows.UTF16ToString(caps.szPname[:]) devices[i] = contracts.DeviceInfo{ - Name: deviceName, - EntityName: deviceName, + Name: name, + EntityName: name, Manufacturer: fmt.Sprintf("MID: %d PID: %d", caps.wMid, caps.wPid), } } return devices, nil } -// SelectDevice selects a MIDI device func (m *ClientMid) SelectDevice(deviceID int) error { m.mu.Lock() defer m.mu.Unlock() @@ -120,57 +119,65 @@ func (m *ClientMid) SelectDevice(deviceID int) error { } m.callback = windows.NewCallback(midiInCallback) - fdwOpen := CALLBACK_FUNCTION | MIDI_IO_STATUS - - r1, _, err := procMidiInOpen.Call( + r1, _, _ := procMidiInOpen.Call( uintptr(unsafe.Pointer(&m.handle)), uintptr(deviceID), m.callback, uintptr(unsafe.Pointer(m)), - uintptr(fdwOpen), + uintptr(CALLBACK_FUNCTION|MIDI_IO_STATUS), ) if r1 != 0 { - m.logger.Error(fmt.Sprintf("Failed to open MIDI device %d: %v", deviceID, err)) - return fmt.Errorf("failed to open MIDI device %d: %v", deviceID, err) + return fmt.Errorf("%w %d: return code %d", ErrOpenFailed, deviceID, r1) } - m.portConn = true m.logger.Info(fmt.Sprintf("MIDI device %d connected", deviceID)) return nil } -// StartCapture initializes MIDI event capture -func (m *ClientMid) StartCapture(eventChannel chan contracts.MIDI) { +func (m *ClientMid) closeOutCh() { + m.closeChOnce.Do(func() { + if m.outCh != nil { + close(m.outCh) + } + }) +} + +func (m *ClientMid) StartCapture(ctx context.Context) (<-chan contracts.MIDI, error) { m.mu.Lock() defer m.mu.Unlock() if !m.portConn { - m.logger.Error("Cannot start capture: No MIDI device selected") - return + return nil, fmt.Errorf("no MIDI device selected") } - if _, ok := m.eventChannel.Load().(chan contracts.MIDI); ok { - m.logger.Warn("Capture already started") - return + size := m.channelBufferSize + if size <= 0 { + size = 100 } - - m.eventChannel.Store(eventChannel) + ch := make(chan contracts.MIDI, size) + m.outCh = ch + m.closeChOnce = sync.Once{} + m.eventChannel.Store((chan contracts.MIDI)(ch)) if m.handle == 0 { - m.logger.Error("Invalid MIDI device handle") - return + return nil, ErrInvalidDeviceHandle } - - r1, _, err := procMidiInStart.Call(uintptr(m.handle)) + r1, _, _ := procMidiInStart.Call(uintptr(m.handle)) if r1 != 0 { - m.logger.Error(fmt.Sprintf("Failed to start MIDI capture: %v", err)) - return + m.eventChannel.Store(make(chan contracts.MIDI)) + return nil, fmt.Errorf("%w: return code %d", ErrStartCaptureFailed, r1) } m.logger.Info("MIDI capture started") + + go func() { + <-ctx.Done() + _ = m.Stop() + }() + + return ch, nil } -// midiInCallback processes incoming MIDI messages func midiInCallback(hMidiIn uintptr, wMsg uint32, dwInstance uintptr, dwParam1 uintptr, dwParam2 uintptr) uintptr { m := (*ClientMid)(unsafe.Pointer(dwInstance)) @@ -183,7 +190,6 @@ func midiInCallback(hMidiIn uintptr, wMsg uint32, dwInstance uintptr, dwParam1 u if dwParam2 == 0 { return 0 } - status := byte(dwParam1 & 0xFF) data1 := byte((dwParam1 >> 8) & 0xFF) data2 := byte((dwParam1 >> 16) & 0xFF) @@ -191,29 +197,27 @@ func midiInCallback(hMidiIn uintptr, wMsg uint32, dwInstance uintptr, dwParam1 u command := status & 0xF0 channel := status & 0x0F - midiEvent := contracts.MIDI{ + event := contracts.MIDI{ Timestamp: uint64(time.Now().UTC().UnixNano()), Command: command, Note: data1, Velocity: data2, } - // Apply the MIDI event filter, checking if the command is allowed - if m.midiEventFilter != nil && !isCommandAllowed(midiEvent.Command, m.midiEventFilter.Commands) { + if !contracts.IsCommandAllowed(event.Command, m.midiEventFilter) { m.logger.Debug(fmt.Sprintf("MIDI command 0x%X filtered out", command)) return 0 } - if command == byte(contracts.NoteOn) && midiEvent.Velocity == 0 || command == byte(contracts.NoteOff) { - m.logger.Debug(fmt.Sprintf("Note Off: Channel %d, Note %d", channel+1, midiEvent.Note)) + if command == byte(contracts.NoteOn) && event.Velocity == 0 || command == byte(contracts.NoteOff) { + m.logger.Debug(fmt.Sprintf("Note Off: Channel %d, Note %d", channel+1, event.Note)) } else if command == byte(contracts.NoteOn) { - m.logger.Debug(fmt.Sprintf("Note On: Channel %d, Note %d, Velocity %d", channel+1, midiEvent.Note, midiEvent.Velocity)) + m.logger.Debug(fmt.Sprintf("Note On: Channel %d, Note %d, Velocity %d", channel+1, event.Note, event.Velocity)) } - // Send the event to the channel, with a warning in case the channel is full if ch, ok := m.eventChannel.Load().(chan contracts.MIDI); ok && ch != nil { select { - case ch <- midiEvent: + case ch <- event: default: m.logger.Warn("MIDI event channel is full; event discarded") } @@ -225,57 +229,38 @@ func midiInCallback(hMidiIn uintptr, wMsg uint32, dwInstance uintptr, dwParam1 u default: m.logger.Warn(fmt.Sprintf("Unknown MIDI message: 0x%X", wMsg)) } - return 0 } -// Stop terminates MIDI event capture and disconnects the device func (m *ClientMid) Stop() error { m.mu.Lock() defer m.mu.Unlock() if !m.portConn { - m.logger.Warn("No MIDI device is connected") return nil } - if err := m.stopCapture(); err != nil { return fmt.Errorf("failed to stop MIDI capture: %w", err) } m.logger.Info("MIDI capture stopped and device closed") + m.closeOutCh() return nil } -// stopCapture stops the capture and releases resources func (m *ClientMid) stopCapture() error { if m.handle == 0 { - return fmt.Errorf("invalid MIDI device handle") + return ErrInvalidDeviceHandle } - - r1, _, err := procMidiInStop.Call(uintptr(m.handle)) + r1, _, _ := procMidiInStop.Call(uintptr(m.handle)) if r1 != 0 { - m.logger.Error(fmt.Sprintf("Failed to stop MIDI capture: %v", err)) - return err + return fmt.Errorf("%w: return code %d", ErrStopCaptureFailed, r1) } - - r1, _, err = procMidiInClose.Call(uintptr(m.handle)) + r1, _, _ = procMidiInClose.Call(uintptr(m.handle)) if r1 != 0 { - m.logger.Error(fmt.Sprintf("Failed to close MIDI device: %v", err)) - return err + return fmt.Errorf("%w: return code %d", ErrCloseDeviceFailed, r1) } - m.portConn = false m.handle = 0 - m.eventChannel.Store(nil) + m.eventChannel.Store(make(chan contracts.MIDI)) return nil } - -// isCommandAllowed checks if the MIDI command is allowed by the filter -func isCommandAllowed(command byte, allowedCommands []contracts.MIDICommand) bool { - for _, allowedCommand := range allowedCommands { - if command == byte(allowedCommand) { - return true - } - } - return false -} diff --git a/sdk/contracts/config.go b/sdk/contracts/config.go new file mode 100644 index 0000000..f4c809f --- /dev/null +++ b/sdk/contracts/config.go @@ -0,0 +1,21 @@ +package contracts + +// MIDICommand represents the types of MIDI commands for event filtering. +type MIDICommand byte + +const ( + // NoteOn is the MIDI command for a Note On event (0x90). + NoteOn MIDICommand = 0x90 + // NoteOff is the MIDI command for a Note Off event (0x80). + NoteOff MIDICommand = 0x80 +) + +// MIDIEventFilter allows users to specify which MIDI commands to capture. +type MIDIEventFilter struct { + Commands []MIDICommand +} + +// CoreMIDIConfig holds configuration for CoreMIDI. +type CoreMIDIConfig struct { + ClientName string +} diff --git a/sdk/contracts/filter.go b/sdk/contracts/filter.go new file mode 100644 index 0000000..93eca0e --- /dev/null +++ b/sdk/contracts/filter.go @@ -0,0 +1,15 @@ +package contracts + +// IsCommandAllowed reports whether command passes the filter. +// If filter is nil, all commands are allowed. +func IsCommandAllowed(command byte, filter *MIDIEventFilter) bool { + if filter == nil { + return true + } + for _, allowed := range filter.Commands { + if command == byte(allowed) { + return true + } + } + return false +} diff --git a/sdk/contracts/filter_test.go b/sdk/contracts/filter_test.go new file mode 100644 index 0000000..3f531b5 --- /dev/null +++ b/sdk/contracts/filter_test.go @@ -0,0 +1,45 @@ +package contracts_test + +import ( + "testing" + + "github.com/leandrodaf/midi/sdk/contracts" +) + +func TestIsCommandAllowed_NilFilter(t *testing.T) { + if !contracts.IsCommandAllowed(byte(contracts.NoteOn), nil) { + t.Errorf("expected nil filter to allow all commands") + } +} + +func TestIsCommandAllowed_EmptyCommands(t *testing.T) { + filter := &contracts.MIDIEventFilter{Commands: []contracts.MIDICommand{}} + + if contracts.IsCommandAllowed(byte(contracts.NoteOn), filter) { + t.Errorf("expected empty command list to block all commands") + } +} + +func TestIsCommandAllowed_CommandPresent(t *testing.T) { + filter := &contracts.MIDIEventFilter{Commands: []contracts.MIDICommand{contracts.NoteOn}} + + if !contracts.IsCommandAllowed(byte(contracts.NoteOn), filter) { + t.Errorf("expected listed command to be allowed") + } +} + +func TestIsCommandAllowed_CommandAbsent(t *testing.T) { + filter := &contracts.MIDIEventFilter{Commands: []contracts.MIDICommand{contracts.NoteOff}} + + if contracts.IsCommandAllowed(byte(contracts.NoteOn), filter) { + t.Errorf("expected unlisted command to be blocked") + } +} + +func TestIsCommandAllowed_MultipleCommands(t *testing.T) { + filter := &contracts.MIDIEventFilter{Commands: []contracts.MIDICommand{contracts.NoteOff, contracts.NoteOn}} + + if !contracts.IsCommandAllowed(byte(contracts.NoteOn), filter) { + t.Errorf("expected command to match one of multiple allowed commands") + } +} diff --git a/sdk/contracts/logger.go b/sdk/contracts/logger.go index c9e5cfc..da3c8ec 100644 --- a/sdk/contracts/logger.go +++ b/sdk/contracts/logger.go @@ -6,51 +6,45 @@ import "time" type LogLevel int const ( - // InfoLevel indicates informational messages that highlight the progress of the application. - InfoLevel LogLevel = iota - // DebugLevel indicates debug messages that are useful for developers to troubleshoot issues. - DebugLevel - // ErrorLevel indicates error messages that represent serious issues that need attention. - ErrorLevel - // WarnLevel indicates potentially harmful situations that should be monitored. + DebugLevel LogLevel = iota + InfoLevel WarnLevel - // FatalLevel indicates very severe error events that will presumably lead the application to abort. + ErrorLevel FatalLevel ) -// LogDestination specifies where the log messages should be directed. +// LogDestination specifies where log messages are directed. type LogDestination string const ( - // ConsoleLog directs log messages to the console output. ConsoleLog LogDestination = "console" - // FileLog directs log messages to a file. - FileLog LogDestination = "file" + FileLog LogDestination = "file" ) -// Field representa um campo de log com vários tipos de dados. -type Field interface { - Bool(key string, val bool) Field - Int(key string, val int) Field - Float64(key string, val float64) Field - String(key string, val string) Field - Time(key string, val time.Time) Field - Int64(key string, val int64) Field - Error(key string, val error) Field - Uint64(key string, val uint64) Field - Uint8(key string, val uint8) Field +// Field is a structured log field holding a key-value pair. +type Field struct { + Key string + Value any } -// Logger fornece métodos para registrar mensagens em diferentes níveis. +// Standalone Field constructors — use these instead of logger.Field().XXX(). +func BoolField(key string, val bool) Field { return Field{key, val} } +func IntField(key string, val int) Field { return Field{key, val} } +func Float64Field(key string, val float64) Field { return Field{key, val} } +func StringField(key string, val string) Field { return Field{key, val} } +func TimeField(key string, val time.Time) Field { return Field{key, val} } +func Int64Field(key string, val int64) Field { return Field{key, val} } +func ErrField(key string, val error) Field { return Field{key, val} } +func Uint64Field(key string, val uint64) Field { return Field{key, val} } +func Uint8Field(key string, val uint8) Field { return Field{key, val} } + +// Logger provides methods for structured logging. type Logger interface { Info(msg string, fields ...Field) Error(msg string, fields ...Field) Debug(msg string, fields ...Field) Warn(msg string, fields ...Field) Fatal(msg string, fields ...Field) - - Field() Field - SetLevel(level LogLevel) SetDestination(dest LogDestination, filePath ...string) } diff --git a/sdk/contracts/midi.go b/sdk/contracts/midi.go index 8b48c09..85a4094 100644 --- a/sdk/contracts/midi.go +++ b/sdk/contracts/midi.go @@ -1,17 +1,25 @@ package contracts -// MIDI represents a MIDI event with a timestamp, command, note, and velocity. +import "context" + +// MIDI represents a MIDI event. type MIDI struct { - Timestamp uint64 // Timestamp indicates the time the event occurred. - Command byte // Command specifies the type of MIDI event (e.g., Note On, Note Off). - Note byte // Note represents the MIDI note number (0-127). - Velocity byte // Velocity indicates the strength of the note being played (0-127). + Timestamp uint64 + Command byte + Note byte + Velocity byte } -// ClientMIDI defines an interface for MIDI client operations. +// ClientMIDI defines the interface for MIDI client operations. type ClientMIDI interface { - Stop() error // Stops the MIDI client and releases resources. - ListDevices() ([]DeviceInfo, error) // Lists all available MIDI devices. - SelectDevice(deviceID int) error // Selects a MIDI device by its ID for communication. - StartCapture(eventChannel chan MIDI) // Starts capturing MIDI events and sends them to the specified channel. + // Stop halts MIDI event capture and releases resources. + Stop() error + // ListDevices returns all available MIDI input devices. + ListDevices() ([]DeviceInfo, error) + // SelectDevice selects a MIDI device by its index for capture. + SelectDevice(deviceID int) error + // StartCapture begins capturing MIDI events. It returns a read-only channel + // that receives events. The channel is closed when the context is cancelled + // or Stop() is called. The channel buffer size is controlled by WithChannelBufferSize. + StartCapture(ctx context.Context) (<-chan MIDI, error) } diff --git a/sdk/contracts/mock.go b/sdk/contracts/mock.go new file mode 100644 index 0000000..98c6cfd --- /dev/null +++ b/sdk/contracts/mock.go @@ -0,0 +1,48 @@ +package contracts + +import "context" + +// MockMIDIClient is a configurable ClientMIDI mock for tests. +type MockMIDIClient struct { + StartCaptureFunc func(ctx context.Context) (<-chan MIDI, error) + StopFunc func() error + ListDevicesFunc func() ([]DeviceInfo, error) + SelectDeviceFunc func(deviceID int) error + + StartCaptureCalls int + StopCalls int + ListDevicesCalls int + SelectDeviceCalls int +} + +func (m *MockMIDIClient) StartCapture(ctx context.Context) (<-chan MIDI, error) { + m.StartCaptureCalls++ + if m.StartCaptureFunc != nil { + return m.StartCaptureFunc(ctx) + } + return nil, nil +} + +func (m *MockMIDIClient) Stop() error { + m.StopCalls++ + if m.StopFunc != nil { + return m.StopFunc() + } + return nil +} + +func (m *MockMIDIClient) ListDevices() ([]DeviceInfo, error) { + m.ListDevicesCalls++ + if m.ListDevicesFunc != nil { + return m.ListDevicesFunc() + } + return nil, nil +} + +func (m *MockMIDIClient) SelectDevice(deviceID int) error { + m.SelectDeviceCalls++ + if m.SelectDeviceFunc != nil { + return m.SelectDeviceFunc(deviceID) + } + return nil +} diff --git a/sdk/contracts/mock_test.go b/sdk/contracts/mock_test.go new file mode 100644 index 0000000..ea22482 --- /dev/null +++ b/sdk/contracts/mock_test.go @@ -0,0 +1,141 @@ +package contracts_test + +import ( + "context" + "errors" + "testing" + + "github.com/leandrodaf/midi/sdk/contracts" +) + +var _ contracts.ClientMIDI = (*contracts.MockMIDIClient)(nil) + +func TestMockMIDIClient_UsesConfiguredFuncs(t *testing.T) { + expectedChannel := make(chan contracts.MIDI) + expectedStartErr := errors.New("start error") + expectedStopErr := errors.New("stop error") + expectedListErr := errors.New("list error") + expectedSelectErr := errors.New("select error") + expectedDevices := []contracts.DeviceInfo{{Name: "device-1"}} + + startCalled := false + stopCalled := false + listCalled := false + selectCalled := false + + client := &contracts.MockMIDIClient{ + StartCaptureFunc: func(ctx context.Context) (<-chan contracts.MIDI, error) { + startCalled = true + return expectedChannel, expectedStartErr + }, + StopFunc: func() error { + stopCalled = true + return expectedStopErr + }, + ListDevicesFunc: func() ([]contracts.DeviceInfo, error) { + listCalled = true + return expectedDevices, expectedListErr + }, + SelectDeviceFunc: func(deviceID int) error { + selectCalled = true + if deviceID != 7 { + t.Fatalf("expected device ID 7, got %d", deviceID) + } + return expectedSelectErr + }, + } + + channel, err := client.StartCapture(context.Background()) + if !startCalled { + t.Fatalf("expected StartCaptureFunc to be called") + } + if channel != expectedChannel { + t.Errorf("expected StartCapture to return configured channel") + } + if err != expectedStartErr { + t.Errorf("expected StartCapture to return configured error") + } + + err = client.Stop() + if !stopCalled { + t.Fatalf("expected StopFunc to be called") + } + if err != expectedStopErr { + t.Errorf("expected Stop to return configured error") + } + + devices, err := client.ListDevices() + if !listCalled { + t.Fatalf("expected ListDevicesFunc to be called") + } + if len(devices) != len(expectedDevices) || devices[0].Name != expectedDevices[0].Name { + t.Errorf("expected ListDevices to return configured devices") + } + if err != expectedListErr { + t.Errorf("expected ListDevices to return configured error") + } + + err = client.SelectDevice(7) + if !selectCalled { + t.Fatalf("expected SelectDeviceFunc to be called") + } + if err != expectedSelectErr { + t.Errorf("expected SelectDevice to return configured error") + } +} + +func TestMockMIDIClient_ZeroValuesWhenFuncsNil(t *testing.T) { + client := &contracts.MockMIDIClient{} + + channel, err := client.StartCapture(context.Background()) + if channel != nil { + t.Errorf("expected nil channel when StartCaptureFunc is not set") + } + if err != nil { + t.Errorf("expected nil error when StartCaptureFunc is not set, got %v", err) + } + + devices, err := client.ListDevices() + if devices != nil { + t.Errorf("expected nil devices when ListDevicesFunc is not set") + } + if err != nil { + t.Errorf("expected nil error when ListDevicesFunc is not set, got %v", err) + } + + err = client.SelectDevice(1) + if err != nil { + t.Errorf("expected nil error when SelectDeviceFunc is not set, got %v", err) + } + + err = client.Stop() + if err != nil { + t.Errorf("expected nil error when StopFunc is not set, got %v", err) + } +} + +func TestMockMIDIClient_CallCountersIncrement(t *testing.T) { + client := &contracts.MockMIDIClient{} + + _, _ = client.StartCapture(context.Background()) + _, _ = client.StartCapture(context.Background()) + _ = client.Stop() + _, _ = client.ListDevices() + _, _ = client.ListDevices() + _ = client.SelectDevice(1) + _ = client.SelectDevice(2) + _ = client.SelectDevice(3) + + if client.StartCaptureCalls != 2 { + t.Errorf("expected StartCaptureCalls to be 2, got %d", client.StartCaptureCalls) + } + if client.StopCalls != 1 { + t.Errorf("expected StopCalls to be 1, got %d", client.StopCalls) + } + if client.ListDevicesCalls != 2 { + t.Errorf("expected ListDevicesCalls to be 2, got %d", client.ListDevicesCalls) + } + if client.SelectDeviceCalls != 3 { + t.Errorf("expected SelectDeviceCalls to be 3, got %d", client.SelectDeviceCalls) + } +} diff --git a/sdk/contracts/options.go b/sdk/contracts/options.go index fbac57f..c68af7d 100644 --- a/sdk/contracts/options.go +++ b/sdk/contracts/options.go @@ -1,61 +1,70 @@ package contracts -// MIDICommand represents the types of MIDI commands for event filtering. -type MIDICommand byte - -const ( - // NoteOn is the MIDI command for a Note On event (0x90). - NoteOn MIDICommand = 0x90 - // NoteOff is the MIDI command for a Note Off event (0x80). - NoteOff MIDICommand = 0x80 -) - -// MIDIEventFilter allows users to specify which MIDI commands to capture. -type MIDIEventFilter struct { - Commands []MIDICommand // List of MIDI commands to filter. -} - -// CoreMIDIConfig holds configuration for CoreMIDI. -type CoreMIDIConfig struct { - ClientName string // Name of the MIDI client. +// ClientOptions holds all configuration for the MIDI client. +type ClientOptions struct { + Logger Logger + LogLevel LogLevel + logLevelSet bool // true when WithLogLevel was called explicitly + LogDestination LogDestination + LogFilePath string + MIDIEventFilter *MIDIEventFilter + CoreMIDIConfig *CoreMIDIConfig + ChannelBufferSize int } -// ClientOptions defines the configuration options for the MIDI client. -type ClientOptions struct { - Logger Logger // Logger for logging events and errors. - LogLevel LogLevel // Level of logging to use. - LogFilePath string // File path for logging if file logging is enabled. - MIDIEventFilter *MIDIEventFilter // Optional filter for MIDI events to capture. - CoreMIDIConfig *CoreMIDIConfig // Configuration specific to CoreMIDI. +// LogLevelIsSet reports whether WithLogLevel was explicitly called. +func (o *ClientOptions) LogLevelIsSet() bool { + return o.logLevelSet } // Option is a function that modifies ClientOptions. type Option func(*ClientOptions) -// WithLogger sets the logger for the MIDI client. +// WithLogger sets a custom logger. func WithLogger(l Logger) Option { return func(opts *ClientOptions) { opts.Logger = l } } -// WithLogLevel sets the logging level for the MIDI client. +// WithLogLevel sets the minimum logging level. func WithLogLevel(level LogLevel) Option { return func(opts *ClientOptions) { opts.LogLevel = level + opts.logLevelSet = true + } +} + +// WithLogDestination directs log output to console or file. +// When dest is FileLog, filePath must be provided. +func WithLogDestination(dest LogDestination, filePath ...string) Option { + return func(opts *ClientOptions) { + opts.LogDestination = dest + if len(filePath) > 0 { + opts.LogFilePath = filePath[0] + } } } -// WithMIDIEventFilter sets the MIDI event filter for the MIDI client. +// WithMIDIEventFilter sets a command allowlist. Only listed commands are delivered. +// Pass nil or omit to receive all commands. func WithMIDIEventFilter(filter MIDIEventFilter) Option { return func(opts *ClientOptions) { opts.MIDIEventFilter = &filter } } -// WithCoreMIDIConfig sets the CoreMIDI configuration for the MIDI client. +// WithCoreMIDIConfig sets CoreMIDI-specific configuration (macOS only). func WithCoreMIDIConfig(config CoreMIDIConfig) Option { return func(opts *ClientOptions) { opts.CoreMIDIConfig = &config } } + +// WithChannelBufferSize sets the buffer size of the event channel returned by StartCapture. +// Default is 100. +func WithChannelBufferSize(n int) Option { + return func(opts *ClientOptions) { + opts.ChannelBufferSize = n + } +} diff --git a/sdk/midi/options_setup.go b/sdk/midi/options_setup.go index 2e4561b..debc0a2 100644 --- a/sdk/midi/options_setup.go +++ b/sdk/midi/options_setup.go @@ -5,31 +5,29 @@ import ( "github.com/leandrodaf/midi/sdk/contracts" ) -// applyDefaultOptions sets default values for ClientOptions if not explicitly provided. -// -// opts ...contracts.Option: A variadic list of option functions that can modify ClientOptions. -// -// Returns: -// - contracts.ClientOptions: A structure containing the finalized client options with defaults applied. -// - error: An error if there was an issue applying the options. func applyDefaultOptions(opts ...contracts.Option) (contracts.ClientOptions, error) { options := &contracts.ClientOptions{} for _, opt := range opts { opt(options) } - // Set defaults if options are not provided if options.Logger == nil { - options.Logger = logger.NewZapLogger() // Default to a standard logger + options.Logger = logger.NewLogger() } - if options.LogLevel == 0 { - options.LogLevel = contracts.InfoLevel // Default log level to InfoLevel + if !options.LogLevelIsSet() { + options.LogLevel = contracts.InfoLevel } - if options.CoreMIDIConfig == nil { - options.CoreMIDIConfig = &contracts.CoreMIDIConfig{ClientName: "GO MIDI Client"} // Default CoreMIDI config + options.CoreMIDIConfig = &contracts.CoreMIDIConfig{ClientName: "GO MIDI Client"} + } + if options.ChannelBufferSize <= 0 { + options.ChannelBufferSize = 100 + } + + options.Logger.SetLevel(options.LogLevel) + if options.LogDestination != "" { + options.Logger.SetDestination(options.LogDestination, options.LogFilePath) } - options.Logger.SetLevel(options.LogLevel) // Set the logger to the specified log level return *options, nil } diff --git a/sdk/midi/options_setup_test.go b/sdk/midi/options_setup_test.go new file mode 100644 index 0000000..82e73a0 --- /dev/null +++ b/sdk/midi/options_setup_test.go @@ -0,0 +1,103 @@ +package midi + +import ( + "testing" + + "github.com/leandrodaf/midi/sdk/contracts" +) + +type stubLogger struct{} + +func (l *stubLogger) Info(msg string, fields ...contracts.Field) {} +func (l *stubLogger) Error(msg string, fields ...contracts.Field) {} +func (l *stubLogger) Debug(msg string, fields ...contracts.Field) {} +func (l *stubLogger) Warn(msg string, fields ...contracts.Field) {} +func (l *stubLogger) Fatal(msg string, fields ...contracts.Field) {} +func (l *stubLogger) SetLevel(level contracts.LogLevel) {} +func (l *stubLogger) SetDestination(dest contracts.LogDestination, filePath ...string) { +} + +func TestApplyDefaultOptions_DefaultLogger(t *testing.T) { + options, err := applyDefaultOptions() + if err != nil { + t.Fatalf("applyDefaultOptions returned error: %v", err) + } + if options.Logger == nil { + t.Errorf("expected default logger to be set") + } +} + +func TestApplyDefaultOptions_DefaultLogLevel(t *testing.T) { + options, err := applyDefaultOptions() + if err != nil { + t.Fatalf("applyDefaultOptions returned error: %v", err) + } + if options.LogLevel != contracts.InfoLevel { + t.Errorf("expected default log level %v, got %v", contracts.InfoLevel, options.LogLevel) + } +} + +func TestApplyDefaultOptions_DefaultChannelBufferSize(t *testing.T) { + options, err := applyDefaultOptions() + if err != nil { + t.Fatalf("applyDefaultOptions returned error: %v", err) + } + if options.ChannelBufferSize != 100 { + t.Errorf("expected default channel buffer size 100, got %d", options.ChannelBufferSize) + } +} + +func TestApplyDefaultOptions_DefaultCoreMIDIConfig(t *testing.T) { + options, err := applyDefaultOptions() + if err != nil { + t.Fatalf("applyDefaultOptions returned error: %v", err) + } + if options.CoreMIDIConfig == nil { + t.Errorf("expected default CoreMIDIConfig to be set") + } +} + +func TestApplyDefaultOptions_WithLogLevel(t *testing.T) { + options, err := applyDefaultOptions(contracts.WithLogLevel(contracts.DebugLevel)) + if err != nil { + t.Fatalf("applyDefaultOptions returned error: %v", err) + } + if options.LogLevel != contracts.DebugLevel { + t.Errorf("expected log level %v, got %v", contracts.DebugLevel, options.LogLevel) + } + if !options.LogLevelIsSet() { + t.Errorf("expected LogLevelIsSet to be true when WithLogLevel is used") + } +} + +func TestApplyDefaultOptions_PreservesCustomLogger(t *testing.T) { + customLogger := &stubLogger{} + + options, err := applyDefaultOptions(contracts.WithLogger(customLogger)) + if err != nil { + t.Fatalf("applyDefaultOptions returned error: %v", err) + } + if options.Logger != customLogger { + t.Errorf("expected custom logger to be preserved") + } +} + +func TestApplyDefaultOptions_WithChannelBufferSize(t *testing.T) { + options, err := applyDefaultOptions(contracts.WithChannelBufferSize(50)) + if err != nil { + t.Fatalf("applyDefaultOptions returned error: %v", err) + } + if options.ChannelBufferSize != 50 { + t.Errorf("expected channel buffer size 50, got %d", options.ChannelBufferSize) + } +} + +func TestApplyDefaultOptions_LogLevelNotSetByDefault(t *testing.T) { + options, err := applyDefaultOptions() + if err != nil { + t.Fatalf("applyDefaultOptions returned error: %v", err) + } + if options.LogLevelIsSet() { + t.Errorf("expected LogLevelIsSet to be false by default") + } +} From 4bd30c95895b17dec238be9e542e13d205199aee Mon Sep 17 00:00:00 2001 From: Leandro Ferreira Date: Thu, 14 May 2026 09:10:58 -0300 Subject: [PATCH 2/4] ci: add GitHub Actions workflow for Linux, Windows, and macOS - vet job runs go vet on ubuntu first - test matrix: macos-latest (CGo=1, CoreMIDI), windows-latest (CGo=0, syscalls), ubuntu-latest (CGo=0, dummy stubs) - uses go-version-file: go.mod to track version automatically - fail-fast: false so all three platforms always run independently Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 66 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2040008 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,66 @@ +name: CI + +on: + push: + branches: ["main", "modernize/**", "feat/**", "fix/**"] + pull_request: + branches: ["main"] + +jobs: + # ── Lint & vet ──────────────────────────────────────────────────────────── + vet: + name: Vet + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + - name: go vet + run: go vet ./... + + # ── Build + test matrix ─────────────────────────────────────────────────── + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + needs: vet + strategy: + fail-fast: false + matrix: + include: + # macOS: real CoreMIDI implementation via CGo. + # Xcode CLI tools + CoreMIDI/CoreFoundation/CoreServices frameworks + # are pre-installed on GitHub-hosted macOS runners. + - os: macos-latest + cgo: "1" + notes: "CoreMIDI (CGo)" + + # Windows: real winmm.dll implementation via pure-Go syscalls. + # No CGo required. + - os: windows-latest + cgo: "0" + notes: "winmm.dll (syscalls)" + + # Linux: not officially supported — dummy stubs compile and all + # pure-Go tests (contracts, logger, options) run normally. + - os: ubuntu-latest + cgo: "0" + notes: "dummy stubs (unsupported platform)" + + env: + CGO_ENABLED: ${{ matrix.cgo }} + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache: true + + - name: Build + run: go build ./... + + - name: Test + run: go test -v -race ./... From b285321997fdac979d7f6ca0a2c51d428262b42e Mon Sep 17 00:00:00 2001 From: Leandro Ferreira Date: Thu, 14 May 2026 09:14:26 -0300 Subject: [PATCH 3/4] ci: fix -race on Windows and suppress Node.js 20 warnings - Windows: CGO_ENABLED=1 (gcc pre-installed on runners) so -race works - Linux: CGO_ENABLED=0 (no C toolchain needed for dummy stubs), race='' to skip -race - Add FORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true env to silence deprecation warnings for actions/checkout and actions/setup-go Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2040008..b7db4b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,11 +6,17 @@ on: pull_request: branches: ["main"] +# Use Node.js 24 for all actions to avoid Node.js 20 deprecation warnings. +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + jobs: # ── Lint & vet ──────────────────────────────────────────────────────────── vet: name: Vet runs-on: ubuntu-latest + env: + CGO_ENABLED: "0" steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -34,19 +40,22 @@ jobs: # are pre-installed on GitHub-hosted macOS runners. - os: macos-latest cgo: "1" - notes: "CoreMIDI (CGo)" + race: "-race" - # Windows: real winmm.dll implementation via pure-Go syscalls. - # No CGo required. + # Windows: winmm.dll implementation via pure-Go syscalls. + # CGO_ENABLED=1 is required only for the -race detector (gcc is + # pre-installed on GitHub Windows runners via MinGW). - os: windows-latest - cgo: "0" - notes: "winmm.dll (syscalls)" + cgo: "1" + race: "-race" # Linux: not officially supported — dummy stubs compile and all # pure-Go tests (contracts, logger, options) run normally. + # CGO_ENABLED=0 avoids needing a C toolchain; -race is skipped + # because the race detector requires CGo. - os: ubuntu-latest cgo: "0" - notes: "dummy stubs (unsupported platform)" + race: "" env: CGO_ENABLED: ${{ matrix.cgo }} @@ -63,4 +72,4 @@ jobs: run: go build ./... - name: Test - run: go test -v -race ./... + run: go test -v ${{ matrix.race }} ./... From 1bbb26fd3dceecf0af7d9c86df8d5ff731ab5427 Mon Sep 17 00:00:00 2001 From: Leandro Ferreira Date: Thu, 14 May 2026 09:18:31 -0300 Subject: [PATCH 4/4] ci: upgrade actions/checkout and actions/setup-go to v6 (Node.js 24) Eliminates Node.js 20 deprecation warnings. Both v6 releases natively target Node.js 24. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ci.yml | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b7db4b9..a764f0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,9 +6,6 @@ on: pull_request: branches: ["main"] -# Use Node.js 24 for all actions to avoid Node.js 20 deprecation warnings. -env: - FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true jobs: # ── Lint & vet ──────────────────────────────────────────────────────────── @@ -18,8 +15,8 @@ jobs: env: CGO_ENABLED: "0" steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true @@ -61,9 +58,9 @@ jobs: CGO_ENABLED: ${{ matrix.cgo }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - - uses: actions/setup-go@v5 + - uses: actions/setup-go@v6 with: go-version-file: go.mod cache: true