packages feed

fadno-1.1.9.1: src/Fadno/Meter.hs

{-# LANGUAGE ConstraintKinds #-}
{-# LANGUAGE RankNTypes #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE AllowAmbiguousTypes #-}

module Fadno.Meter where

import Fadno.Notation
import Fadno.Note
import Control.Lens hiding (Empty,pre)
import Data.Foldable
import Data.Ratio
import GHC.Real
import Data.Maybe
import Data.List (sort,sortBy,nub)
import Data.Function
import Control.Arrow
import qualified Data.Map.Strict as M
import Safe

type HasRatioNotes t n p = (Traversable t, HasNote n p Rational)

--
-- REBAR
--

-- | use 'rebar' with multiple input "bars".
rebars :: (HasRatioNotes b n p, HasRatioNotes c m p,Monoid p,Eq p,
           Monoid (c m), Monoid (b n),HasTimeSignature (c m),
           Snoc (c m) (c m) m m,HasTie m,Show (c m)) =>
          TimeSignature -> [b n] -> [c m]
rebars ts = rebar ts . mconcat

-- | Given a time signature and a "bar" (Traversable "b" of HasNotes "n"),
-- make new "bars" (Traversable "c" of HasNotes "m"),
-- partitioning notes, applying ties as needed,
-- and decorating with the time signature.
rebar :: (HasRatioNotes b n p, HasRatioNotes c m p,Monoid p,Eq p,Monoid (c m),
          HasTimeSignature (c m),Snoc (c m) (c m) m m,HasTie m,Show (c m)) =>
         TimeSignature -> b n -> [c m]
rebar ts = reverse . fixup . foldl' go [mempty] . fmap fromNote where
    tslen = tsToRatio ts
    go bss@[] _ = bss -- error case but might as well be total
    go bss@(b:bs) n | barlen == tslen = go (mempty:bss) n
                    | newBarLen <= tslen = (b |> n):bs
                    | otherwise = {- trace1' "otherwise" (remaining, ndur) $ -} go ((b |> pre):bs) post
        where barlen = sumDurs b
              ndur = view noteDur n
              newBarLen = barlen + ndur
              remaining = newBarLen - tslen
              post = tieMay TStop . set noteDur remaining $ n
              pre = tieMay TStart . set noteDur (ndur - remaining) $ n
    -- fixup: set head and tail timesigs
    fixup [] = []
    fixup bs = over _head fixLast . joinLast . over _last (timeSignature ?~ ts) $ bs
    fixLast b | barlen == tslen = b
              | otherwise = case tsFromRatio' ts barlen of
                              Just t -> b & timeSignature ?~ t
                              Nothing -> b
              where barlen = sumDurs b
    -- joinLast: apply heuristic that an additional bar less than 1/2 the ts length
    -- should be merged with the prior bar.
    joinLast aas@(a:b:as) | sumDurs a + sumDurs b < tslen * (3%2) =
                              (b `mappend` a):as -- leave notes tied. why not.
                          | otherwise = aas
    joinLast a = a



-- | 'rebar' using 'Bar' and 'Note\'' for output.
rebar' :: (HasRatioNotes b n p,Monoid p,Eq p,Show p) =>
          TimeSignature -> b n -> [Bar (Note' p Rational)]
rebar' = rebar

-- | 'rebars' using 'Bar' and 'Note\'' for output.
rebars' :: (HasRatioNotes b n p,Monoid (b n),Monoid p,Eq p,Show p) =>
           TimeSignature -> [b n] -> [Bar (Note' p Rational)]
rebars' = rebars

-- | Set tie if not a rest
tieMay :: (Eq a, Monoid a, HasNote s a d, HasTie s) => Tie -> s -> s
tieMay end v | isRest v = v
             | otherwise = over tie setTie v
             where setTie o@(Just old) | old == end = o
                                       | otherwise = Just TBoth
                   setTie Nothing = Just end



--
-- TIE RULES
--

-- | Representable duration denominators.
--
-- For standard base-2 durs, 2 and 4 are spurious as they reduce to 1,
-- thus 1 plus the "dot values" 3,7.
--
-- For non-standard (quintuples etc), we admit 2 and 4 as well, for e.g. 2%5,
-- a "half-note" under a quarter-quintuple. Anything greater
-- exceeding the understanding limit: 8%17 can certainly be represented as
-- a half-note, but it makes little sense to the reader.
validDenoms :: [Integer]
validDenoms = [1,2,3,4,7]

-- | Max representable duration.
maxDur :: Rational
maxDur = 2

-- | Test for representational duration per 'validDenoms' and 'maxDur'.
validDur :: Rational -> Bool
validDur r = r == maxDur ||
             (r < maxDur && numerator r `elem` validDenoms)

-- | Tie rules that work across any denominators, such that
-- 5%8 -> [1%2,1%8], 9%16 -> [1%2,1%16],  11%16 -> [1%2,3%16],
-- 13%16 -> [3%2,1%16], 9%4 -> [2,1%4].
splitDur :: Rational -> [Rational]
splitDur r | r < 0 = error "splitDur: negative duration"
           | r == 0 = []
           | validDur r = [r]
           -- NB: subtraction doesn't preserve denom (17:%20 - 3%5 -> 1%4, not 5%20)
           | otherwise = split:splitDur (r - split)
           where split = findSplit r

-- | Find split by 1) finding largest power-of-2 fraction under value or
-- 2) finding longest power-of-two denominator split, up to 8.
findSplit :: Rational -> Rational
findSplit r = case filter validDur candidates of
                [] -> splitOnValid
                (v:_) -> v
    where
      n = numerator r
      d = denominator r
      pow2s = [x | p <- [(1 :: Integer)..], x<-[2^p]]
      denomPow2s = reverse $ takeWhile (\v -> v < d && d `rem` v == 0) pow2s
      candidates = filter (<r) $ map (\cd -> (n * cd `div` d) :% (d `div` cd)) denomPow2s
      splitOnValid = case filter (<= min r maxDur) $ map (:%d) $ reverse $
                     takeWhile (<min n 8) pow2s of
                       [] -> r
                       (v:_) -> v

-- | Apply rules in 'splitDur' and tie affected notes.
tieRules :: (HasRatioNotes b n p, HasTie n, Monoid p, Eq p, Show n,
             HasRatioNotes c m p, HasTie m, Monoid (c m),
             Snoc (c m) (c m) m m) => b n -> c m
tieRules = foldl' apply mempty where
    apply r n = case splitDur (view noteDur n) of
                       [] -> error $ "tieRules: empty result from splitDur for " ++ show n
                       [_] -> r |> set tie (view tie n) (fromNote n)
                       ds -> foldl (|>) r . fixLast . fixFirst . map mkTied $ ds
                           where mkTied d = tieMay TBoth (set noteDur d (fromNote n))
                                 forOrgTie t = case view tie n of
                                                 Nothing -> t
                                                 (Just a) | a == t -> t
                                                          | otherwise -> TBoth
                                 fixFirst = over (_head.tie) (fmap (const (forOrgTie TStart)))
                                 fixLast = over (_last.tie) (fmap (const (forOrgTie TStop)))



-- | Monomorphic-result 'tieRules
tieRules' :: (HasRatioNotes b n p, HasTie n, Monoid p, Eq p, Show n) =>
             b n -> Bar (Note' p Rational)
tieRules' = tieRules


--
-- SELECT TIMESIG
--

-- | Weights and pulse values for pre-configured TSs.
data TsConfig = TsConfig {
      _tSig :: TimeSignature
    , _tWeight :: Rational
    , _tPulse :: Rational
    } deriving (Eq,Show)
makeLenses ''TsConfig

selectTimeSig :: HasRatioNotes t n p => [t n] -> Maybe TimeSignature
selectTimeSig phrases = fmap fst $ headMay $ selectTimeSigs phrases

-- | Combine scores from phrases.
selectTimeSigs :: HasRatioNotes t n p => [t n] -> [(TimeSignature,Rational)]
selectTimeSigs = mergeScores . preferDivisableHeads . map selectTsConfigs where
    mergeScores = sortBy (flip compare `on` snd) .
                  M.toList . foldl1 (M.unionWith (+)) .
                  map (M.fromListWith max . map (_tSig &&& _tWeight))


-- | nutty heuristic that overweights a TS for a uniform duration divisor
preferDivisableHeads :: [[TsConfig]] -> [[TsConfig]]
preferDivisableHeads [] = []
preferDivisableHeads [a] = [a]
preferDivisableHeads phraseTss =
    case sequence (map headMay phraseTss) of
      Nothing -> phraseTss
      Just heads | length (nub heads) == 1 -> phraseTss
                 | otherwise -> case nub $ (zipWith commonDivHeur <*> tail) (map tsConfigToDur heads) of
                      [] -> phraseTss
                      [a] -> maybe phraseTss ((:phraseTss).return) $ tsConfigFromDur 100 a
                      _ -> phraseTss

-- | main heuristic is finding the common divisible duration,
-- with requirement that it must be greater than 1/4 the difference between the durations.
-- Hopefully avoids crappy tiny TSs like 2/8.
commonDivHeur :: Rational -> Rational -> Rational
commonDivHeur d1 d2 | d1 == d2 = d1
                | c / (abs (d1 - d2)) > (1%4) = min d1 d2
                | otherwise = c
                where c = d1 / fromIntegral (numerator (d1/d2))


tsConfigToDur :: TsConfig -> Rational
tsConfigToDur = tsToRatio . _tSig



-- | Attempt to construct a TS config from duration
tsConfigFromDur :: Rational -> Rational -> Maybe TsConfig
tsConfigFromDur weight = fmap (\t -> TsConfig t weight (minMedianDur (_tsUnit t))) . tsFromRatio

-- | Pre-configured timesigs.
tsConfigs :: [TsConfig]
tsConfigs = [TsConfig (4/:Q4) 9 (1%4)
            ,TsConfig (3/:Q4) 8 (1%4)
            ,TsConfig (6/:Q8) 8 (3%8)
            ,TsConfig (12/:Q8) 7 (3%8)
            ,TsConfig (2/:Q4) 6 (1%4)
             ,TsConfig (5/:Q4) 5 (5%4)
             ,TsConfig (5/:Q8) 5 (5%8)
             ,TsConfig (7/:Q4) 5 (7%4)
            ,TsConfig (7/:Q8) 5 (7%8)
             ,TsConfig (9/:Q8) 5 (3%8)
            ]


-- | Given a median note duration, minima for acceptable quanta.
minMedianDur :: Quanta -> Rational
minMedianDur q = fromMaybe (1%32) . lookup q $
                                   [(Q8,1%32),(Q4,1%16),(Q2,1%4)]

-- | Given a phrase, select configs
selectTsConfigs :: HasRatioNotes t n p => t n -> [TsConfig]
selectTsConfigs phrase | null phrase = []
                       | otherwise = sortBy (flip compare `on` _tWeight) $
                                     mapMaybe (evalTsConfig phrase)
                                     -- append custom-length TS as lowest-weight choice
                                     (maybe tsConfigs (:tsConfigs) (tsConfigFromDur 4 (sumDurs phrase)))

-- | Filter and score time signatures per heuristics.
evalTsConfig :: HasRatioNotes t n p => t n -> TsConfig -> Maybe TsConfig
evalTsConfig phrase c@(TsConfig ts@(TimeSignature _n q) _ pulse)
    | medianDur < minMedianDur q = Nothing -- density filter
    | phraseDur < tsDur = Nothing -- min duration filter
    | tsDur >= 2 = Nothing -- too long TS
    | phraseDur == tsDur = Just $ over tWeight (* (9%4)) c -- exact length match bonus,
    | otherwise = Just $ over tWeight computeWeight c
        where medianDur = sort (toListOf (traverse.noteDur) phrase) !! (phraseLength `div` 2)
              phraseLength = length phrase
              phraseDur = sumDurs phrase
              tsDur = tsToRatio ts
              -- scale by pulse coverage, subtract by divisibility by ts duration
              computeWeight w = (w * pulseCoverage pulse phrase) - (phraseDur `frem` tsDur)

-- | 'rem' for 'RealFrac'
frem :: RealFrac a => a -> a -> a
frem a b = let (_ :: Int,f) = properFraction (a/b) in f

isDivBy :: RealFrac a => a -> a -> Bool
isDivBy a b = 0 == frem a b

-- | Compute percentage of notes falling on pulse values.
pulseCoverage :: HasRatioNotes t n p => Rational -> t n -> Rational
pulseCoverage pulse phrase = fromIntegral pulseNoteCount % fromIntegral (length phrase)
    where
      pulseNoteCount = length . filter ((`isDivBy` pulse) . fst) . mapTime $ phrase