{-# LANGUAGE DeriveGeneric #-}
module Data.Tempo where

import Data.Time
import GHC.Generics

-- | Musical tempo is represented as a data structure with three orthogonal components.
-- (Generic instances are derived in order to allow later generation of instances for
-- Aeson classes.)

data Tempo = Tempo {
  freq :: Rational, -- frequency of cycles/beats, ie. cycles/beats per second
  time :: UTCTime, -- a time at which a number of "elapsed" cycles/beats will be indicated
  count :: Rational -- the number of "elapsed" cycles/beats at the time indicated
  } deriving (Eq,Generic,Show)


-- | The 'origin' of a Tempo is the time at which the number of elapsed cycles/beats
-- would have been 0.

origin :: Tempo -> UTCTime
origin x = addUTCTime (realToFrac $ count x * (-1) / freq x) (time x)


-- | Given a Tempo and a clock time (UTCTime), timeToCount tells us how many cycles/beats
-- have elapsed at that time.

timeToCount :: Tempo -> UTCTime -> Rational
timeToCount x t = (realToFrac $ diffUTCTime t $ time x) * freq x + count x

-- | Given a Tempo and a count of elapsed cycles/beats, countToTime tells us when that "beat"
-- will (or would have) take(n) place.

countToTime :: Tempo -> Rational -> UTCTime
countToTime x c = addUTCTime  (realToFrac $ c / freq x) (origin x)


-- | Provided a new frequency and a pivot time, changeTempo modifies a given Tempo as if
-- the frequency changed at the pivot time, with the count continuing to increase 'monotonically'

changeTempo :: Rational -> UTCTime -> Tempo -> Tempo
changeTempo f t x = Tempo {
  freq = f,
  time = t,
  count = timeToCount x t
  }

-- | For convenience, changeTempoNow is an IO action that changes the frequency of the tempo
-- 'now', ie. at the time returned by a call to getCurrentTime embedded in the action.

changeTempoNow :: Rational -> Tempo -> IO Tempo
changeTempoNow f x = do
  t <- getCurrentTime
  return $ changeTempo f t x

-- | Given a tempo, a window defined by two UTCTime-s, a metre (cycles of cycles), and an offset
-- within that metre, findBeats returns all occurrences of the defined metric position within the window.
-- The window is inclusive at the lower limit, and exclusive at the upper limit (so answers can
-- occur exactly at the lower limit but not at the upper limit).

findBeats :: Tempo -> UTCTime -> UTCTime -> Rational -> Rational -> [Rational]
findBeats tempo lowerLimitUtc upperLimitUtc metre offset =
  let lowerLimitCycles = timeToCount tempo lowerLimitUtc
      upperLimitCycles = timeToCount tempo upperLimitUtc
  in findBeats' metre offset lowerLimitCycles upperLimitCycles

-- | Given a metre and offset (eg. 2 and 0.5 to represent half-way through the first cycle
-- of a metre lasting 2 cycles), and lower and upper limits in elapsed cycles, findBeats'
-- returns all positions that match the given offset and metre and are greater than or equal
-- to the lower limit, and lower than the upper limit.

findBeats' :: Rational -> Rational -> Rational -> Rational -> [Rational]
findBeats' metre offset lowerLimit upperLimit
  | nextBeat metre offset lowerLimit >= upperLimit = []
  | otherwise = nextBeat metre offset lowerLimit : findBeats' metre offset (lowerLimit+metre) upperLimit

-- | Given a metre, offset, and a lower limit in elapsed cycles, nextBeat returns the next
-- position in cycles that matches the given offset and metre, and is greater than
-- or equal to the lower limit.

nextBeat :: Rational -> Rational -> Rational -> Rational
nextBeat metre offset lowerLimit
  | metre == 0 = error "you can't have a metre of 0!!!"
  | otherwise =
  let fract x = x - realToFrac (floor x) -- for convenience
      lowerLimitInMetre = lowerLimit/metre -- lower limit expressed in multiples of the metre (cycles of cycles)
      offsetInMetre = fract $ offset/metre -- offset expressed in multiples of the metre
      -- the answer occurs either in this instance of the metre, or the next...
      nextBeatInMetre | offsetInMetre >= (fract lowerLimitInMetre) = (realToFrac $ floor lowerLimitInMetre) + offsetInMetre
                      | otherwise = (realToFrac $ ceiling lowerLimitInMetre) + offsetInMetre
  in nextBeatInMetre*metre -- translate answer in terms of meter back to cycles