1# This file is part of python-ly, https://pypi.python.org/pypi/python-ly
2#
3# Copyright (c) 2008 - 2015 by Wilbert Berendsen
4#
5# This program is free software; you can redistribute it and/or
6# modify it under the terms of the GNU General Public License
7# as published by the Free Software Foundation; either version 2
8# of the License, or (at your option) any later version.
9#
10# This program is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with this program; if not, write to the Free Software
17# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
18# See http://www.gnu.org/licenses/ for more information.
19
20"""
21Pitch manipulation.
22"""
23
24from __future__ import unicode_literals
25
26import re
27from fractions import Fraction
28
29import ly.lex.lilypond
30
31
32pitchInfo = {
33    'nederlands': (
34        ('c', 'd', 'e', 'f', 'g', 'a', 'b'),
35        ('eses', 'eseh', 'es', 'eh', '', 'ih', 'is', 'isih', 'isis'),
36        (('ees', 'es'), ('aes', 'as'))
37    ),
38    'english': (
39        ('c', 'd', 'e', 'f', 'g', 'a', 'b'),
40        ('ff', 'tqf', 'f', 'qf', '', 'qs', 's', 'tqs', 'ss'),
41    ),
42    'deutsch': (
43        ('c', 'd', 'e', 'f', 'g', 'a', 'h'),
44        ('eses', 'eseh', 'es', 'eh', '', 'ih', 'is', 'isih', 'isis'),
45        (('ases', 'asas'), ('ees', 'es'), ('aes', 'as'), ('heses', 'heses'), ('hes', 'b'))
46    ),
47    'svenska': (
48        ('c', 'd', 'e', 'f', 'g', 'a', 'h'),
49        ('essess', '', 'ess', '', '', '', 'iss', '', 'ississ'),
50        (('ees', 'es'), ('aes', 'as'), ('hessess', 'hessess'), ('hess', 'b'))
51    ),
52    'italiano': (
53        ('do', 're', 'mi', 'fa', 'sol', 'la', 'si'),
54        ('bb', 'bsb', 'b', 'sb', '', 'sd', 'd', 'dsd', 'dd')
55    ),
56    'espanol': (
57        ('do', 're', 'mi', 'fa', 'sol', 'la', 'si'),
58        ('bb', '', 'b', '', '', '', 's', '', 'ss')
59    ),
60    'portugues': (
61        ('do', 're', 'mi', 'fa', 'sol', 'la', 'si'),
62        ('bb', 'btqt', 'b', 'bqt', '', 'sqt', 's', 'stqt', 'ss')
63    ),
64    'vlaams': (
65        ('do', 're', 'mi', 'fa', 'sol', 'la', 'si'),
66        ('bb', '', 'b', '', '', '', 'k', '', 'kk')
67    ),
68}
69pitchInfo['norsk'] = pitchInfo['deutsch']
70pitchInfo['suomi'] = pitchInfo['deutsch']
71pitchInfo['catalan'] = pitchInfo['italiano']
72
73
74class PitchNameNotAvailable(Exception):
75    """Exception raised when there is no name for a pitch.
76
77    Can occur when translating pitch names, if the target language e.g.
78    does not have quarter-tone names.
79
80    """
81    def __init__(self, language):
82        super(PitchNameNotAvailable, self).__init__()
83        self.language = language
84
85
86class Pitch(object):
87    """A pitch with note, alter and octave attributes.
88
89    Attributes may be manipulated directly.
90
91    """
92    def __init__(self, note=0, alter=0, octave=0, accidental="", octavecheck=None):
93        self.note = note                # base note (c, d, e, f, g, a, b)
94                                        # as integer (0 to 6)
95        self.alter = alter              # # = .5; b = -.5; natural = 0
96        self.octave = octave            # '' = 2; ,, = -2
97        self.accidental = accidental    # "", "?" or "!"
98        self.octavecheck = octavecheck  # a number is an octave check
99
100    def __repr__(self):
101        return '<Pitch {0}>'.format(self.output())
102
103    def output(self, language="nederlands"):
104        """Returns our string representation."""
105        res = []
106        res.append(pitchWriter(language)(self.note, self.alter))
107        res.append(octaveToString(self.octave))
108        res.append(self.accidental)
109        if self.octavecheck is not None:
110            res.append('=')
111            res.append(octaveToString(self.octavecheck))
112        return ''.join(res)
113
114    @classmethod
115    def c1(cls):
116        """Returns a pitch c'."""
117        return cls(octave=1)
118
119    @classmethod
120    def c0(cls):
121        """Returns a pitch c."""
122        return cls()
123
124    @classmethod
125    def f0(cls):
126        """Return a pitch f."""
127        return cls(3)
128
129    def copy(self):
130        """Returns a new instance with our attributes."""
131        return self.__class__(self.note, self.alter, self.octave)
132
133    def makeAbsolute(self, lastPitch):
134        """Makes ourselves absolute, i.e. sets our octave from lastPitch."""
135        self.octave += lastPitch.octave - (self.note - lastPitch.note + 3) // 7
136
137    def makeRelative(self, lastPitch):
138        """Makes ourselves relative, i.e. changes our octave from lastPitch."""
139        self.octave -= lastPitch.octave - (self.note - lastPitch.note + 3) // 7
140
141
142class PitchWriter(object):
143    language = "unknown"
144    def __init__(self, names, accs, replacements=()):
145        self.names = names
146        self.accs = accs
147        self.replacements = replacements
148
149    def __call__(self, note, alter = 0):
150        """
151        Returns a string representing the pitch in our language.
152        Raises PitchNameNotAvailable if the requested pitch
153        has an alteration that is not available in the current language.
154        """
155        pitch = self.names[note]
156        if alter:
157            acc = self.accs[int(alter * 4 + 4)]
158            if not acc:
159                raise PitchNameNotAvailable(self.language)
160            pitch += acc
161        for s, r in self.replacements:
162            if pitch.startswith(s):
163                pitch = r + pitch[len(s):]
164                break
165        return pitch
166
167
168class PitchReader(object):
169    def __init__(self, names, accs, replacements=()):
170        self.names = list(names)
171        self.accs = list(accs)
172        self.replacements = replacements
173        self.rx = re.compile("({0})({1})?$".format("|".join(names),
174            "|".join(acc for acc in accs if acc)))
175
176    def __call__(self, text):
177        for s, r in self.replacements:
178            if text.startswith(r):
179                text = s + text[len(r):]
180        for dummy in 1, 2:
181            m = self.rx.match(text)
182            if m:
183                note = self.names.index(m.group(1))
184                if m.group(2):
185                    alter = Fraction(self.accs.index(m.group(2)) - 4, 4)
186                else:
187                    alter = 0
188                return note, alter
189            # HACK: were we using (rarely used) long english syntax?
190            text = text.replace('flat', 'f').replace('sharp', 's')
191        return False
192
193
194def octaveToString(octave):
195    """Converts numeric octave to a string with apostrophes or commas.
196
197    0 -> "" ; 1 -> "'" ; -1 -> "," ; etc.
198
199    """
200    return octave < 0 and ',' * -octave or "'" * octave
201
202
203def octaveToNum(octave):
204    """Converts string octave to an integer:
205
206    "" -> 0 ; "," -> -1 ; "'''" -> 3 ; etc.
207
208    """
209    return octave.count("'") - octave.count(",")
210
211
212_pitchReaders = {}
213_pitchWriters = {}
214
215
216def pitchReader(language):
217    """Returns a PitchReader for the specified language."""
218    try:
219        return _pitchReaders[language]
220    except KeyError:
221        res = _pitchReaders[language] = PitchReader(*pitchInfo[language])
222        return res
223
224
225def pitchWriter(language):
226    """Returns a PitchWriter for the specified language."""
227    try:
228        return _pitchWriters[language]
229    except KeyError:
230        res = _pitchWriters[language] = PitchWriter(*pitchInfo[language])
231        res.language = language
232        return res
233
234
235class PitchIterator(object):
236    """Iterate over notes or pitches in a source."""
237
238    def __init__(self, source, language="nederlands"):
239        """Initialize with a ly.document.Source.
240
241        The language is by default set to "nederlands".
242
243        """
244        self.source = source
245        self.setLanguage(language)
246
247    def setLanguage(self, lang):
248        r"""Changes the pitch name language to use.
249
250        Called internally when \language or \include tokens are encountered
251        with a valid language name/file.
252
253        Sets the language attribute to the language name and the read attribute
254        to an instance of ly.pitch.PitchReader.
255
256        """
257        if lang in pitchInfo.keys():
258            self.language = lang
259            return True
260
261    def tokens(self):
262        """Yield all the tokens from the source, following the language."""
263        for t in self.source:
264            yield t
265            if isinstance(t, ly.lex.lilypond.Keyword):
266                if t in ("\\include", "\\language"):
267                    for t in self.source:
268                        if not isinstance(t, ly.lex.Space) and t != '"':
269                            lang = t[:-3] if t.endswith('.ly') else t[:]
270                            if self.setLanguage(lang):
271                                yield LanguageName(lang, t.pos)
272                            break
273                        yield t
274
275    def read(self, token):
276        """Reads the token and returns (note, alter) or None."""
277        return pitchReader(self.language)(token)
278
279    def pitches(self):
280        """Yields all tokens, but collects Note and Octave tokens.
281
282        When a Note is encountered, also reads octave and octave check and then
283        a Pitch is yielded instead of the tokens.
284
285        """
286        tokens = self.tokens()
287        for t in tokens:
288            while isinstance(t, ly.lex.lilypond.Note):
289                p = self.read(t)
290                if not p:
291                    break
292                p = Pitch(*p)
293
294                p.note_token = t
295                p.octave_token = None
296                p.accidental_token = None
297                p.octavecheck_token = None
298
299                t = None # prevent hang in this loop
300                for t in tokens:
301                    if isinstance(t, ly.lex.lilypond.Octave):
302                        p.octave = octaveToNum(t)
303                        p.octave_token = t
304                    elif isinstance(t, ly.lex.lilypond.Accidental):
305                        p.accidental_token = p.accidental = t
306                    elif isinstance(t, ly.lex.lilypond.OctaveCheck):
307                        p.octavecheck = octaveToNum(t)
308                        p.octavecheck_token = t
309                        break
310                    elif not isinstance(t, ly.lex.Space):
311                        break
312                yield p
313                if t is None:
314                    break
315            else:
316                yield t
317
318    def position(self, t):
319        """Returns the cursor position for the given token or Pitch."""
320        if isinstance(t, Pitch):
321            t = t.note_token
322        return self.source.position(t)
323
324    def write(self, pitch, language=None):
325        """Output a changed Pitch.
326
327        The Pitch is written in the Source's document.
328
329        To use this method reliably, you must instantiate the PitchIterator
330        with a ly.document.Source that has tokens_with_position set to True.
331
332        """
333        document = self.source.document
334        pwriter = pitchWriter(language or self.language)
335        note = pwriter(pitch.note, pitch.alter)
336        end = pitch.note_token.end
337        if note != pitch.note_token:
338            document[pitch.note_token.pos:end] = note
339        octave = octaveToString(pitch.octave)
340        if octave != pitch.octave_token:
341            if pitch.octave_token is None:
342                document[end:end] = octave
343            else:
344                end = pitch.octave_token.end
345                document[pitch.octave_token.pos:end] = octave
346        if pitch.accidental:
347            if pitch.accidental_token is None:
348                document[end:end] = pitch.accidental
349            elif pitch.accidental != pitch.accidental_token:
350                end = pitch.accidental_token.end
351                document[pitch.accidental_token.pos:end] = pitch.accidental
352        elif pitch.accidental_token:
353            del document[pitch.accidental_token.pos:pitch.accidental_token.end]
354        if pitch.octavecheck is not None:
355            octavecheck = '=' + octaveToString(pitch.octavecheck)
356            if pitch.octavecheck_token is None:
357                document[end:end] = octavecheck
358            elif octavecheck != pitch.octavecheck_token:
359                document[pitch.octavecheck_token.pos:pitch.octavecheck_token.end] = octavecheck
360        elif pitch.octavecheck_token:
361            del document[pitch.octavecheck_token.pos:pitch.octavecheck_token.end]
362
363
364class LanguageName(ly.lex.Token):
365    """A Token that denotes a language name."""
366    pass
367
368
369