2020-10-12 14:17:36 +00:00
|
|
|
defmodule MIDIPlayer do
|
2020-10-10 17:36:07 +00:00
|
|
|
use GenServer
|
|
|
|
|
2020-10-11 11:40:12 +00:00
|
|
|
@moduledoc """
|
2020-10-11 14:26:37 +00:00
|
|
|
A GenServer for playing a schedule of MIDI commands at predefined times.
|
2020-10-11 11:40:12 +00:00
|
|
|
"""
|
|
|
|
|
2020-10-12 19:00:31 +00:00
|
|
|
# TBD: I think cancelling the timer could introduce a race condition.
|
|
|
|
# Could maybe match timer's reference to saved one?
|
|
|
|
|
2020-10-12 18:20:59 +00:00
|
|
|
@type schedule :: [{non_neg_integer(), binary()}]
|
|
|
|
|
2020-10-10 17:36:07 +00:00
|
|
|
# Client API
|
|
|
|
|
2020-10-14 13:05:04 +00:00
|
|
|
@midi_synth_args [:soundfont]
|
|
|
|
|
2020-10-11 11:40:12 +00:00
|
|
|
@doc """
|
|
|
|
Start the MIDI player.
|
2020-10-14 11:01:21 +00:00
|
|
|
|
2020-10-14 13:05:04 +00:00
|
|
|
The soundfont path can be set with the :soundfont argument.
|
2020-10-11 11:40:12 +00:00
|
|
|
"""
|
2020-10-14 13:05:04 +00:00
|
|
|
@spec start_link(GenServer.options()) :: GenServer.on_start()
|
|
|
|
def start_link(opts \\ []) do
|
|
|
|
init_args = Keyword.take(opts, @midi_synth_args)
|
|
|
|
server_args = Keyword.drop(opts, @midi_synth_args)
|
|
|
|
GenServer.start_link(__MODULE__, init_args, server_args)
|
2020-10-10 17:36:07 +00:00
|
|
|
end
|
|
|
|
|
2020-10-11 11:40:12 +00:00
|
|
|
@doc """
|
2020-10-11 14:26:37 +00:00
|
|
|
Generate the current schedule defined by the given events.
|
2020-10-11 14:04:56 +00:00
|
|
|
|
|
|
|
The list of events is internally converted to MIDI commands.
|
|
|
|
If multiple events are scheduled on the same time,
|
|
|
|
then they are executed in the same order as in the list.
|
2020-10-11 11:40:12 +00:00
|
|
|
The duration makes sure the player plays a (potential) pause after the last
|
|
|
|
midi command.
|
2020-10-11 14:26:37 +00:00
|
|
|
|
2020-10-12 14:17:36 +00:00
|
|
|
See `MIDIPlayer.Event` to create events.
|
2020-10-11 11:40:12 +00:00
|
|
|
"""
|
2020-10-14 11:01:21 +00:00
|
|
|
@spec generate_schedule(GenServer.server(), [MIDIPlayer.Event.t()], non_neg_integer()) :: :ok
|
|
|
|
def generate_schedule(player, events, duration) when duration > 0 do
|
|
|
|
GenServer.call(player, {:generate_schedule, events, duration})
|
2020-10-10 17:36:07 +00:00
|
|
|
end
|
|
|
|
|
2020-10-11 11:40:12 +00:00
|
|
|
@doc """
|
|
|
|
Play the current MIDI schedule from the start.
|
|
|
|
"""
|
2020-10-14 11:01:21 +00:00
|
|
|
@spec play(GenServer.server()) :: :ok
|
|
|
|
def play(player) do
|
|
|
|
GenServer.call(player, :play)
|
2020-10-10 17:36:07 +00:00
|
|
|
end
|
|
|
|
|
2020-10-11 11:40:12 +00:00
|
|
|
@doc """
|
|
|
|
Set the player on repeat or not.
|
|
|
|
"""
|
2020-10-14 11:01:21 +00:00
|
|
|
@spec set_repeat(GenServer.server(), boolean()) :: :ok
|
|
|
|
def set_repeat(player, repeat) when is_boolean(repeat) do
|
|
|
|
GenServer.call(player, {:set_repeat, repeat})
|
2020-10-10 18:30:28 +00:00
|
|
|
end
|
|
|
|
|
2020-10-11 11:40:12 +00:00
|
|
|
@doc """
|
|
|
|
Stop the player and cancel the pause.
|
|
|
|
"""
|
2020-10-14 11:01:21 +00:00
|
|
|
@spec stop_playing(GenServer.server()) :: :ok
|
|
|
|
def stop_playing(player) do
|
|
|
|
GenServer.call(player, :stop_playing)
|
2020-10-10 18:57:11 +00:00
|
|
|
end
|
|
|
|
|
2020-10-11 11:40:12 +00:00
|
|
|
@doc """
|
2020-10-14 11:01:21 +00:00
|
|
|
Pause the player.
|
|
|
|
|
|
|
|
See `MIDIPlayer.resume/1` for resuming playback.
|
2020-10-11 11:40:12 +00:00
|
|
|
"""
|
2020-10-14 11:01:21 +00:00
|
|
|
@spec pause(GenServer.server()) :: :ok | {:error, :already_paused | :not_started}
|
|
|
|
def pause(player) do
|
|
|
|
GenServer.call(player, :pause)
|
2020-10-10 21:03:56 +00:00
|
|
|
end
|
|
|
|
|
2020-10-11 11:40:12 +00:00
|
|
|
@doc """
|
|
|
|
Resume playback on the player after it has been paused.
|
|
|
|
"""
|
2020-10-14 11:01:21 +00:00
|
|
|
@spec resume(GenServer.server()) :: :ok | {:error, :not_paused}
|
|
|
|
def resume(player) do
|
|
|
|
GenServer.call(player, :resume)
|
2020-10-10 21:03:56 +00:00
|
|
|
end
|
|
|
|
|
2020-10-12 18:20:59 +00:00
|
|
|
@doc """
|
|
|
|
Get the current schedule of the player.
|
|
|
|
|
|
|
|
The schedule is a list of tuples of a time in milliseconds and the
|
|
|
|
corresponding bitstream of MIDI commands to be played at that time.
|
|
|
|
The list is guaranteed to be ascending in time.
|
|
|
|
"""
|
2020-10-14 11:01:21 +00:00
|
|
|
@spec get_schedule(GenServer.server()) :: schedule()
|
|
|
|
def get_schedule(player) do
|
|
|
|
GenServer.call(player, :get_schedule)
|
2020-10-12 18:20:59 +00:00
|
|
|
end
|
|
|
|
|
2020-10-10 17:36:07 +00:00
|
|
|
# Server callbacks
|
|
|
|
|
2020-10-11 11:40:12 +00:00
|
|
|
@impl GenServer
|
2020-10-14 11:01:21 +00:00
|
|
|
def init(args) do
|
|
|
|
{:ok, synth} = MIDISynth.start_link(args)
|
2020-10-10 18:30:28 +00:00
|
|
|
|
|
|
|
{:ok,
|
|
|
|
%{
|
|
|
|
timer: nil,
|
|
|
|
schedule: [],
|
|
|
|
schedule_left: [],
|
2020-10-10 21:03:56 +00:00
|
|
|
start_time: nil,
|
2020-10-10 19:36:01 +00:00
|
|
|
duration: 0,
|
2020-10-10 18:30:28 +00:00
|
|
|
synth: synth,
|
2020-10-10 21:03:56 +00:00
|
|
|
repeat: false,
|
|
|
|
pause_time: nil
|
2020-10-10 18:30:28 +00:00
|
|
|
}}
|
2020-10-10 17:36:07 +00:00
|
|
|
end
|
|
|
|
|
2020-10-11 11:40:12 +00:00
|
|
|
@impl GenServer
|
2020-10-11 14:26:37 +00:00
|
|
|
def handle_call({:generate_schedule, events, duration}, _from, state) do
|
|
|
|
state = %{state | schedule: convert_events(events)}
|
|
|
|
{:reply, :ok, %{reset(state) | duration: duration}}
|
2020-10-10 17:36:07 +00:00
|
|
|
end
|
|
|
|
|
2020-10-11 14:26:37 +00:00
|
|
|
def handle_call(:play, _from, %{schedule: schedule} = state) do
|
2020-10-10 17:36:07 +00:00
|
|
|
start_time = Timex.now()
|
2020-10-10 18:30:28 +00:00
|
|
|
timer = start_timer(schedule, start_time)
|
2020-10-10 19:36:01 +00:00
|
|
|
|
2020-10-11 16:15:16 +00:00
|
|
|
{:reply, :ok, %{reset(state) | timer: timer, start_time: start_time}}
|
2020-10-10 18:30:28 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
def handle_call({:set_repeat, repeat}, _from, state) do
|
|
|
|
{:reply, :ok, %{state | repeat: repeat}}
|
2020-10-10 17:36:07 +00:00
|
|
|
end
|
|
|
|
|
2020-10-11 16:15:16 +00:00
|
|
|
def handle_call(:stop_playing, _from, state) do
|
2020-10-11 14:26:37 +00:00
|
|
|
{:reply, :ok, reset(state)}
|
2020-10-10 21:03:56 +00:00
|
|
|
end
|
|
|
|
|
|
|
|
def handle_call(:pause, _from, %{pause_time: pause_time} = state) when pause_time != nil do
|
|
|
|
{:reply, {:error, :already_paused}, state}
|
|
|
|
end
|
|
|
|
|
|
|
|
def handle_call(:pause, _from, %{timer: nil} = state) do
|
|
|
|
{:reply, {:error, :not_started}, state}
|
|
|
|
end
|
|
|
|
|
|
|
|
def handle_call(:pause, _from, %{timer: timer} = state) do
|
2020-10-10 18:57:11 +00:00
|
|
|
Process.cancel_timer(timer, info: false)
|
2020-10-10 21:03:56 +00:00
|
|
|
pause_time = Timex.now()
|
|
|
|
|
|
|
|
{:reply, :ok, %{state | timer: nil, pause_time: pause_time}}
|
|
|
|
end
|
|
|
|
|
|
|
|
def handle_call(:resume, _from, %{pause_time: nil} = state) do
|
|
|
|
{:reply, {:error, :not_paused}, state}
|
|
|
|
end
|
|
|
|
|
|
|
|
def handle_call(
|
|
|
|
:resume,
|
|
|
|
_from,
|
|
|
|
%{start_time: start_time, pause_time: pause_time, schedule_left: schedule_left} = state
|
|
|
|
) do
|
|
|
|
time_since_pause = Timex.diff(Timex.now(), pause_time, :millisecond)
|
|
|
|
start_time = DateTime.add(start_time, time_since_pause, :millisecond)
|
|
|
|
timer = start_timer(schedule_left, start_time)
|
|
|
|
|
|
|
|
{:reply, :ok, %{state | timer: timer, start_time: start_time, pause_time: nil}}
|
2020-10-10 18:57:11 +00:00
|
|
|
end
|
|
|
|
|
2020-10-12 18:20:59 +00:00
|
|
|
def handle_call(:get_schedule, _from, %{schedule: schedule} = state) do
|
|
|
|
{:reply, schedule, state}
|
|
|
|
end
|
|
|
|
|
2020-10-11 11:40:12 +00:00
|
|
|
@impl GenServer
|
2020-10-10 17:36:07 +00:00
|
|
|
def handle_info(
|
|
|
|
:play,
|
2020-10-10 18:30:28 +00:00
|
|
|
%{
|
|
|
|
schedule_left: schedule_left,
|
|
|
|
start_time: start_time,
|
|
|
|
synth: synth,
|
2020-10-10 19:36:01 +00:00
|
|
|
duration: duration
|
2020-10-10 18:30:28 +00:00
|
|
|
} = state
|
2020-10-10 17:36:07 +00:00
|
|
|
) do
|
2020-10-10 19:36:01 +00:00
|
|
|
{timer, schedule_left} = play_next_command(schedule_left, start_time, duration, synth)
|
|
|
|
{:noreply, %{state | timer: timer, schedule_left: schedule_left}}
|
|
|
|
end
|
2020-10-10 18:30:28 +00:00
|
|
|
|
2020-10-10 19:36:01 +00:00
|
|
|
def handle_info(
|
|
|
|
:end,
|
|
|
|
%{repeat: true, start_time: start_time, duration: duration, schedule: schedule} = state
|
|
|
|
) do
|
|
|
|
start_time = DateTime.add(start_time, duration, :millisecond)
|
|
|
|
timer = start_timer(schedule, start_time)
|
|
|
|
{:noreply, %{state | start_time: start_time, timer: timer, schedule_left: schedule}}
|
|
|
|
end
|
|
|
|
|
|
|
|
def handle_info(:end, state) do
|
2020-10-10 19:50:48 +00:00
|
|
|
{:noreply, %{state | timer: nil}}
|
2020-10-10 17:36:07 +00:00
|
|
|
end
|
|
|
|
|
2020-10-10 18:30:28 +00:00
|
|
|
# Private functions
|
2020-10-10 17:36:07 +00:00
|
|
|
|
2020-10-10 18:30:28 +00:00
|
|
|
defp start_timer([], _start_time), do: nil
|
|
|
|
|
|
|
|
defp start_timer([{offset, _command} | _], start_time) do
|
|
|
|
next_time = DateTime.add(start_time, offset, :millisecond)
|
|
|
|
delay = max(Timex.diff(next_time, Timex.now(), :millisecond), 0)
|
|
|
|
Process.send_after(self(), :play, delay)
|
|
|
|
end
|
|
|
|
|
2020-10-10 19:36:01 +00:00
|
|
|
defp play_next_command([], start_time, duration, _synth) do
|
|
|
|
end_time = DateTime.add(start_time, duration, :millisecond)
|
|
|
|
delay = max(Timex.diff(end_time, Timex.now(), :millisecond), 0)
|
|
|
|
timer = Process.send_after(self(), :end, delay)
|
|
|
|
{timer, []}
|
|
|
|
end
|
2020-10-10 18:30:28 +00:00
|
|
|
|
2020-10-10 19:36:01 +00:00
|
|
|
defp play_next_command(
|
|
|
|
[{offset, command} | next_schedule] = schedule_left,
|
|
|
|
start_time,
|
|
|
|
duration,
|
|
|
|
synth
|
|
|
|
) do
|
2020-10-10 17:36:07 +00:00
|
|
|
next_time = DateTime.add(start_time, offset, :millisecond)
|
|
|
|
micro_diff = Timex.diff(next_time, Timex.now(), :microsecond)
|
|
|
|
|
|
|
|
if micro_diff < 500 do
|
|
|
|
# Play command, and try to play next command too.
|
|
|
|
MIDISynth.midi(synth, command)
|
2020-10-10 19:36:01 +00:00
|
|
|
play_next_command(next_schedule, start_time, duration, synth)
|
2020-10-10 17:36:07 +00:00
|
|
|
else
|
|
|
|
# Command is too far in the future, schedule next timer.
|
2020-10-10 18:30:28 +00:00
|
|
|
delay = max(ceil(micro_diff / 1000), 0)
|
|
|
|
timer = Process.send_after(self(), :play, delay)
|
|
|
|
{timer, schedule_left}
|
2020-10-10 17:36:07 +00:00
|
|
|
end
|
|
|
|
end
|
2020-10-11 14:04:56 +00:00
|
|
|
|
|
|
|
defp convert_events(events) do
|
|
|
|
events
|
2020-10-12 14:17:36 +00:00
|
|
|
|> Enum.flat_map(&MIDIPlayer.Event.convert/1)
|
2020-10-11 14:04:56 +00:00
|
|
|
|> Enum.reduce(%{}, fn {time, midi}, acc ->
|
|
|
|
Map.update(acc, time, midi, &<<&1::binary, midi::binary>>)
|
|
|
|
end)
|
|
|
|
|> Enum.sort()
|
|
|
|
end
|
2020-10-11 14:26:37 +00:00
|
|
|
|
|
|
|
defp reset(%{timer: timer, schedule: schedule} = state) do
|
|
|
|
if timer != nil do
|
|
|
|
Process.cancel_timer(timer, info: false)
|
|
|
|
end
|
|
|
|
|
|
|
|
%{state | timer: nil, pause_time: nil, schedule_left: schedule}
|
|
|
|
end
|
2020-10-10 17:36:07 +00:00
|
|
|
end
|