Coverage for /usr/lib/python3/dist-packages/gpiozero/tones.py: 44%

86 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2024-02-10 12:38 +0000

1# vim: set fileencoding=utf-8: 

2# 

3# GPIO Zero: a library for controlling the Raspberry Pi's GPIO pins 

4# 

5# Copyright (c) 2019 Dave Jones <dave@waveform.org.uk> 

6# Copyright (c) 2019 Ben Nuttall <ben@bennuttall.com> 

7# 

8# SPDX-License-Identifier: BSD-3-Clause 

9 

10from __future__ import ( 

11 unicode_literals, 

12 absolute_import, 

13 print_function, 

14 division, 

15) 

16str = type('') 

17 

18import re 

19import warnings 

20from collections import namedtuple 

21try: 

22 from math import log2 

23except ImportError: 

24 from .compat import log2 

25 

26from .exc import AmbiguousTone 

27 

28 

29class Tone(float): 

30 """ 

31 Represents a frequency of sound in a variety of musical notations. 

32 

33 :class:`Tone` class can be used with the :class:`~gpiozero.TonalBuzzer` 

34 class to easily represent musical tones. The class can be constructed in a 

35 variety of ways. For example as a straight frequency in `Hz`_ (which is the 

36 internal storage format), as an integer MIDI note, or as a string 

37 representation of a musical note. 

38 

39 All the following constructors are equivalent ways to construct the typical 

40 tuning note, `concert A`_ at 440Hz, which is MIDI note #69: 

41 

42 >>> from gpiozero.tones import Tone 

43 >>> Tone(440.0) 

44 >>> Tone(69) 

45 >>> Tone('A4') 

46 

47 If you do not want the constructor to guess which format you are using 

48 (there is some ambiguity between frequencies and MIDI notes at the bottom 

49 end of the frequencies, from 128Hz down), you can use one of the explicit 

50 constructors, :meth:`from_frequency`, :meth:`from_midi`, or 

51 :meth:`from_note`, or you can specify a keyword argument when 

52 constructing:: 

53 

54 >>> Tone.from_frequency(440) 

55 >>> Tone.from_midi(69) 

56 >>> Tone.from_note('A4') 

57 >>> Tone(frequency=440) 

58 >>> Tone(midi=69) 

59 >>> Tone(note='A4') 

60 

61 Several attributes are provided to permit conversion to any of the 

62 supported construction formats: :attr:`frequency`, :attr:`midi`, and 

63 :attr:`note`. Methods are provided to step :meth:`up` or :meth:`down` to 

64 adjacent MIDI notes. 

65 

66 .. warning:: 

67 

68 Currently :class:`Tone` derives from :class:`float` and can be used as 

69 a floating point number in most circumstances (addition, subtraction, 

70 etc). This part of the API is not yet considered "stable"; i.e. we may 

71 decide to enhance / change this behaviour in future versions. 

72 

73 .. _Hz: https://en.wikipedia.org/wiki/Hertz 

74 .. _concert A: https://en.wikipedia.org/wiki/Concert_pitch 

75 """ 

76 

77 tones = 'CCDDEFFGGAAB' 

78 semitones = { 

79 '♭': -1, 

80 'b': -1, 

81 '♮': 0, 

82 '': 0, 

83 '♯': 1, 

84 '#': 1, 

85 } 

86 regex = re.compile( 

87 r'(?P<note>[A-G])' 

88 r'(?P<semi>[%s]?)' 

89 r'(?P<octave>[0-9])' % ''.join(semitones.keys())) 

90 

91 def __new__(cls, value=None, **kwargs): 

92 if value is None: 92 ↛ 93line 92 didn't jump to line 93, because the condition on line 92 was never true

93 if len(kwargs) != 1: 

94 raise TypeError('expected precisely one keyword argument') 

95 key, value = kwargs.popitem() 

96 try: 

97 return { 

98 'frequency': cls.from_frequency, 

99 'midi': cls.from_midi, 

100 'note': cls.from_note, 

101 }[key](value) 

102 except KeyError: 

103 raise TypeError('unexpected keyword argument %r' % key) 

104 else: 

105 if kwargs: 105 ↛ 106line 105 didn't jump to line 106, because the condition on line 105 was never true

106 raise TypeError('cannot specify keywords with a value') 

107 if isinstance(value, (int, float)): 107 ↛ 108line 107 didn't jump to line 108, because the condition on line 107 was never true

108 if 0 <= value < 128: 

109 if value > 0: 

110 warnings.warn( 

111 AmbiguousTone( 

112 "Ambiguous tone specification; assuming you " 

113 "want a MIDI note. To suppress this warning " 

114 "use, e.g. Tone(midi=60), or to obtain a " 

115 "frequency instead use, e.g. Tone(frequency=" 

116 "60)")) 

117 return cls.from_midi(value) 

118 else: 

119 return cls.from_frequency(value) 

120 elif isinstance(value, (bytes, str)): 120 ↛ 123line 120 didn't jump to line 123, because the condition on line 120 was never false

121 return cls.from_note(value) 

122 else: 

123 return cls.from_frequency(value) 

124 

125 def __str__(self): 

126 return self.note 

127 

128 def __repr__(self): 

129 try: 

130 midi = self.midi 

131 except ValueError: 

132 midi = '' 

133 else: 

134 midi = ' midi=%r' % midi 

135 try: 

136 note = self.note 

137 except ValueError: 

138 note = '' 

139 else: 

140 note = ' note=%r' % note 

141 return "<Tone%s%s frequency=%.2fHz>" % (note, midi, self.frequency) 

142 

143 @classmethod 

144 def from_midi(cls, midi_note): 

145 """ 

146 Construct a :class:`Tone` from a MIDI note, which must be an integer 

147 in the range 0 to 127. For reference, A4 (`concert A`_ typically used 

148 for tuning) is MIDI note #69. 

149 

150 .. _concert A: https://en.wikipedia.org/wiki/Concert_pitch 

151 """ 

152 midi = int(midi_note) 

153 if 0 <= midi_note < 128: 153 ↛ 157line 153 didn't jump to line 157, because the condition on line 153 was never false

154 A4_midi = 69 

155 A4_freq = 440 

156 return cls.from_frequency(A4_freq * 2 ** ((midi - A4_midi) / 12)) 

157 raise ValueError('invalid MIDI note: %r' % midi) 

158 

159 @classmethod 

160 def from_note(cls, note): 

161 """ 

162 Construct a :class:`Tone` from a musical note which must consist of 

163 a capital letter A through G, followed by an optional semi-tone 

164 modifier ("b" for flat, "#" for sharp, or their Unicode equivalents), 

165 followed by an octave number (0 through 9). 

166 

167 For example `concert A`_, the typical tuning note at 440Hz, would be 

168 represented as "A4". One semi-tone above this would be "A#4" or 

169 alternatively "Bb4". Unicode representations of sharp and flat are also 

170 accepted. 

171 """ 

172 if isinstance(note, bytes): 172 ↛ 173line 172 didn't jump to line 173, because the condition on line 172 was never true

173 note = note.decode('ascii') 

174 if isinstance(note, str): 174 ↛ 182line 174 didn't jump to line 182, because the condition on line 174 was never false

175 match = Tone.regex.match(note) 

176 if match: 176 ↛ 182line 176 didn't jump to line 182, because the condition on line 176 was never false

177 octave = int(match.group('octave')) + 1 

178 return cls.from_midi( 

179 Tone.tones.index(match.group('note')) + 

180 Tone.semitones[match.group('semi')] + 

181 octave * 12) 

182 raise ValueError('invalid note specification: %r' % note) 

183 

184 @classmethod 

185 def from_frequency(cls, freq): 

186 """ 

187 Construct a :class:`Tone` from a frequency specified in `Hz`_ which 

188 must be a positive floating-point value in the range 0 < freq <= 20000. 

189 

190 .. _Hz: https://en.wikipedia.org/wiki/Hertz 

191 """ 

192 if 0 < freq <= 20000: 192 ↛ 194line 192 didn't jump to line 194, because the condition on line 192 was never false

193 return super(Tone, cls).__new__(cls, freq) 

194 raise ValueError('invalid frequency: %.2f' % freq) 

195 

196 @property 

197 def frequency(self): 

198 """ 

199 Return the frequency of the tone in `Hz`_. 

200 

201 .. _Hz: https://en.wikipedia.org/wiki/Hertz 

202 """ 

203 return float(self) 

204 

205 @property 

206 def midi(self): 

207 """ 

208 Return the (nearest) MIDI note to the tone's frequency. This will be an 

209 integer number in the range 0 to 127. If the frequency is outside the 

210 range represented by MIDI notes (which is approximately 8Hz to 12.5KHz) 

211 :exc:`ValueError` exception will be raised. 

212 """ 

213 result = int(round(12 * log2(self.frequency / 440) + 69)) 

214 if 0 <= result < 128: 

215 return result 

216 raise ValueError('%f is outside the MIDI note range' % self.frequency) 

217 

218 @property 

219 def note(self): 

220 """ 

221 Return the (nearest) note to the tone's frequency. This will be a 

222 string in the form accepted by :meth:`from_note`. If the frequency is 

223 outside the range represented by this format ("A0" is approximately 

224 27.5Hz, and "G9" is approximately 12.5Khz) a :exc:`ValueError` 

225 exception will be raised. 

226 """ 

227 offset = self.midi - 60 # self.midi - A4_midi + Tone.tones.index('A') 

228 index = offset % 12 # offset % len(Tone.tones) 

229 octave = 4 + offset // 12 

230 if 0 <= octave <= 9: 

231 return ( 

232 Tone.tones[index] + 

233 ('#' if Tone.tones[index] == Tone.tones[index - 1] else '') + 

234 str(octave) 

235 ) 

236 raise ValueError('%f is outside the notation range' % self.frequency) 

237 

238 def up(self, n=1): 

239 """ 

240 Return the :class:`Tone` *n* semi-tones above this frequency (*n* 

241 defaults to 1). 

242 """ 

243 return Tone.from_midi(self.midi + n) 

244 

245 def down(self, n=1): 

246 """ 

247 Return the :class:`Tone` *n* semi-tones below this frequency (*n* 

248 defaults to 1). 

249 """ 

250 return Tone.from_midi(self.midi - n)