require 'portmidi'
require 'launchpad/errors'
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 'rubygems'
# 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 MidiCodes
# 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"
#
# 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 || {})
Portmidi.start
@input = device(Portmidi.input_devices, Portmidi::Input, :id => opts[:input_device_id], :name => opts[:device_name]) if opts[:input]
@output = 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
@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
# 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.
#
# 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] = case code
when Status::ON
case note
when SceneButton::SCENE1 then :scene1
when SceneButton::SCENE2 then :scene2
when SceneButton::SCENE3 then :scene3
when SceneButton::SCENE4 then :scene4
when SceneButton::SCENE5 then :scene5
when SceneButton::SCENE6 then :scene6
when SceneButton::SCENE7 then :scene7
when SceneButton::SCENE8 then :scene8
else
data[:x] = note % 16
data[:y] = note / 16
:grid
end
when Status::CC
case note
when ControlButton::UP then :up
when ControlButton::DOWN then :down
when ControlButton::LEFT then :left
when ControlButton::RIGHT then :right
when ControlButton::SESSION then :session
when ControlButton::USER1 then :user1
when ControlButton::USER2 then :user2
when ControlButton::MIXER then :mixer
end
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 device(devices, device_type, opts)
id = opts[:id]
if id.nil?
name = opts[:name] || 'Launchpad'
device = devices.select {|device| device.name == name}.first
id = device.device_id unless device.nil?
end
raise NoSuchDeviceError.new("MIDI device #{opts[:id] || opts[:name]} doesn't exist") if id.nil?
device_type.new(id)
rescue RuntimeError => e
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
raise NoInputAllowedError if @input.nil?
@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)
raise NoOutputAllowedError if @output.nil?
@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)
case type
when :up then ControlButton::UP
when :down then ControlButton::DOWN
when :left then ControlButton::LEFT
when :right then ControlButton::RIGHT
when :session then ControlButton::SESSION
when :user1 then ControlButton::USER1
when :user2 then ControlButton::USER2
when :mixer then ControlButton::MIXER
when :scene1 then SceneButton::SCENE1
when :scene2 then SceneButton::SCENE2
when :scene3 then SceneButton::SCENE3
when :scene4 then SceneButton::SCENE4
when :scene5 then SceneButton::SCENE5
when :scene6 then SceneButton::SCENE6
when :scene7 then SceneButton::SCENE7
when :scene8 then SceneButton::SCENE8
else
x = (opts[:x] || -1).to_i
y = (opts[:y] || -1).to_i
raise NoValidGridCoordinatesError.new("you need to specify valid coordinates (x/y, 0-7, from top left), you specified: x=#{x}, y=#{y}") if x < 0 || x > 7 || y < 0 || y > 7
y * 16 + x
end
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)
color = if opts.is_a?(Hash)
red = brightness(opts[:red] || 0)
green = brightness(opts[:green] || 0)
16 * green + red
else
opts.to_i
end
flags = case opts[:mode]
when :flashing then 8
when :buffering then 0
else 12
end
color + flags
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
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