packages feed

tidal-link-1.2.2: src/hs/Sound/Tidal/Clock.hs

module Sound.Tidal.Clock where

import Control.Concurrent (forkIO, threadDelay)
import Control.Concurrent.STM (TVar, atomically, modifyTVar', newTVar, readTVar, retry, swapTVar)
import Control.Monad (when)
import Control.Monad.Reader (ReaderT, ask, runReaderT)
import Control.Monad.State (StateT, evalStateT, get, liftIO, modify, put)
import Data.Coerce (coerce)
import Data.Int (Int64)
import Foreign.C.Types (CDouble (..))
import qualified Sound.Osc.Fd as O
import qualified Sound.Tidal.Link as Link
import System.IO (hPutStrLn, stderr)

type Time = Rational

-- | representation of a tick based clock
type Clock =
  ReaderT ClockMemory (StateT ClockState IO)

-- | internal read-only memory of the clock
data ClockMemory = ClockMemory
  { clockConfig :: ClockConfig,
    clockRef :: ClockRef,
    clockAction :: TickAction
  }

-- | internal mutable state of the clock
data ClockState = ClockState
  { ticks :: Int64,
    start :: Link.Micros,
    nowArc :: (Time, Time),
    nudged :: Double
  }
  deriving (Show)

-- | reference to interact with the clock, while it is running
data ClockRef = ClockRef
  { rAction :: TVar ClockAction,
    rAbletonLink :: Link.AbletonLink
  }

-- | configuration of the clock
data ClockConfig = ClockConfig
  { clockQuantum :: CDouble,
    clockBeatsPerCycle :: CDouble,
    clockFrameTimespan :: Double,
    clockEnableLink :: Bool,
    clockSkipTicks :: Int64,
    clockProcessAhead :: Double
  }

-- | action to be executed on a tick,
-- | given the current timespan, nudge and reference to the clock
type TickAction =
  (Time, Time) -> Double -> ClockConfig -> ClockRef -> (Link.SessionState, Link.SessionState) -> IO ()

-- | possible actions for interacting with the clock
data ClockAction
  = NoAction
  | SetCycle Time
  | SetTempo Time
  | SetNudge Double

defaultCps :: Double
defaultCps = 0.575

defaultConfig :: ClockConfig
defaultConfig =
  ClockConfig
    { clockFrameTimespan = 1 / 20,
      clockEnableLink = False,
      clockProcessAhead = 3 / 10,
      clockSkipTicks = 10,
      clockQuantum = 4,
      clockBeatsPerCycle = 4
    }

-- | creates a clock according to the config and runs it
-- | in a seperate thread
clocked :: ClockConfig -> TickAction -> IO ClockRef
clocked config ac = runClock config ac clockCheck

-- | runs the clock on the initial state and memory as given
-- | by initClock, hands the ClockRef for interaction from outside
runClock :: ClockConfig -> TickAction -> Clock () -> IO ClockRef
runClock config ac clock = do
  (mem, st) <- initClock config ac
  _ <- forkIO $ evalStateT (runReaderT clock mem) st
  pure (clockRef mem)

-- | creates a ableton link instance and an MVar for interacting
-- | with the clock from outside and computes the initial clock state
initClock :: ClockConfig -> TickAction -> IO (ClockMemory, ClockState)
initClock config ac = do
  abletonLink <- Link.create bpm
  when (clockEnableLink config) $ Link.enable abletonLink
  sessionState <- Link.createAndCaptureAppSessionState abletonLink
  now <- Link.clock abletonLink
  let startAt = now + processAhead
  Link.requestBeatAtTime sessionState 0 startAt (clockQuantum config)
  Link.commitAndDestroyAppSessionState abletonLink sessionState
  clockMV <- atomically $ newTVar NoAction
  let st =
        ClockState
          { ticks = 0,
            start = now,
            nowArc = (0, 0),
            nudged = 0
          }
  pure (ClockMemory config (ClockRef clockMV abletonLink) ac, st)
  where
    processAhead = round $ (clockProcessAhead config) * 1000000
    bpm = (coerce defaultCps) * 60 * (clockBeatsPerCycle config)

-- The reference time Link uses,
-- is the time the audio for a certain beat hits the speaker.
-- Processing of the nowArc should happen early enough for
-- all events in the nowArc to hit the speaker, but not too early.
-- Processing thus needs to happen a short while before the start
-- of nowArc. How far ahead is controlled by cProcessAhead.

-- previously called checkArc
clockCheck :: Clock ()
clockCheck = do
  (ClockMemory config (ClockRef clockMV abletonLink) _) <- ask

  action <- liftIO $ atomically $ swapTVar clockMV NoAction
  processAction action

  st <- get

  let logicalEnd = logicalTime config (start st) $ ticks st + 1
      nextArcStartCycle = arcEnd $ nowArc st

  ss <- liftIO $ Link.createAndCaptureAppSessionState abletonLink
  arcStartTime <- liftIO $ cyclesToTime config ss nextArcStartCycle
  liftIO $ Link.destroySessionState ss

  if (arcStartTime < logicalEnd)
    then clockProcess
    else tick

-- tick moves the logical time forward or recalculates the ticks in case
-- the logical time is out of sync with Link time.
-- tick delays the thread when logical time is ahead of Link time.
tick :: Clock ()
tick = do
  (ClockMemory config (ClockRef _ abletonLink) _) <- ask
  st <- get
  now <- liftIO $ Link.clock abletonLink
  let processAhead = round $ (clockProcessAhead config) * 1000000
      frameTimespan = round $ (clockFrameTimespan config) * 1000000
      preferredNewTick = ticks st + 1
      logicalNow = logicalTime config (start st) preferredNewTick
      aheadOfNow = now + processAhead
      actualTick = (aheadOfNow - start st) `div` frameTimespan
      drifted = abs (actualTick - preferredNewTick) > (clockSkipTicks config)
      newTick
        | drifted = actualTick
        | otherwise = preferredNewTick
      delta = min frameTimespan (logicalNow - aheadOfNow)

  put $ st {ticks = newTick}

  if drifted
    then liftIO $ hPutStrLn stderr $ "skip: " ++ (show (actualTick - ticks st))
    else when (delta > 0) $ liftIO $ threadDelay $ fromIntegral delta

  clockCheck

-- previously called processArc
-- hands the current link operations to the TickAction
clockProcess :: Clock ()
clockProcess = do
  (ClockMemory config ref@(ClockRef _ abletonLink) action) <- ask
  st <- get
  let logicalEnd = logicalTime config (start st) $ ticks st + 1
      startCycle = arcEnd $ nowArc st

  sessionState <- liftIO $ Link.createAndCaptureAppSessionState abletonLink
  endCycle <- liftIO $ timeToCycles config sessionState logicalEnd

  liftIO $ action (startCycle, endCycle) (nudged st) config ref (sessionState, sessionState)

  liftIO $ Link.commitAndDestroyAppSessionState abletonLink sessionState

  put (st {nowArc = (startCycle, endCycle)})
  tick

processAction :: ClockAction -> Clock ()
processAction NoAction = pure ()
processAction (SetNudge n) = modify (\st -> st {nudged = n})
processAction (SetTempo bpm) = do
  (ClockMemory _ (ClockRef _ abletonLink) _) <- ask
  sessionState <- liftIO $ Link.createAndCaptureAppSessionState abletonLink
  now <- liftIO $ Link.clock abletonLink
  liftIO $ Link.setTempo sessionState (fromRational bpm) now
  liftIO $ Link.commitAndDestroyAppSessionState abletonLink sessionState
processAction (SetCycle cyc) = do
  (ClockMemory config (ClockRef _ abletonLink) _) <- ask
  sessionState <- liftIO $ Link.createAndCaptureAppSessionState abletonLink

  now <- liftIO $ Link.clock abletonLink
  let processAhead = round $ (clockProcessAhead config) * 1000000
      startAt = now + processAhead
      beat = (fromRational cyc) * (clockBeatsPerCycle config)
  liftIO $ Link.requestBeatAtTime sessionState beat startAt (clockQuantum config)
  liftIO $ Link.commitAndDestroyAppSessionState abletonLink sessionState

  modify (\st -> st {ticks = 0, start = now, nowArc = (cyc, cyc)})

---------------------------------------------------------------
----------- functions representing link operations ------------
---------------------------------------------------------------

arcStart :: (Time, Time) -> Time
arcStart = fst

arcEnd :: (Time, Time) -> Time
arcEnd = snd

beatToCycles :: ClockConfig -> Double -> Double
beatToCycles config beat = beat / (coerce $ clockBeatsPerCycle config)

cyclesToBeat :: ClockConfig -> Double -> Double
cyclesToBeat config cyc = cyc * (coerce $ clockBeatsPerCycle config)

getSessionState :: ClockRef -> IO Link.SessionState
getSessionState (ClockRef _ abletonLink) = Link.createAndCaptureAppSessionState abletonLink

-- onSingleTick assumes it runs at beat 0.
-- The best way to achieve that is to use forceBeatAtTime.
-- But using forceBeatAtTime means we can not commit its session state.
getZeroedSessionState :: ClockConfig -> ClockRef -> IO Link.SessionState
getZeroedSessionState config (ClockRef _ abletonLink) = do
  ss <- Link.createAndCaptureAppSessionState abletonLink
  nowLink <- liftIO $ Link.clock abletonLink
  Link.forceBeatAtTime ss 0 (nowLink + processAhead) (clockQuantum config)
  pure ss
  where
    processAhead = round $ (clockProcessAhead config) * 1000000

getTempo :: Link.SessionState -> IO Time
getTempo ss = fmap toRational $ Link.getTempo ss

setTempoCPS :: Time -> Link.Micros -> ClockConfig -> Link.SessionState -> IO ()
setTempoCPS cps now conf ss = Link.setTempo ss (coerce $ cyclesToBeat conf ((fromRational cps) * 60)) now

timeAtBeat :: ClockConfig -> Link.SessionState -> Double -> IO Link.Micros
timeAtBeat config ss beat = Link.timeAtBeat ss (coerce beat) (clockQuantum config)

timeToCycles :: ClockConfig -> Link.SessionState -> Link.Micros -> IO Time
timeToCycles config ss time = do
  beat <- Link.beatAtTime ss time (clockQuantum config)
  pure $! (toRational beat) / (toRational (clockBeatsPerCycle config))

-- At what time does the cycle occur according to Link?
cyclesToTime :: ClockConfig -> Link.SessionState -> Time -> IO Link.Micros
cyclesToTime config ss cyc = do
  let beat = (fromRational cyc) * (clockBeatsPerCycle config)
  Link.timeAtBeat ss beat (clockQuantum config)

linkToOscTime :: ClockRef -> Link.Micros -> IO O.Time
linkToOscTime (ClockRef _ abletonLink) lt = do
  nowOsc <- O.time
  nowLink <- liftIO $ Link.clock abletonLink
  pure $ addMicrosToOsc (lt - nowLink) nowOsc

addMicrosToOsc :: Link.Micros -> O.Time -> O.Time
addMicrosToOsc m t = ((fromIntegral m) / 1000000) + t

-- Time is processed at a fixed rate according to configuration
-- logicalTime gives the time when a tick starts based on when
-- processing first started.
logicalTime :: ClockConfig -> Link.Micros -> Int64 -> Link.Micros
logicalTime config startTime ticks' = startTime + ticks' * frameTimespan
  where
    frameTimespan = round $ (clockFrameTimespan config) * 1000000

---------------------------------------------------------------
----------- functions for interacting with the clock ----------
---------------------------------------------------------------

getBPM :: ClockRef -> IO Time
getBPM (ClockRef _ abletonLink) = do
  ss <- Link.createAndCaptureAppSessionState abletonLink
  bpm <- Link.getTempo ss
  Link.destroySessionState ss
  pure $! toRational bpm

getCPS :: ClockConfig -> ClockRef -> IO Time
getCPS config ref = fmap (\bpm -> bpm / (toRational $ clockBeatsPerCycle config) / 60) (getBPM ref)

getCycleTime :: ClockConfig -> ClockRef -> IO Time
getCycleTime config (ClockRef _ abletonLink) = do
  now <- Link.clock abletonLink
  ss <- Link.createAndCaptureAppSessionState abletonLink
  c <- timeToCycles config ss now
  Link.destroySessionState ss
  pure $! c

resetClock :: ClockRef -> IO ()
resetClock clock = setClock clock 0

setClock :: ClockRef -> Time -> IO ()
setClock (ClockRef clock _) t = atomically $ do
  action <- readTVar clock
  case action of
    NoAction -> modifyTVar' clock (const $ SetCycle t)
    _ -> retry

setBPM :: ClockRef -> Time -> IO ()
setBPM (ClockRef clock _) t = atomically $ do
  action <- readTVar clock
  case action of
    NoAction -> modifyTVar' clock (const $ SetTempo t)
    _ -> retry

setCPS :: ClockConfig -> ClockRef -> Time -> IO ()
setCPS config ref cps = setBPM ref bpm
  where
    bpm = cps * 60 * (toRational $ clockBeatsPerCycle config)

setNudge :: ClockRef -> Double -> IO ()
setNudge (ClockRef clock _) n = atomically $ do
  action <- readTVar clock
  case action of
    NoAction -> modifyTVar' clock (const $ SetNudge n)
    _ -> retry

-- Used for Tempo callback
-- Tempo changes will be applied.
-- However, since the full arc is processed at once and since Link does not support
-- scheduling, tempo change may affect scheduling of events that happen earlier
-- in the normal stream (the one handled by onTick).
clockOnce :: TickAction -> ClockConfig -> ClockRef -> IO ()
clockOnce action config ref@(ClockRef _ abletonLink) = do
  ss <- getZeroedSessionState config ref
  temposs <- Link.createAndCaptureAppSessionState abletonLink
  -- The nowArc is a full cycle
  action (0, 1) 0 config ref (ss, temposs)
  Link.destroySessionState ss
  Link.commitAndDestroyAppSessionState abletonLink temposs

disableLink :: ClockRef -> IO ()
disableLink (ClockRef _ abletonLink) = Link.disable abletonLink

enableLink :: ClockRef -> IO ()
enableLink (ClockRef _ abletonLink) = Link.enable abletonLink