require 'portmidi'
require 'launchpad/errors'
require 'launchpad/logging'
require 'launchpad/midi_codes'
require 'launchpad/version'
module Launchpad
# This class is used to exchange data with the launchpad.
# It provides methods to light LEDs and to get information about button presses/releases.
#
# Example:
#
# require 'launchpad/device'
#
# device = Launchpad::Device.new
# device.test_leds
# sleep 1
# device.reset
# sleep 1
# device.change :grid, :x => 4, :y => 4, :red => :high, :green => :low
class Device
include Logging
include MidiCodes
CODE_NOTE_TO_DATA_TYPE = {
[Status::ON, SceneButton::SCENE1] => :scene1,
[Status::ON, SceneButton::SCENE2] => :scene2,
[Status::ON, SceneButton::SCENE3] => :scene3,
[Status::ON, SceneButton::SCENE4] => :scene4,
[Status::ON, SceneButton::SCENE5] => :scene5,
[Status::ON, SceneButton::SCENE6] => :scene6,
[Status::ON, SceneButton::SCENE7] => :scene7,
[Status::ON, SceneButton::SCENE8] => :scene8,
[Status::CC, ControlButton::UP] => :up,
[Status::CC, ControlButton::DOWN] => :down,
[Status::CC, ControlButton::LEFT] => :left,
[Status::CC, ControlButton::RIGHT] => :right,
[Status::CC, ControlButton::SESSION] => :session,
[Status::CC, ControlButton::USER1] => :user1,
[Status::CC, ControlButton::USER2] => :user2,
[Status::CC, ControlButton::MIXER] => :mixer
}.freeze
# TODO: Rename scenes to match Mk2
TYPE_TO_NOTE = {
:up => ControlButton::UP,
:down => ControlButton::DOWN,
:left => ControlButton::LEFT,
:right => ControlButton::RIGHT,
:session => ControlButton::SESSION,
:user1 => ControlButton::USER1,
:user2 => ControlButton::USER2,
:mixer => ControlButton::MIXER,
:scene1 => SceneButton::SCENE1,
:scene2 => SceneButton::SCENE2,
:scene3 => SceneButton::SCENE3,
:scene4 => SceneButton::SCENE4,
:scene5 => SceneButton::SCENE5,
:scene6 => SceneButton::SCENE6,
:scene7 => SceneButton::SCENE7,
:scene8 => SceneButton::SCENE8
}.freeze
# Initializes the launchpad device. When output capabilities are requested,
# the launchpad will be reset.
#
# Optional options hash:
#
# [:input] whether to use MIDI input for user interaction,
# true/false, optional, defaults to +true+
# [:output] whether to use MIDI output for data display,
# true/false, optional, defaults to +true+
# [:input_device_id] ID of the MIDI input device to use,
# optional, :device_name will be used if omitted
# [:output_device_id] ID of the MIDI output device to use,
# optional, :device_name will be used if omitted
# [:device_name] Name of the MIDI device to use,
# optional, defaults to "Launchpad"
# [:logger] [Logger] to be used by this device instance, can be changed afterwards
#
# Errors raised:
#
# [Launchpad::NoSuchDeviceError] when device with ID or name specified does not exist
# [Launchpad::DeviceBusyError] when device with ID or name specified is busy
def initialize(opts = nil)
opts = {
:input => true,
:output => true
}.merge(opts || {})
self.logger = opts[:logger]
logger.debug "initializing Launchpad::Device##{object_id} with #{opts.inspect}"
Portmidi.start
@input = create_device!(Portmidi.input_devices, Portmidi::Input,
:id => opts[:input_device_id],
:name => opts[:device_name]
) if opts[:input]
@output = create_device!(Portmidi.output_devices, Portmidi::Output,
:id => opts[:output_device_id],
:name => opts[:device_name]
) if opts[:output]
reset if output_enabled?
end
# Closes the device - nothing can be done with the device afterwards.
def close
logger.debug "closing Launchpad::Device##{object_id}"
@input.close unless @input.nil?
@input = nil
@output.close unless @output.nil?
@output = nil
end
# Determines whether this device has been closed.
def closed?
!(input_enabled? || output_enabled?)
end
# Determines whether this device can be used to read input.
def input_enabled?
!@input.nil?
end
# Determines whether this device can be used to output data.
def output_enabled?
!@output.nil?
end
# Resets the launchpad - all settings are reset and all LEDs are switched off.
#
# Errors raised:
#
# [Launchpad::NoOutputAllowedError] when output is not enabled
def reset
output(Status::CC, Status::NIL, Status::NIL)
end
# Lights all LEDs (for testing purposes).
#
# Parameters (see Launchpad for values):
#
# [+brightness+] brightness of both LEDs for all buttons
#
# Errors raised:
#
# [Launchpad::NoOutputAllowedError] when output is not enabled
# def test_leds(brightness = :high)
# brightness = brightness(brightness)
# if brightness == 0
# reset
# else
# output(Status::CC, Status::NIL, Velocity::TEST_LEDS + brightness)
# end
# end
# Changes a single LED.
#
# Parameters (see Launchpad for values):
#
# [+type+] type of the button to change
#
# Optional options hash (see Launchpad for values):
#
# [:x] x coordinate
# [:y] y coordinate
# [:red] brightness of red LED
# [:green] brightness of green LED
# [:mode] button mode, defaults to :normal, one of:
# [:normal/tt>] updates the LED for all circumstances (the new value will be written to both buffers)
# [:flashing/tt>] updates the LED for flashing (the new value will be written to buffer 0 while the LED will be off in buffer 1, see buffering_mode)
# [:buffering/tt>] updates the LED for the current update_buffer only
#
# Errors raised:
#
# [Launchpad::NoValidGridCoordinatesError] when coordinates aren't within the valid range
# [Launchpad::NoValidBrightnessError] when brightness values aren't within the valid range
# [Launchpad::NoOutputAllowedError] when output is not enabled
# def change(type, opts = nil)
# opts ||= {}
# status = %w(up down left right session user1 user2 mixer).include?(type.to_s) ? Status::CC : Status::ON
# output(status, note(type, opts), velocity(opts))
# end
def change_grid(x, y, r, g, b)
led = (y * 10) + x + 11
@output.write_sysex([
# SysEx Begin:
0xF0,
# Manufacturer/Device:
0x00,
0x20,
0x29,
0x02,
0x18,
# Command:
0x0B,
# LED:
led,
# Red, Green, Blue:
r,
g,
b,
# SysEx End:
0xF7,
])
end
def change_command(position, r, g, b)
led = TYPE_TO_NOTE[position]
@output.write_sysex([
# SysEx Begin:
0xF0,
# Manufacturer/Device:
0x00,
0x20,
0x29,
0x02,
0x18,
# Command:
0x0B,
# LED:
led,
# Red, Green, Blue:
r,
g,
b,
# SysEx End:
0xF7,
])
end
# Changes all LEDs in batch mode.
#
# Parameters (see Launchpad for values):
#
# [+colors] an array of colors, each either being an integer or a Hash
# * integer: calculated using the formula
# color = 16 * green + red
# * Hash:
# [:red] brightness of red LED
# [:green] brightness of green LED
# [:mode] button mode, defaults to :normal, one of:
# [:normal/tt>] updates the LEDs for all circumstances (the new value will be written to both buffers)
# [:flashing/tt>] updates the LEDs for flashing (the new values will be written to buffer 0 while the LEDs will be off in buffer 1, see buffering_mode)
# [:buffering/tt>] updates the LEDs for the current update_buffer only
# the array consists of 64 colors for the grid buttons,
# 8 colors for the scene buttons (top to bottom)
# and 8 colors for the top control buttons (left to right),
# maximum 80 values - excessive values will be ignored,
# missing values will be filled with 0
#
# Errors raised:
#
# [Launchpad::NoValidBrightnessError] when brightness values aren't within the valid range
# [Launchpad::NoOutputAllowedError] when output is not enabled
# def change_all(*colors)
# # ensure that colors is at least and most 80 elements long
# colors = colors.flatten[0..79]
# colors += [0] * (80 - colors.size) if colors.size < 80
# # send normal MIDI message to reset rapid LED change pointer
# # in this case, set mapping mode to x-y layout (the default)
# output(Status::CC, Status::NIL, GridLayout::XY)
# # send colors in slices of 2
# messages = []
# colors.each_slice(2) do |c1, c2|
# messages << message(Status::MULTI, velocity(c1), velocity(c2))
# end
# output_messages(messages)
# end
# Switches LEDs marked as flashing on when using custom timer for flashing.
#
# Errors raised:
#
# [Launchpad::NoOutputAllowedError] when output is not enabled
# def flashing_on
# buffering_mode(:display_buffer => 0)
# end
# Switches LEDs marked as flashing off when using custom timer for flashing.
#
# Errors raised:
#
# [Launchpad::NoOutputAllowedError] when output is not enabled
# def flashing_off
# buffering_mode(:display_buffer => 1)
# end
# Starts flashing LEDs marked as flashing automatically.
# Stop flashing by calling flashing_on or flashing_off.
#
# Errors raised:
#
# [Launchpad::NoOutputAllowedError] when output is not enabled
# def flashing_auto
# buffering_mode(:flashing => true)
# end
# Controls the two buffers.
#
# Optional options hash:
#
# [:display_buffer] which buffer to use for display, defaults to +0+
# [:update_buffer] which buffer to use for updates when :mode is set to :buffering, defaults to +0+ (see change)
# [:copy] whether to copy the LEDs states from the new display_buffer over to the new update_buffer, true/false, defaults to false
# [:flashing] whether to start flashing by automatically switching between the two buffers for display, true/false, defaults to false
#
# Errors raised:
#
# [Launchpad::NoOutputAllowedError] when output is not enabled
# def buffering_mode(opts = nil)
# opts = {
# :display_buffer => 0,
# :update_buffer => 0,
# :copy => false,
# :flashing => false
# }.merge(opts || {})
# data = opts[:display_buffer] + 4 * opts[:update_buffer] + 32
# data += 16 if opts[:copy]
# data += 8 if opts[:flashing]
# output(Status::CC, Status::NIL, data)
# end
# Reads user actions (button presses/releases) that haven't been handled yet.
# This is non-blocking, so when nothing happend yet you'll get an empty array.
#
# Returns:
#
# an array of hashes with (see Launchpad for values):
#
# [:timestamp] integer indicating the time when the action occured
# [:state] state of the button after action
# [:type] type of the button
# [:x] x coordinate
# [:y] y coordinate
#
# Errors raised:
#
# [Launchpad::NoInputAllowedError] when input is not enabled
def read_pending_actions
Array(input).collect do |midi_message|
(code, note, velocity) = midi_message[:message]
data = {
:timestamp => midi_message[:timestamp],
:state => (velocity == 127 ? :down : :up)
}
data[:type] = CODE_NOTE_TO_DATA_TYPE[[code, note]] || :grid
if data[:type] == :grid
note = note - 11
data[:x] = note % 10
data[:y] = note / 10
end
data
end
end
private
# Creates input/output devices.
#
# Parameters:
#
# [+devices+] array of portmidi devices
# [+device_type] class to instantiate (Portmidi::Input/Portmidi::Output)
#
# Options hash:
#
# [:id] id of the MIDI device to use
# [:name] name of the MIDI device to use,
# only used when :id is not specified,
# defaults to "Launchpad"
#
# Returns:
#
# newly created device
#
# Errors raised:
#
# [Launchpad::NoSuchDeviceError] when device with ID or name specified does not exist
# [Launchpad::DeviceBusyError] when device with ID or name specified is busy
def create_device!(devices, device_type, opts)
logger.debug "creating #{device_type} with #{opts.inspect}, choosing from portmidi devices #{devices.inspect}"
id = opts[:id]
if id.nil?
name = opts[:name] || "Launchpad MK2"
device = devices.select {|dev| dev.name == name}.first
id = device.device_id unless device.nil?
end
if id.nil?
message = "MIDI device #{opts[:id] || opts[:name]} doesn't exist"
logger.fatal message
raise NoSuchDeviceError.new(message)
end
device_type.new(id)
rescue RuntimeError => e
logger.fatal "error creating #{device_type}: #{e.inspect}"
raise DeviceBusyError.new(e)
end
# Reads input from the MIDI device.
#
# Returns:
#
# an array of hashes with:
#
# [:message] an array of
# MIDI status code,
# MIDI data 1 (note),
# MIDI data 2 (velocity)
# and a fourth value
# [:timestamp] integer indicating the time when the MIDI message was created
#
# Errors raised:
#
# [Launchpad::NoInputAllowedError] when output is not enabled
def input
if @input.nil?
logger.error "trying to read from device that's not been initialized for input"
raise NoInputAllowedError
end
@input.read(16)
end
# Writes data to the MIDI device.
#
# Parameters:
#
# [+status+] MIDI status code
# [+data1+] MIDI data 1 (note)
# [+data2+] MIDI data 2 (velocity)
#
# Errors raised:
#
# [Launchpad::NoOutputAllowedError] when output is not enabled
def output(status, data1, data2)
output_messages([message(status, data1, data2)])
end
# Writes several messages to the MIDI device.
#
# Parameters:
#
# [+messages+] an array of hashes (usually created with message) with:
# [:message] an array of
# MIDI status code,
# MIDI data 1 (note),
# MIDI data 2 (velocity)
# [:timestamp] integer indicating the time when the MIDI message was created
def output_messages(messages)
if @output.nil?
logger.error "trying to write to device that's not been initialized for output"
raise NoOutputAllowedError
end
logger.debug "writing messages to launchpad:\n #{messages.join("\n ")}" if logger.debug?
@output.write(messages)
nil
end
# Calculates the MIDI data 1 value (note) for a button.
#
# Parameters (see Launchpad for values):
#
# [+type+] type of the button
#
# Options hash:
#
# [:x] x coordinate
# [:y] y coordinate
#
# Returns:
#
# integer to be used for MIDI data 1
#
# Errors raised:
#
# [Launchpad::NoValidGridCoordinatesError] when coordinates aren't within the valid range
def note(type, opts)
note = TYPE_TO_NOTE[type]
if note.nil?
x = (opts[:x] || -1).to_i
y = (opts[:y] || -1).to_i
if x < 0 || x > 7 || y < 0 || y > 7
logger.error "wrong coordinates specified: x=#{x}, y=#{y}"
raise NoValidGridCoordinatesError.new("you need to specify valid coordinates (x/y, 0-7, from top left), you specified: x=#{x}, y=#{y}")
end
note = y * 10 + x
end
note
end
# Calculates the MIDI data 2 value (velocity) for given brightness and mode values.
#
# Options hash:
#
# [:red] brightness of red LED
# [:green] brightness of green LED
# [:mode] button mode, defaults to :normal, one of:
# [:normal/tt>] updates the LED for all circumstances (the new value will be written to both buffers)
# [:flashing/tt>] updates the LED for flashing (the new value will be written to buffer 0 while in buffer 1, the value will be :off, see )
# [:buffering/tt>] updates the LED for the current update_buffer only
#
# Returns:
#
# integer to be used for MIDI data 2
#
# Errors raised:
#
# [Launchpad::NoValidBrightnessError] when brightness values aren't within the valid range
# def velocity(opts)
# if opts.is_a?(Hash)
# red = brightness(opts[:red] || 0)
# green = brightness(opts[:green] || 0)
# color = 16 * green + red
# flags = case opts[:mode]
# when :flashing then 8
# when :buffering then 0
# else 12
# end
# color + flags
# else
# opts.to_i + 12
# end
# end
# Calculates the integer brightness for given brightness values.
#
# Parameters (see Launchpad for values):
#
# [+brightness+] brightness
#
# Errors raised:
#
# [Launchpad::NoValidBrightnessError] when brightness values aren't within the valid range
# def brightness(brightness)
# case brightness
# when 0, :off then 0
# when 1, :low, :lo then 1
# when 2, :medium, :med then 2
# when 3, :high, :hi then 3
# else
# logger.error "wrong brightness specified: #{brightness}"
# raise NoValidBrightnessError.new("you need to specify the brightness as 0/1/2/3, :off/:low/:medium/:high or :off/:lo/:hi, you specified: #{brightness}")
# end
# end
# Creates a MIDI message.
#
# Parameters:
#
# [+status+] MIDI status code
# [+data1+] MIDI data 1 (note)
# [+data2+] MIDI data 2 (velocity)
#
# Returns:
#
# an array with:
#
# [:message] an array of
# MIDI status code,
# MIDI data 1 (note),
# MIDI data 2 (velocity)
# [:timestamp] integer indicating the time when the MIDI message was created, in this case 0
def message(status, data1, data2)
{:message => [status, data1, data2], :timestamp => 0}
end
end
end