midi_player/lib/midi_tools/player.ex

226 lines
6.2 KiB
Elixir

defmodule MIDITools.Player do
use GenServer
@moduledoc """
A GenServer for playing a schedule of MIDI commands at certain times.
"""
# Client API
@doc """
Start the MIDI player.
"""
@spec start_link() :: GenServer.on_start()
def start_link do
GenServer.start_link(__MODULE__, nil, name: __MODULE__)
end
@doc """
Set the current schedule and total duration for the MIDI player.
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.
The duration makes sure the player plays a (potential) pause after the last
midi command.
"""
@spec set_schedule([MIDITools.Event.t()], non_neg_integer()) :: :ok
def set_schedule(events, duration) do
schedule = convert_events(events)
GenServer.call(__MODULE__, {:set_schedule, schedule, duration})
end
@doc """
Play the current MIDI schedule from the start.
"""
@spec play() :: :ok
def play do
GenServer.call(__MODULE__, :play)
end
@doc """
Set the player on repeat or not.
"""
@spec set_repeat(boolean()) :: :ok
def set_repeat(repeat) when is_boolean(repeat) do
GenServer.call(__MODULE__, {:set_repeat, repeat})
end
@doc """
Stop the player and cancel the pause.
"""
@spec stop_playing() :: :ok
def stop_playing do
GenServer.call(__MODULE__, :stop_playing)
end
@doc """
Pause the player. See `MIDITools.Player.resume/0` for resuming playback.
"""
@spec pause() :: :ok | {:error, :already_paused | :not_started}
def pause do
GenServer.call(__MODULE__, :pause)
end
@doc """
Resume playback on the player after it has been paused.
"""
@spec resume() :: :ok, {:error, :not_paused}
def resume do
GenServer.call(__MODULE__, :resume)
end
# Server callbacks
@impl GenServer
def init(_arg) do
{:ok, synth} = MIDISynth.start_link([])
{:ok,
%{
timer: nil,
schedule: [],
schedule_left: [],
start_time: nil,
duration: 0,
synth: synth,
repeat: false,
pause_time: nil
}}
end
@impl GenServer
def handle_call({:set_schedule, schedule, duration}, _from, state) do
{:reply, :ok, %{state | schedule: schedule, schedule_left: schedule, duration: duration}}
end
def handle_call(:play, _from, %{timer: timer, schedule: schedule} = state) do
if timer != nil do
Process.cancel_timer(timer, info: false)
end
start_time = Timex.now()
timer = start_timer(schedule, start_time)
{:reply, :ok,
%{state | timer: timer, start_time: start_time, schedule_left: schedule, pause_time: nil}}
end
def handle_call({:set_repeat, repeat}, _from, state) do
{:reply, :ok, %{state | repeat: repeat}}
end
def handle_call(:stop_playing, _from, %{timer: timer} = state) do
if timer != nil do
Process.cancel_timer(timer, info: false)
end
{:reply, :ok, %{state | timer: nil, pause_time: nil}}
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
Process.cancel_timer(timer, info: false)
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}}
end
@impl GenServer
def handle_info(
:play,
%{
schedule_left: schedule_left,
start_time: start_time,
synth: synth,
duration: duration
} = state
) do
{timer, schedule_left} = play_next_command(schedule_left, start_time, duration, synth)
{:noreply, %{state | timer: timer, schedule_left: schedule_left}}
end
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
{:noreply, %{state | timer: nil}}
end
# Private functions
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
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
defp play_next_command(
[{offset, command} | next_schedule] = schedule_left,
start_time,
duration,
synth
) do
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)
play_next_command(next_schedule, start_time, duration, synth)
else
# Command is too far in the future, schedule next timer.
delay = max(ceil(micro_diff / 1000), 0)
timer = Process.send_after(self(), :play, delay)
{timer, schedule_left}
end
end
defp convert_events(events) do
events
|> Enum.flat_map(&MIDITools.Event.convert/1)
|> Enum.reduce(%{}, fn {time, midi}, acc ->
Map.update(acc, time, midi, &<<&1::binary, midi::binary>>)
end)
|> Enum.sort()
end
end