midi_player/lib/midi_tools/player.ex

211 lines
5.8 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 duration makes sure the player plays a (potential) pause after the last
midi command.
"""
@spec set_schedule(MIDITools.Schedule.t(), non_neg_integer()) :: :ok
def set_schedule(schedule, duration) do
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
end