{- | Parser for the Scala scale file format. See for details. This module succesfully parses all scales in v.89 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.Array.CSV as T {- hmt -} 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.Prime as T {- hmt -} import qualified Music.Theory.Read as T {- hmt -} import qualified Music.Theory.Show as T {- hmt -} import qualified Music.Theory.String as T {- hmt -} import qualified Music.Theory.Tuning as T {- hmt -} import qualified Music.Theory.Tuning.Type as T {- hmt -} -- * Pitch -- | A @.scl@ pitch is either in 'Cents' or is a 'Ratio'. type Pitch = Either T.Cents Rational -- | 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 -> 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 :: Pitch -> 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 -> 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 :: [Pitch] -> (Int,Int) 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] -> Maybe Pitch_Type uniform_pitch_type p = case pitch_representations p of (0,_) -> Just Pitch_Ratio (_,0) -> Just Pitch_Cents _ -> Nothing -- | The predominant type of the pitches for 'Scale'. pitch_type_predominant :: [Pitch] -> Pitch_Type pitch_type_predominant p = let (c,r) = pitch_representations p in if c >= r then Pitch_Cents else Pitch_Ratio -- * Scale -- | A scale has a name, a description, a degree, and a sequence of pitches. -- The /name/ is the the file-name without the /.scl/ suffix. -- By convention the first comment line gives the file name (with suffix). -- The pitches do NOT include 1:1 or 0c and do include the octave. type Scale = (String,String,Int,[Pitch]) -- | The name of a scale. scale_name :: Scale -> String scale_name (nm,_,_,_) = nm -- | Text description of a scale. scale_description :: Scale -> String scale_description (_,d,_,_) = d -- | The degree of the scale (number of 'Pitch'es). scale_degree :: Scale -> Int scale_degree (_,_,n,_) = n -- | The 'Pitch'es at 'Scale'. scale_pitches :: Scale -> [Pitch] scale_pitches (_,_,_,p) = p -- | Is 'Pitch' outside of the standard octave (ie. cents 0-1200 and ratios 1-2) pitch_non_oct :: Pitch -> Bool pitch_non_oct p = case p of Left c -> c < 0 || c > 1200 Right r -> r < 1 || r > 2 -- | Ensure degree and number of pitches align. scale_verify :: Scale -> Bool scale_verify (_,_,n,p) = n == length p -- | Raise error if scale doesn't verify, else 'id'. scale_verify_err :: Scale -> Scale scale_verify_err scl = if scale_verify scl then scl else error ("invalid scale: " ++ scale_name scl) -- | The last 'Pitch' element of the scale (ie. the /octave/). For empty scales give 'Nothing'. scale_octave :: Scale -> Maybe Pitch scale_octave (_,_,_,s) = case s of [] -> Nothing _ -> Just (last s) -- | Error variant. scale_octave_err :: Scale -> Pitch scale_octave_err = fromMaybe (error "scale_octave?") . scale_octave -- | Is 'scale_octave' perfect, ie. 'Ratio' of @2@ or 'Cents' of @1200@. perfect_octave :: Scale -> Bool perfect_octave s = case scale_octave s of Just (Right 2) -> True Just (Left 1200.0) -> True _ -> False -- | Are all pitches of the same type. is_scale_uniform :: Scale -> Bool is_scale_uniform = isJust . uniform_pitch_type . scale_pitches -- | Are the pitches in ascending sequence. is_scale_ascending :: Scale -> Bool is_scale_ascending = T.is_ascending . map pitch_cents . scale_pitches -- | Make scale pitches uniform, conforming to the most predominant pitch type. scale_uniform :: Epsilon -> Scale -> Scale 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 :: Scale -> [T.Cents] scale_cents s = 0 : map pitch_cents (scale_pitches s) -- | 'map' 'round' of 'scale_cents'. scale_cents_i :: Scale -> [T.Cents_I] scale_cents_i = map round . scale_cents -- | Scale as list of 'Rational' (ie. 'pitch_ratio') with @1@ prefix. scale_ratios :: Epsilon -> Scale -> [Rational] scale_ratios epsilon s = 1 : map (pitch_ratio epsilon) (scale_pitches s) -- | Require that 'Scale' be uniformly of 'Ratio's. scale_ratios_u :: Scale -> Maybe [Rational] scale_ratios_u scl = let err = error "scale_ratios_u?" p = scale_pitches scl in case uniform_pitch_type p of Just Pitch_Ratio -> Just (1 : map (fromMaybe err . T.from_right) p) _ -> Nothing -- | Erroring variant of 'scale_ratios_u. scale_ratios_req :: Scale -> [Rational] scale_ratios_req = fromMaybe (error "scale_ratios_req") . scale_ratios_u {- | 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 ("","",length r,map Right r)) db > scale_name py == "pyth_12" 'scale_eqv' provides an approximate equality function. > let c = map T.ratio_to_cents r > let Just py' = find (scale_eqv 0.00001 ("","",length c,map Left c)) db > scale_name py' == "pyth_12" -} scale_eq :: Scale -> Scale -> Bool scale_eq (_,_,d0,p0) (_,_,d1,p1) = d0 == d1 && p0 == p1 -- | Are scales equal at degree and 'intersect' to at least /k/ places of tuning data. scale_eq_n :: Int -> Scale -> Scale -> Bool scale_eq_n k (_,_,d0,p0) (_,_,d1,p1) = d0 == d1 && length (intersect p0 p1) >= k -- | Is `s1` a proper subset of `s2`. scale_sub :: Scale -> Scale -> Bool scale_sub (_,_,d0,p0) (_,_,d1,p1) = d0 < d1 && intersect p0 p1 == p0 -- | Are scales equal at degree and equivalent to within /epsilon/ at 'pitch_cents'. scale_eqv :: Epsilon -> Scale -> Scale -> Bool scale_eqv epsilon (_,_,d0,p0) (_,_,d1,p1) = let (~=) p q = abs (pitch_cents p - pitch_cents q) < epsilon in d0 == d1 && all id (zipWith (~=) p0 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 trailing comments (the description may be empty, keep nulls) -- -- > 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]) -- | Pitches are either cents (with decimal point, possibly trailing) or ratios (with @/@). -- -- > map parse_pitch ["70.0","350.","3/2","2","2/1"] == [Left 70,Left 350,Right (3/2),Right 2,Right 2] parse_pitch :: String -> Pitch 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 :: String -> Pitch parse_pitch_ln x = case words x of p:_ -> parse_pitch p _ -> error (show ("parse_pitch_ln",words x)) -- | Parse @.scl@ file. parse_scl :: String -> String -> Scale 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_msg "degree" 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/89/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/89/scl/young-lm_piano.scl" -- > scl_resolve_name "/home/rohan/data/scala/89/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 :: String -> IO Scale scl_load nm = do fn <- scl_resolve_name nm s <- T.read_file_iso_8859_1 fn return (parse_scl (takeBaseName nm) s) {- | Load all @.scl@ files at /dir/, associate with file-name. > db <- scl_load_dir_fn "/home/rohan/data/scala/89/scl" > length db == 5050 -- v.89 > map (\(fn,s) -> (takeFileName fn,scale_name s)) db -} scl_load_dir_fn :: FilePath -> IO [(FilePath,Scale)] scl_load_dir_fn d = do fn <- T.dir_subset [".scl"] d scl <- mapM scl_load fn return (zip fn scl) -- | 'snd' of 'scl_load_dir_fn' scl_load_dir :: FilePath -> IO [Scale] scl_load_dir = fmap (map snd) . scl_load_dir_fn -- | 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 :: IO [Scale] scl_load_db = do dir <- scl_get_dir r <- mapM scl_load_dir dir return (concat r) -- * PP -- | scales_dir_txt_tbl :: [Scale] -> [[String]] scales_dir_txt_tbl = let f s = [scale_name s,show (scale_degree s),scale_description s] in map f -- | Format as CSV file. -- -- > db <- scl_load_db -- > writeFile "/tmp/scl.csv" (scales_dir_txt_csv db) scales_dir_txt_csv :: [Scale] -> String scales_dir_txt_csv db = T.csv_table_pp id T.def_csv_opt (Nothing,scales_dir_txt_tbl db) -- | Simple plain-text display of scale data. -- -- > db <- scl_load_db -- > writeFile "/tmp/scl.txt" (unlines (intercalate [""] (map scale_stat db))) scale_stat :: Scale -> [String] scale_stat s = let p = scale_pitches s u_ty = uniform_pitch_type p n_ty = let p_ty = pitch_type_predominant p (p_i,p_j) = pitch_representations p in concat ["non-uniform (",show p_ty,",",show p_i,":",show p_j,")"] in ["name : " ++ scale_name s ,"description : " ++ scale_description s ,"degree : " ++ show (scale_degree s) ,"type : " ++ maybe n_ty show u_ty ,"perfect-oct : " ++ show (perfect_octave s) ,"cents-i : " ++ show (scale_cents_i s) ,if u_ty == Just Pitch_Ratio then "ratios : " ++ intercalate "," (map T.rational_pp (scale_ratios_req s)) else ""] -- | Pretty print 'Pitch' in @Scala@ format. pitch_pp :: Pitch -> 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. -- -- > scl <- scl_load "et19" -- > scl <- scl_load "young-lm_piano" -- > putStr $ unlines $ scale_pp scl scale_pp :: Scale -> [String] scale_pp (nm,dsc,k,p) = ["! " ++ nm ++ ".scl" ,"!" ,dsc ,show k ,"!"] ++ map pitch_pp p scale_wr :: FilePath -> Scale -> IO () scale_wr fn = writeFile fn . unlines . scale_pp -- | Write /scl/ to /dir/ with the file-name 'scale_name'.scl scale_wr_dir :: FilePath -> Scale -> IO () scale_wr_dir dir scl = scale_wr (dir scale_name scl <.> "scl") scl -- * 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'. load_dist_file :: FilePath -> IO String load_dist_file nm = do d <- dist_get_dir readFile (d nm) -- | 'fmap' 'lines' 'load_dist_file' -- -- > s <- load_dist_file_ln "intnam.par" -- > length s == 533 -- Scala 2.42p load_dist_file_ln :: FilePath -> IO [String] load_dist_file_ln = fmap lines . load_dist_file -- * QUERY -- | Is scale just-intonation (ie. are all pitches ratios) scl_is_ji :: Scale -> Bool scl_is_ji = (==) (Just Pitch_Ratio) . uniform_pitch_type . scale_pitches -- | Calculate limit for JI scale (ie. largest prime factor) scl_ji_limit :: Scale -> Integer scl_ji_limit = maximum . map fst . concatMap T.rational_prime_factors_m . scale_ratios_req -- | Sum of absolute differences to scale given in cents, sorted, with rotation. scl_cdiff_abs_sum :: [T.Cents] -> Scale -> [(Double,[T.Cents],Int)] scl_cdiff_abs_sum c scl = let r = map (T.dx_d 0) (T.rotations (T.d_dx (sort (scale_cents scl)))) ndiff x i = let d = zipWith (-) c x in (sum (map abs d),d,i) in sort (zipWith ndiff r [0..]) {- | Variant selecting only nearest and with post-processing function. > scl <- scl_load "holder" > scale_cents_i scl > c = [0,83,193,308,388,502,584,695,778,890,1004,1085,1200] > (_,r,_) = scl_cdiff_abs_sum_1 round c scl > r == [0,2,-1,1,0,-1,0,-1,0,0,0,0,0] -} scl_cdiff_abs_sum_1 :: (Double -> n) -> [T.Cents] -> Scale -> (Double,[n],Int) scl_cdiff_abs_sum_1 pp c scl = case scl_cdiff_abs_sum c scl of [] -> error "scl_cdiff_abs_sum_1" (n,d,r):_ -> (n,map pp d,r) {- | Sort DB into ascending order of sum of absolute of differences to scale given in cents. Scales are sorted and all rotations are considered. > db <- scl_load_db > c = [0,83,193,308,388,502,584,695,778,890,1004,1085,1200] > r = scl_db_query_cdiff_asc round db c > ((_,dx,_),_):_ = r > dx == [0,2,-1,1,0,-1,0,-1,0,0,0,0,0] > mapM_ (putStrLn . unlines . scale_stat . snd) (take 10 r) -} scl_db_query_cdiff_asc :: Ord n => (Double -> n) -> [Scale] -> [T.Cents] -> [((Double,[n],Int),Scale)] scl_db_query_cdiff_asc pp db c = let n = length c - 1 db_n = filter ((== n) . scale_degree) db in sort (map (\scl -> (scl_cdiff_abs_sum_1 pp c scl,scl)) db_n) -- | Is /x/ the same scale as /scl/ under /cmp/. scale_cmp_ji :: ([Rational] -> [Rational] -> Bool) -> [Rational] -> Scale -> Bool scale_cmp_ji cmp x scl = case scale_ratios_u scl of Nothing -> False Just r -> cmp x r -- | Find scale(s) that are 'scale_cmp_ji' to /x/. -- Usual /cmp/ are (==) and 'is_subset'. scl_find_ji :: ([Rational] -> [Rational] -> Bool) -> [Rational] -> IO [Scale] scl_find_ji cmp x = do db <- scl_load_db return (filter (scale_cmp_ji cmp x) db) -- * Tuning -- | Translate 'Scale' to 'T.Tuning'. If 'Scale' is uniformly -- rational, 'T.Tuning' is rational, else it is in 'T.Cents'. scale_to_tuning :: Scale -> T.Tuning scale_to_tuning (_,_,_,p) = case partitionEithers p of ([],r) -> let (r',o) = T.separate_last r in T.Tuning (Left (1 : r')) (if o == 2 then Nothing else Just (Left o)) _ -> let (c,o) = T.separate_last p c' = 0 : map pitch_cents c o' = if o == Left 1200 || o == Right 2 then Nothing else Just (T.either_swap o) in T.Tuning (Right c') o' -- | Convert 'T.Tuning' to 'Scale'. -- -- > tuning_to_scale ("et12","12 tone equal temperament") (T.tn_equal_temperament 12) tuning_to_scale :: (String,String) -> T.Tuning -> Scale tuning_to_scale (nm,dsc) tn@(T.Tuning p _) = let n = either length length p p' = either (map Right . tail) (map Left . tail) p ++ [T.either_swap (T.tn_octave_def tn)] in (nm,dsc,n,p') -- | 'scale_to_tuning' of 'scl_load'. -- -- > fmap T.tn_limit (scl_load_tuning "pyra") -- Just 59 scl_load_tuning :: String -> IO T.Tuning scl_load_tuning = fmap scale_to_tuning . scl_load