hmt-0.16: Music/Theory/Tuning/Scala.hs
-- | Parser for the Scala scale file format. See
-- <http://www.huygens-fokker.org/scala/scl_format.html> for details.
-- This module succesfully parses all 4671 scales in v.85 of the scale
-- library.
module Music.Theory.Tuning.Scala where
import Control.Monad {- base -}
import Data.Either {- base -}
import Data.List {- base -}
import Data.Maybe {- base -}
import Data.Ratio {- base -}
import System.Directory {- directory -}
import System.Environment {- base -}
import System.FilePath {- filepath -}
import qualified Music.Theory.Directory as T {- hmt -}
import qualified Music.Theory.Either as T {- hmt -}
import qualified Music.Theory.Function as T {- hmt -}
import qualified Music.Theory.IO as T {- hmt -}
import qualified Music.Theory.List as T {- hmt -}
import qualified Music.Theory.Math as T {- hmt -}
import qualified Music.Theory.Read as T {- hmt -}
import qualified Music.Theory.String as T {- hmt -}
import qualified Music.Theory.Tuning as T {- hmt -}
-- * Pitch
-- | A @.scl@ pitch is either in 'Cents' or is a 'Ratio'.
type Pitch i = Either T.Cents (Ratio i)
-- | An enumeration type for @.scl@ pitch classification.
data Pitch_Type = Pitch_Cents | Pitch_Ratio deriving (Eq,Show)
-- | A nearness value for deriving approximate rationals.
type Epsilon = Double
-- | Derive 'Pitch_Type' from 'Pitch'.
pitch_type :: Pitch i -> Pitch_Type
pitch_type = either (const Pitch_Cents) (const Pitch_Ratio)
-- | Pitch as 'T.Cents', conversion by 'T.ratio_to_cents' if necessary.
pitch_cents :: Integral i => Pitch i -> T.Cents
pitch_cents p =
case p of
Left c -> c
Right r -> T.ratio_to_cents r
-- | Pitch as 'Rational', conversion by 'T.reconstructed_ratio' if
-- necessary, hence /epsilon/.
pitch_ratio :: Epsilon -> Pitch Integer -> Rational
pitch_ratio epsilon p =
case p of
Left c -> T.reconstructed_ratio epsilon c
Right r -> r
-- | A pair giving the number of 'Cents' and number of 'Ratio' pitches.
pitch_representations :: Integral t => [Pitch i] -> (t,t)
pitch_representations =
let f (l,r) p = case p of
Left _ -> (l + 1,r)
Right _ -> (l,r + 1)
in foldl f (0,0)
-- | If scale is uniform, give type.
uniform_pitch_type :: [Pitch i] -> Maybe Pitch_Type
uniform_pitch_type p =
case pitch_representations p :: (Int,Int) of
(0,_) -> Just Pitch_Ratio
(_,0) -> Just Pitch_Cents
_ -> Nothing
-- | The predominant type of the pitches for 'Scale'.
pitch_type_predominant :: [Pitch i] -> Pitch_Type
pitch_type_predominant p =
let (c,r) = pitch_representations p :: (Int,Int)
in if c >= r then Pitch_Cents else Pitch_Ratio
-- * Scale
-- | A scale has a name, a description, a degree, and a list of 'Pitch'es.
type Scale i = (String,String,Int,[Pitch i])
-- | The name of a scale.
scale_name :: Scale i -> String
scale_name (nm,_,_,_) = nm
-- | Text description of a scale.
scale_description :: Scale i -> String
scale_description (_,d,_,_) = d
-- | The degree of the scale (number of 'Pitch'es).
scale_degree :: Scale i -> Int
scale_degree (_,_,n,_) = n
-- | The 'Pitch'es at 'Scale'.
scale_pitches :: Scale i -> [Pitch i]
scale_pitches (_,_,_,p) = p
-- | Ensure degree and number of pitches align.
scale_verify :: Scale i -> Bool
scale_verify (_,_,n,p) = n == length p
-- | Raise error if scale doesn't verify, else 'id'.
scale_verify_err :: Scale i -> Scale i
scale_verify_err scl = if scale_verify scl then scl else error "invalid scale"
-- | The last 'Pitch' element of the scale (ie. the /ocatve/).
scale_octave :: Scale i -> Maybe (Pitch i)
scale_octave (_,_,_,s) =
case s of
[] -> Nothing
_ -> Just (last s)
-- | Is 'scale_octave' perfect, ie. 'Ratio' of @2@ or 'Cents' of
-- @1200@.
perfect_octave :: Integral i => Scale i -> Bool
perfect_octave s = scale_octave s `elem` [Just (Right 2),Just (Left 1200)]
-- | Are all pitches of the same type.
is_scale_uniform :: Scale i -> Bool
is_scale_uniform = isJust . uniform_pitch_type . scale_pitches
-- | Make scale pitches uniform, conforming to the most promininent
-- pitch type.
scale_uniform :: Epsilon -> Scale Integer -> Scale Integer
scale_uniform epsilon (nm,d,n,p) =
case pitch_type_predominant p of
Pitch_Cents -> (nm,d,n,map (Left . pitch_cents) p)
Pitch_Ratio -> (nm,d,n,map (Right . pitch_ratio epsilon) p)
-- | Scale as list of 'T.Cents' (ie. 'pitch_cents') with @0@ prefix.
scale_cents :: Integral i => Scale i -> [T.Cents]
scale_cents s = 0 : map pitch_cents (scale_pitches s)
-- | 'map' 'round' of 'scale_cents'.
scale_cents_i :: Integral i => Scale i -> [i]
scale_cents_i = map round . scale_cents
-- | Scale as list of 'Rational' (ie. 'pitch_ratio') with @1@ prefix.
scale_ratios :: Epsilon -> Scale Integer -> [Rational]
scale_ratios epsilon s = 1 : map (pitch_ratio epsilon) (scale_pitches s)
-- | Require that 'Scale' be uniformlay of 'Ratio's.
scale_ratios_req :: Integral i => Scale i -> [Ratio i]
scale_ratios_req =
let err = error "scale_ratios_req"
in (1 :) . map (fromMaybe err . T.fromRight) . scale_pitches
-- | Translate 'Scale' to 'T.Tuning'. If 'Scale' is uniformly
-- rational, 'T.Tuning' is rational, else 'T.Tuning' is in 'T.Cents'.
-- 'Epsilon' is used to recover the 'Rational' octave if required.
scale_to_tuning :: Epsilon -> Scale Integer -> T.Tuning
scale_to_tuning epsilon (_,_,_,p) =
case partitionEithers p of
([],r) -> let (r',o) = T.separate_last r
in T.Tuning (Left (1 : r')) o
_ -> let (c,o) = T.separate_last p
c' = 0 : map pitch_cents c
o' = either (T.reconstructed_ratio epsilon) id o
in T.Tuning (Right c') o'
-- | Convert 'T.Tuning' to 'Scale'.
--
-- > tuning_to_scale ("et12","12 tone equal temperament") (T.equal_temperament 12)
tuning_to_scale :: (String,String) -> T.Tuning -> Scale Integer
tuning_to_scale (nm,dsc) (T.Tuning p o) =
let n = either length length p
p' = either (map Right . tail) (map Left . tail) p ++ [Right o]
in (nm,dsc,n,p')
{- | Are scales equal ('==') at degree and tuning data.
> db <- scl_load_db
> let r = [2187/2048,9/8,32/27,81/64,4/3,729/512,3/2,6561/4096,27/16,16/9,243/128,2/1]
> let Just py = find (scale_eq ("","",12,map Right r)) db
> scale_name py == "pyth_12"
> let c = map T.ratio_to_cents r
> let Just py' = find (scale_eqv ("","",12,map Left c)) db
> scale_name py' == "pyth_12"
-}
scale_eq :: Eq n => Scale n -> Scale n -> Bool
scale_eq (_,_,d0,p0) (_,_,d1,p1) = d0 == d1 && p0 == p1
-- | Are scales equal ('==') at degree and tuning data after 'pitch_cents'.
scale_eqv :: Integral n => Scale n -> Scale n -> Bool
scale_eqv (_,_,d0,p0) (_,_,d1,p1) =
let f = map pitch_cents
in d0 == d1 && f p0 == f p1
-- * Parser
-- | Comment lines begin with @!@.
is_comment :: String -> Bool
is_comment x =
case x of
'!':_ -> True
_ -> False
-- | Remove to end of line @!@ comments.
--
-- > remove_eol_comments " 1 ! comment" == " 1 "
remove_eol_comments :: String -> String
remove_eol_comments = takeWhile (/= '!')
-- | Remove comments and null lines and trailing comments.
--
-- > filter_comments ["!a","b","","c","d!e"] == ["b","c","d"]
filter_comments :: [String] -> [String]
filter_comments =
map remove_eol_comments .
filter (not . T.predicate_any [is_comment,null])
-- | Pitches are either cents (with decimal point, possibly trailing) or ratios (with @/@).
--
-- > map parse_pitch ["700.0","350.","3/2","2"] == [Left 700,Left 350,Right (3/2),Right 2]
parse_pitch :: (Read i,Integral i) => String -> Pitch i
parse_pitch p =
if '.' `elem` p
then Left (T.read_fractional_allow_trailing_point_err p)
else Right (T.read_ratio_with_div_err p)
-- | Pitch lines may contain commentary.
parse_pitch_ln :: (Read i, Integral i) => String -> Pitch i
parse_pitch_ln x =
case words x of
p:_ -> parse_pitch p
_ -> error (show ("parse_pitch_ln",words x))
-- | Parse @.scl@ file.
parse_scl :: (Read i, Integral i) => String -> String -> Scale i
parse_scl nm s =
case filter_comments (lines (T.filter_cr s)) of
t:n:p -> let scl = (nm,T.delete_trailing_whitespace t,T.read_err n,map parse_pitch_ln p)
in scale_verify_err scl
_ -> error "parse"
-- * IO
-- | Read the environment variable @SCALA_SCL_DIR@, which is a
-- sequence of directories used to locate scala files on.
--
-- > setEnv "SCALA_DIST_DIR" "/home/rohan/data/scala/85/scl"
scl_get_dir :: IO [String]
scl_get_dir = fmap splitSearchPath (getEnv "SCALA_SCL_DIR")
-- | Lookup the @SCALA_SCL_DIR@ environment variable, which must exist, and derive the filepath.
-- It is an error if the name has a file extension.
--
-- > mapM scl_derive_filename ["young-lm_piano","et12"]
scl_derive_filename :: FilePath -> IO FilePath
scl_derive_filename nm = do
dir <- scl_get_dir
when (null dir) (error "scl_derive_filename: SCALA_SCL_DIR: nil")
when (hasExtension nm) (error "scl_derive_filename: name has extension")
T.path_scan_err dir (nm <.> "scl")
-- | If the name is an absolute file path and has a @.scl@ extension,
-- then return it, else run 'scl_derive_filename'.
--
-- > scl_resolve_name "young-lm_piano"
-- > scl_resolve_name "/home/rohan/data/scala/85/scl/young-lm_piano.scl"
-- > scl_resolve_name "/home/rohan/data/scala/85/scl/unknown-tuning.scl"
scl_resolve_name :: String -> IO FilePath
scl_resolve_name nm =
let ex_f x = if x then return nm else error "scl_resolve_name: file does not exist"
in if isAbsolute nm && takeExtension nm == ".scl"
then doesFileExist nm >>= ex_f
else scl_derive_filename nm
-- | Load @.scl@ file, runs 'resolve_scl'.
--
-- > s <- scl_load "xenakis_chrom"
-- > pitch_representations (scale_pitches s) == (6,1)
-- > scale_ratios 1e-3 s == [1,21/20,29/23,179/134,280/187,11/7,100/53,2]
scl_load :: (Read i, Integral i) => String -> IO (Scale i)
scl_load nm = do
fn <- scl_resolve_name nm
s <- T.read_file_iso_8859_1 fn
return (parse_scl (takeBaseName nm) s)
-- | 'scale_to_tuning' of 'scl_load'.
scl_load_tuning :: Epsilon -> String -> IO T.Tuning
scl_load_tuning epsilon = fmap (scale_to_tuning epsilon) . scl_load
{- | Load all @.scl@ files at /dir/.
> dir <- scl_get_dir
> dir == ["/home/rohan/data/scala/85/scl","/home/rohan/sw/hmt/data/scl"]
> let [scl_85_dir,ext_dir] = dir
> db <- scl_load_dir scl_85_dir
> length db == 4671
> length (filter ((== 0) . scale_degree) db) == 0
> length (filter ((/= 1) . head . scale_ratios 1e-3) db) == 0
> length (filter ((/= 0) . head . scale_cents) db) == 0
> length (filter (== Just (Right 2)) (map scale_octave db)) == 4003
> length (filter is_scale_uniform db) == 2816
> let na = filter (not . T.is_ascending . scale_cents) db
> length na == 121
> mapM_ (putStrLn . unlines . scale_stat) na
> import qualified Music.Theory.List as T
> import Sound.SC3.Plot
> plot_p2_stp [T.histogram (map scale_degree db)]
> import Data.List
> let r = ["Xenakis's Byzantine Liturgical mode, 5 + 19 + 6 parts"
> ,"Xenakis's Byzantine Liturgical mode, 12 + 11 + 7 parts"
> ,"Xenakis's Byzantine Liturgical mode, 7 + 16 + 7 parts"]
> in filter (isInfixOf "Xenakis") (map scale_description db) == r
> let r = ["LaMonte Young, tuning of For Guitar '58. 1/1 March '92, inv.of Mersenne lute 1"
> ,"LaMonte Young's Well-Tuned Piano"]
> in filter (isInfixOf "LaMonte Young") (map scale_description db) == r
> length (filter (not . perfect_octave) db) == 663
-}
scl_load_dir :: (Read i, Integral i) => FilePath -> IO [Scale i]
scl_load_dir d = T.dir_subset [".scl"] d >>= mapM scl_load
-- | Load Scala data base at 'scl_get_dir'.
--
-- > db <- scl_load_db
-- > mapM_ (putStrLn.unlines.scale_stat) (filter (not . perfect_octave) db)
scl_load_db :: (Read i, Integral i) => IO [Scale i]
scl_load_db = do
dir <- scl_get_dir
r <- mapM scl_load_dir dir
return (concat r)
-- * PP
scale_stat :: (Integral i,Show i) => Scale i -> [String]
scale_stat s =
let ty = uniform_pitch_type (scale_pitches s)
in ["scale-name : " ++ scale_name s
,"scale-description : " ++ scale_description s
,"scale-degree : " ++ show (scale_degree s)
,"scale-type : " ++ maybe "non-uniform" show ty
,"perfect-octave : " ++ show (perfect_octave s)
,"scale-cents-i : " ++ show (scale_cents_i s)
,if ty == Just Pitch_Ratio
then "scale-ratios : " ++ intercalate "," (map T.rational_pp (scale_ratios_req s))
else ""]
-- | Pretty print 'Pitch' in @Scala@ format.
pitch_pp :: Show i => Pitch i -> String
pitch_pp p =
case p of
Left c -> show c
Right r -> show (numerator r) ++ "/" ++ show (denominator r)
-- | Pretty print 'Scale' in @Scala@ format.
--
-- > s <- scl_load "et19"
-- > s <- scl_load "young-lm_piano"
-- > putStr $ unlines $ scale_pp s
scale_pp :: Show i => Scale i -> [String]
scale_pp (nm,dsc,k,p) =
["! " ++ nm ++ ".scl"
,"!"
,dsc
,show k
,"!"] ++ map pitch_pp p
-- * DIST
-- | @scala@ distribution directory, given at @SCALA_DIST_DIR@.
--
-- > fmap (== "/home/rohan/opt/build/scala-22-pc64-linux") dist_get_dir
dist_get_dir :: IO String
dist_get_dir = getEnv "SCALA_DIST_DIR"
-- | Load file from 'dist_get_dir'.
--
-- > s <- load_dist_file "intnam.par"
-- > length s == 473
load_dist_file :: FilePath -> IO [String]
load_dist_file nm = do
d <- dist_get_dir
fmap lines (readFile (d </> nm))