Skip to content

Commit 5516016

Browse files
EricPobotdwalton76
authored andcommitted
Proposed implementation for issue #403 (#431)
* adds the play_note method and changes duration parameters to seconds * fixes invalid join() parameter * fixes wrong delay parameter sanity check and adds a couple of comments
1 parent 923080b commit 5516016

File tree

1 file changed

+133
-27
lines changed

1 file changed

+133
-27
lines changed

ev3dev/sound.py

Lines changed: 133 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626
import sys
2727

28-
if sys.version_info < (3,4):
28+
if sys.version_info < (3, 4):
2929
raise SystemError('Must be using Python 3.4 or higher')
3030

3131
import os
@@ -69,6 +69,10 @@ class Sound(object):
6969
('G4', 'h'),
7070
('D5', 'h')
7171
))
72+
73+
In order to mimic EV3-G API parameters, durations used in methods
74+
exposed as EV3-G blocks for sound related operations are expressed
75+
as a float number of seconds.
7276
"""
7377

7478
channel = None
@@ -85,7 +89,8 @@ class Sound(object):
8589
)
8690

8791
def _validate_play_type(self, play_type):
88-
assert play_type in self.PLAY_TYPES, "Invalid play_type %s, must be one of %s" % (play_type, ','.join(self.PLAY_TYPES))
92+
assert play_type in self.PLAY_TYPES, \
93+
"Invalid play_type %s, must be one of %s" % (play_type, ','.join(str(t) for t in self.PLAY_TYPES))
8994

9095
def beep(self, args=''):
9196
"""
@@ -131,16 +136,22 @@ def tone(self, *args):
131136
(392.00, 300, 150), (311.13, 250, 100), (466.16, 25, 100), (392, 700)
132137
]).wait()
133138
139+
Have also a look at :py:meth:`play_song` for a more musician-friendly way of doing, which uses
140+
the conventional notation for notes and durations.
141+
134142
.. rubric:: tone(frequency, duration)
135143
136144
Play single tone of given frequency (Hz) and duration (milliseconds).
137145
"""
138146
def play_tone_sequence(tone_sequence):
139147
def beep_args(frequency=None, duration=None, delay=None):
140148
args = ''
141-
if frequency is not None: args += '-f %s ' % frequency
142-
if duration is not None: args += '-l %s ' % duration
143-
if delay is not None: args += '-D %s ' % delay
149+
if frequency is not None:
150+
args += '-f %s ' % frequency
151+
if duration is not None:
152+
args += '-l %s ' % duration
153+
if delay is not None:
154+
args += '-D %s ' % delay
144155

145156
return args
146157

@@ -153,10 +164,37 @@ def beep_args(frequency=None, duration=None, delay=None):
153164
else:
154165
raise Exception("Unsupported number of parameters in Sound.tone()")
155166

156-
def play_tone(self, frequency, duration_ms, delay_ms=100, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE):
167+
def play_tone(self, frequency, duration, delay=0.0, volume=100,
168+
play_type=PLAY_WAIT_FOR_COMPLETE):
169+
""" Play a single tone, specified by its frequency, duration, volume and final delay.
170+
171+
Args:
172+
frequency (int): the tone frequency, in Hertz
173+
duration (float): tone duration, in seconds
174+
delay (float): delay after tone, in seconds (can be useful when chaining calls to ``play_tone``)
175+
volume (int): sound volume in percent (between 0 and 100)
176+
play_type (int): one off Sound.PLAY_xxx play types (wait, no wait, loop)
177+
178+
Returns:
179+
the sound playing subprocess PID when no wait play type is selected, None otherwise
180+
181+
Raises:
182+
ValueError: if invalid value for parameter(s)
183+
"""
157184
self._validate_play_type(play_type)
185+
186+
if duration <= 0:
187+
raise ValueError('invalid duration (%s)' % duration)
188+
if delay < 0:
189+
raise ValueError('invalid delay (%s)' % delay)
190+
if not 0 < volume <= 100:
191+
raise ValueError('invalid volume (%s)' % volume)
192+
158193
self.set_volume(volume)
159194

195+
duration_ms = int(duration * 1000)
196+
delay_ms = int(delay * 1000)
197+
160198
if play_type == Sound.PLAY_WAIT_FOR_COMPLETE:
161199
play = self.tone([(frequency, duration_ms, delay_ms)])
162200
play.wait()
@@ -169,9 +207,43 @@ def play_tone(self, frequency, duration_ms, delay_ms=100, volume=100, play_type=
169207
play = self.tone([(frequency, duration_ms, delay_ms)])
170208
play.wait()
171209

172-
def play(self, wav_file, play_type=PLAY_WAIT_FOR_COMPLETE):
210+
def play_note(self, note, duration, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE):
211+
""" Plays a note, given by its name as defined in ``_NOTE_FREQUENCIES``.
212+
213+
Args:
214+
note (str) the note symbol with its octave number
215+
duration (float): tone duration, in seconds
216+
volume (int) the play volume, in percent of maximum volume
217+
play_type (int) the type of play (wait, no wait, loop), as defined
218+
by the ``PLAY_xxx`` constants
219+
220+
Returns:
221+
the PID of the underlying beep command if no wait play type, None otherwise
222+
223+
Raises:
224+
ValueError: is invalid parameter (note, duration,...)
173225
"""
174-
Play wav file.
226+
self._validate_play_type(play_type)
227+
try:
228+
freq = self._NOTE_FREQUENCIES[note.upper()]
229+
except KeyError:
230+
raise ValueError('invalid note (%s)' % note)
231+
if duration <= 0:
232+
raise ValueError('invalid duration (%s)' % duration)
233+
if not 0 < volume <= 100:
234+
raise ValueError('invalid volume (%s)' % volume)
235+
236+
return self.play_tone(freq, duration=duration, volume=volume, play_type=play_type)
237+
238+
def play(self, wav_file, play_type=PLAY_WAIT_FOR_COMPLETE):
239+
""" Play a sound file (wav format).
240+
241+
Args:
242+
wav_file (str): the sound file path
243+
play_type (int): one off Sound.PLAY_xxx play types (wait, no wait, loop)
244+
245+
Returns:
246+
subprocess.Popen: the spawn subprocess when no wait play type is selected, None otherwise
175247
"""
176248
self._validate_play_type(play_type)
177249

@@ -191,18 +263,40 @@ def play(self, wav_file, play_type=PLAY_WAIT_FOR_COMPLETE):
191263
pid.wait()
192264

193265
def play_file(self, wav_file, volume=100, play_type=PLAY_WAIT_FOR_COMPLETE):
266+
""" Play a sound file (wav format) at a given volume.
267+
268+
Args:
269+
wav_file (str): the sound file path
270+
volume (int) the play volume, in percent of maximum volume
271+
play_type (int): one off Sound.PLAY_xxx play types (wait, no wait, loop)
272+
273+
Returns:
274+
subprocess.Popen: the spawn subprocess when no wait play type is selected, None otherwise
275+
"""
194276
self.set_volume(volume)
195277
self.play(wav_file, play_type)
196278

197279
def speak(self, text, espeak_opts='-a 200 -s 130', volume=100, play_type=PLAY_WAIT_FOR_COMPLETE):
198-
"""
199-
Speak the given text aloud.
280+
""" Speak the given text aloud.
281+
282+
Uses the ``espeak`` external command.
283+
284+
Args:
285+
text (str): the text to speak
286+
espeak_opts (str): espeak command options
287+
volume (int) the play volume, in percent of maximum volume
288+
play_type (int): one off Sound.PLAY_xxx play types (wait, no wait, loop)
289+
290+
Returns:
291+
subprocess.Popen: the spawn subprocess when no wait play type is selected, None otherwise
200292
"""
201293
self._validate_play_type(play_type)
202294
self.set_volume(volume)
203295

204296
with open(os.devnull, 'w') as n:
205-
cmd_line = '/usr/bin/espeak --stdout {0} "{1}" | /usr/bin/aplay -q'.format(espeak_opts, text)
297+
cmd_line = '/usr/bin/espeak --stdout {0} "{1}" | /usr/bin/aplay -q'.format(
298+
espeak_opts, text
299+
)
206300

207301
if play_type == Sound.PLAY_WAIT_FOR_COMPLETE:
208302
play = Popen(cmd_line, stdout=n, shell=True)
@@ -218,8 +312,8 @@ def speak(self, text, espeak_opts='-a 200 -s 130', volume=100, play_type=PLAY_WA
218312

219313
def _get_channel(self):
220314
"""
221-
:return: the detected sound channel
222-
:rtype: str
315+
Returns:
316+
str: the detected sound channel
223317
"""
224318
if self.channel is None:
225319
# Get default channel as the first one that pops up in
@@ -229,7 +323,7 @@ def _get_channel(self):
229323
# Simple mixer control 'Master',0
230324
# Simple mixer control 'Capture',0
231325
out = check_output(['amixer', 'scontrols']).decode()
232-
m = re.search("'(?P<channel>[^']+)'", out)
326+
m = re.search(r"'(?P<channel>[^']+)'", out)
233327
if m:
234328
self.channel = m.group('channel')
235329
else:
@@ -265,13 +359,13 @@ def get_volume(self, channel=None):
265359
channel = self._get_channel()
266360

267361
out = check_output(['amixer', 'get', channel]).decode()
268-
m = re.search('\[(?P<volume>\d+)%\]', out)
362+
m = re.search(r'\[(?P<volume>\d+)%\]', out)
269363
if m:
270364
return int(m.group('volume'))
271365
else:
272366
raise Exception('Failed to parse output of `amixer get {}`'.format(channel))
273367

274-
def play_song(self, song, tempo=120, delay=50):
368+
def play_song(self, song, tempo=120, delay=0.05):
275369
""" Plays a song provided as a list of tuples containing the note name and its
276370
value using music conventional notation instead of numerical values for frequency
277371
and duration.
@@ -329,12 +423,21 @@ def play_song(self, song, tempo=120, delay=50):
329423
Args:
330424
song (iterable[tuple(str, str)]): the song
331425
tempo (int): the song tempo, given in quarters per minute
332-
delay (int): delay in ms between notes
426+
delay (float): delay between notes (in seconds)
333427
334428
Returns:
335429
subprocess.Popen: the spawn subprocess
430+
431+
Raises:
432+
ValueError: if invalid note in song or invalid play parameters
336433
"""
337-
meas_duration = 60000 / tempo * 4
434+
if tempo <= 0:
435+
raise ValueError('invalid tempo (%s)' % tempo)
436+
if delay < 0:
437+
raise ValueError('invalid delay (%s)' % delay)
438+
439+
delay_ms = int(delay * 1000)
440+
meas_duration_ms = 60000 / tempo * 4 # we only support 4/4 bars, hence "* 4"
338441

339442
def beep_args(note, value):
340443
""" Builds the arguments string for producing a beep matching
@@ -349,24 +452,27 @@ def beep_args(note, value):
349452
freq = self._NOTE_FREQUENCIES[note.upper()]
350453
if '/' in value:
351454
base, factor = value.split('/')
352-
duration = meas_duration * self._NOTE_VALUES[base] / float(factor)
455+
duration_ms = meas_duration_ms * self._NOTE_VALUES[base] / float(factor)
353456
elif '*' in value:
354457
base, factor = value.split('*')
355-
duration = meas_duration * self._NOTE_VALUES[base] * float(factor)
458+
duration_ms = meas_duration_ms * self._NOTE_VALUES[base] * float(factor)
356459
elif value.endswith('.'):
357460
base = value[:-1]
358-
duration = meas_duration * self._NOTE_VALUES[base] * 1.5
461+
duration_ms = meas_duration_ms * self._NOTE_VALUES[base] * 1.5
359462
elif value.endswith('3'):
360463
base = value[:-1]
361-
duration = meas_duration * self._NOTE_VALUES[base] * 2 / 3
464+
duration_ms = meas_duration_ms * self._NOTE_VALUES[base] * 2 / 3
362465
else:
363-
duration = meas_duration * self._NOTE_VALUES[value]
466+
duration_ms = meas_duration_ms * self._NOTE_VALUES[value]
364467

365-
return '-f %d -l %d -D %d' % (freq, duration, delay)
468+
return '-f %d -l %d -D %d' % (freq, duration_ms, delay_ms)
366469

367-
return self.beep(' -n '.join(
368-
[beep_args(note, value) for (note, value) in song]
369-
))
470+
try:
471+
return self.beep(' -n '.join(
472+
[beep_args(note, value) for (note, value) in song]
473+
))
474+
except KeyError as e:
475+
raise ValueError('invalid note (%s)' % e)
370476

371477
#: Note frequencies.
372478
#:

0 commit comments

Comments
 (0)