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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -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.
72 changes: 72 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
name: CI

on:
push:
branches: ["main", "modernize/**", "feat/**", "fix/**"]
pull_request:
branches: ["main"]


jobs:
# ── Lint & vet ────────────────────────────────────────────────────────────
vet:
name: Vet
runs-on: ubuntu-latest
env:
CGO_ENABLED: "0"
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
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"
race: "-race"

# 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: "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"
race: ""

env:
CGO_ENABLED: ${{ matrix.cgo }}

steps:
- uses: actions/checkout@v6

- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true

- name: Build
run: go build ./...

- name: Test
run: go test -v ${{ matrix.race }} ./...
54 changes: 54 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
162 changes: 76 additions & 86 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand Down
Loading
Loading