HommageHaskell Offline Music Manipulation And Generation EDSL*(* Embedded Domain Specific Language) IntroductionThere are several approaches to use Haskell in the context of music production. Some of them address the level of signal processing, using Haskells lazy lists as canonical signal stream representation, others address the higher level of music composition as the library Haskore does. In the latter, a tree-like datastructure is used to represent musical structures such as notes, rests, parallel and sequential composition, timing information and more. The idea behind the library presented here is to combine the two levels of music production into one system, allowing to define musical structures as well as some instruments to render the structure with. At least three kinds of tasks can be done with this library:
The name of the library, HOMMAGE, stands for "Haskell Offline Music Manipulation And Generation EDSL". "Offline" means that the library is non-interactive; the music production process contains of the cycle "programming - compiling - running the program - opening the result with a WAV or MIDI player and listening to it". "Music Manipulation and Generation" means that the library can be used to transform and produce audio data. "EDSL" ("Embedded Domain Specific Language") means that the library is implemented as a library which extends Haskell to a "musical" language. Since Haskell's lazy lists are a powerful framework for representing signal streams, only a minimal extension is needed to turn Haskell into a declarative signal processing simulation software. This extension concerns at first the possibility to read and write WAV files. To deal with WAV files, one has to make a distinction between Mono and Stereo WAV files. The problem rising at this point is that this property of a WAV file can only be detected dynamicly. A function that operates on the content of a WAV file that is read must expect values that can be either mono or stereo. For this reason the datatypes 'Signal' and 'Stereo' are introduced. Also, for more readability, we will use the type synonym 'Mono' for 'Double'.
type Mono = Double
data Stereo = Double :><: Double
writeWavMono :: FilePath -> [Mono] -> IO ()
writeWavStereo :: FilePath -> [Stereo] -> IO ()
data Signal = Mono [Mono]
| Stereo [Stereo]
readWavSignal :: FilePath -> IO Signal
openWavSignal :: FilePath -> Signal
Example 1: A simple SoundThe following simple example shows how a signal can be defined and written into a file. The only non-standard function used here is 'writeWavMono'. The function 'sound1' defines a finite sinus wave, represented in a list of Double resp. Mono values with length 44100. The values are between 1.0 and -1.0. In the main function of the program these values are written out into a WAV file after multiplying them with 0.9. sound1 :: [Mono] sound1 = take 44100 $ map sin $ iterate (+0.03) 0.0 main1 = writeWavMono "example-1.wav" $ map (*0.9) sound1 The resulting WAV file, which contains just a boring sinus wave, has a duration of one second, since the sampling rate of the files is 44.1 kHz. The amplitude of a signal that is stored in a WAV file should be between -1.0 and 1.0; any value out of this range will produce clips and unwanted noise in the result. To ensure that there is no clipping or kind of buffer overflow, the amplitude is multiplied with 0.9 in the example. Example 2: A simple SynthesizerAfter presenting the WAV interface, some convenient functions for building synthesizers will be explained. In the next example, a simple synthesizer is defined. Its structure is shown in figure 1. The synthesizer has two parameters: The pitch (note-number) and the trigger signal for the envelope. To explain it in short: The oscillator generates a wave which frequency depends on the pitch. This wave is filtered and then amplified by an envelope. The cutoff frequency of the filter is modulated by a LFO (low frequency oscillator). Figure 1 The following Haskell version of this synthesizer has one main difference to the synthesizer shown in figure 1. Real analogue synthsizers are controlled by a trigger signal that causes the envelope to rise or to decline (and thus the sound to be 'on' or 'off'). One synthesizer can play many sounds in sequence. Instead of this in the Haskell version the duration of the sound is given. Applying the synthesizer function to some parameters results in a finite list that represents just one sound. synth1 :: Double -> Int -> [Mono] synth1 notenr len = zipWith (*) env $ dftfilter (lowpass (repeat 0.5) lfo) osc where lfo = map ((+0.4) . (*0.25)) $ cosinus $ repeat 0.09 osc = saw $ repeat $ noteToFrequency 12.0 notenr env = playADSR FitS Linear (500,1500,0.4,2500) len main2 = writeWavMono "example-2.wav" $ map (*0.9) $ synth1 12.0 11025 Two oscillators are involved in this synthesizer. The lfo signal is generated by a cosinus oscillator ('cosinus'), and the input of the filter comes from a sawtooth oscillator ('saw'). Unlike the Haskell functions 'sin' and 'cos', the domain of the oscillators is the frequency and not the time. A constant input of 1.0 results in a wave with a period of 1024, which is ca. 43Hz in the WAV file. But the input also can be changing, allowing frequency modulation. In the example, constant input values are taken for the oscillators. The lfo has an constant input of 0.05 and generates a wave with ca. 2.15 Hz. The sawtooth oscillator ('saw') produces a wave that is the input of the filter. The frequency of 'saw' is determined by the result of 'noteToFrequency', which takes the number of notes per octave resp. double frequency and the actual note number. The values and the length of the envelope generated by 'playADSR' depend on the parameters including the 4-tupel representing attack, decay, sustain and release, further some additional shape information and the desired length. The envelope is used to amplify the result of the filtered sawtooth wave, and it ensures that the output of the synthesizer is a finite list. Example 3: A simple SongA synthesizer like the one in example 2 can play only one single sound. For playing a polyphonic melody, two things are needed: At first we need something like a musical notation which allows to specify the information concerning pitch-values, rhythm, volume, etc., or in other words: We want to specify in general when which sound has to be played. At second, the notation that represents many specific sounds including their temporal relations must be interpreted by an instrument or rendered to one "big" single sound that can be written out in a WAV file. For the representation of a musical structure a datatype 'Notation' is used, which is similar to the datatype 'Music' of the library Haskore. The main difference is that 'Notation' has a type parameter and functor properties over the content of the notes. In the following example, the content of the notes is a Double value, representing the note-number or pitch. Interpretation of a notation is done by applying a synthesizer to the contents of the notes (using 'fmap'). The synthesizer must result in a type that can be rendered. notes1 :: Notation Double notes1 = line [ Note (1%8) 12.0 , Note (1%8) 24.0 , Note (1%4) 16.0 , Note (1%2) 12.0 ] :=: line [ Rest (1%4) , Note (1%2) 36 ] notes2 :: Notation Double notes2 = notes1 :+: notes1 synth1' :: Double -> WithDur Dur [Mono] synth1' n = WithDur $ \d -> synth1 n (absDur $ durFrom d) main3 = writeWavMono "example-3.wav" $ map (*0.5) $ runNumNotation (fmap synth1' notes2) (bpmToDur 110) Via 'fmap' a modified version of the synthesizer from example 2 is applied to the notes. The notes then contain the type 'WithDur Dur [Mono]'. A notation containing this type can be rendered with 'runNumNotation', calling it with the absolute length (with respect to 44.1 kHz output) of a Note with duration 1. (A synthesizer can generate a sound that has not the desired length. This does not affect the rendering process.) The notation can also be exported to a MIDI-file: main3' = writeMidiSyncNotation "out.mid" [fmap (\n -> MidiNote 0 (round n) 127) notes1] Example 4: Global modulation sourceThe synthesizer in the examples 2 and 3 has a local LFO that starts every time a sound is played. Now, instead of having the LFO as a part of the synthesizer, we want one global LFO signal that can be used by all synthesizers. For this, we remove the LFO from the synthesizer and give it as an argument to it. The LFO is defined now globally as a 'Track', which is a reference to a list of values which is synchronized to the temporal alignment of the notes / synthesizers it is given to. So while in example 3 every sound is modulated by a fresh initialized LFO, in example 4 the same LFO signal modulates the synthesizer differently at different moments. synth2 :: [Mono] -> Double -> Int -> [Mono] synth2 lfo notenr len = zipWith (*) env $ dftfilter (lowpass (repeat 0.5) lfo) osc where osc = saw $ repeat $ noteToFrequency 12.0 notenr env = playADSR FitS Linear (500,1500,0.4,2500) len synth2' :: Track [Mono] -> Double -> Play [Mono] synth2' t_lfo n = do lfo <- playTrack t_lfo dur <- getDur return $ synth2 lfo n (absDur dur) song1 :: Song (Play [Mono]) song1 = do t_lfo <- track $ return $ map ((+0.4) . (*0.25)) $ cosinus $ repeat 0.09 return $ notation $ fmap (synth2' t_lfo) notes2 main4 = writeWavMono "example-4.wav" $ map (*0.5) $ runSong 110 song1 Example 5: A class/datatype structure for defining synthesizersFinally, there are some type classes to make the construction of synthesizers a little bit more convenient.
synth3 :: (Sound lfo, Sound notenr) => lfo -> notenr -> Play Signal
synth3 lfo notenr = notenr
==> ToFreq 12
==> Oscillator Saw 43.0664
==> Filter (Lowpass (0.5 :: Double) lfo)
==> Amplifier (Envelope FitS Linear (500,1500,0.4,2500) )
song2 :: Song (Play [Mono])
song2 = do
t_lfo <- track $ return $ map ((+0.4) . (*0.25)) $ cosinus $ repeat 0.09
t_mod <- track $ return $ map ((+4.0) . (*4.0)) $ tri $ repeat 0.01
return $ notationMono $ fmap (\n -> synth3 t_lfo (t_mod <+> n)) notes2
main5 = writeWavMono "example-5.wav" $
map (*0.5) $ runSong 110 song2
Find more examples here |