3
from __future__ import division, print_function
12
#Trick to prevent gst from hijacking argv parsing
18
from gobject import GError
20
print("Can't import module: %s. it may not be available for this"
21
"version of Python, which is: " % sys.exc_info()[1], file=sys.stderr)
22
print((sys.version), file=sys.stderr)
27
#Frequency bands for FFT
29
#How often to take a sample and do FFT on it.
30
FFT_INTERVAL = 100000000 # In nanoseconds, so this is every 1/10th second
31
#Sampling frequency. The effective maximum frequency we can analyze is
32
#half of this (see Nyquist's theorem)
33
SAMPLING_FREQUENCY = 44100
34
#The default test frequency should be in the middle of the frequency band
35
#that delimits the first and second thirds of the frequency range.
36
#That gives a not-so-ear-piercing tone and should ensure there's no
37
#spillout to neighboring frequency bands.
38
DEFAULT_TEST_FREQUENCY = (SAMPLING_FREQUENCY / (2 * BINS)) * int(BINS / 3) - \
39
(SAMPLING_FREQUENCY / (2 * BINS)) / 2
40
#only sample a signal when peak level is in this range (in dB attenuation,
41
#0 means no attenuation (and horrible clipping).
42
REC_LEVEL_RANGE = (-2.0, -12.0)
43
#For our test signal to be considered present, it has to be this much higher
44
#than the average of the rest of the frequencies (to ensure we have a nice,
45
#clear peak). This is in dB.
46
MAGNITUDE_THRESHOLD = -5.0
49
class PIDController(object):
50
""" A Proportional-Integrative-Derivative controller (PID) controls a
51
process's output to try to maintain a desired output value (known as
52
'setpoint', by continually adjusting the process's input.
54
It does so by calculating the "error" (difference between output and
55
setpoint) and attempting to minimize it manipulating the input.
57
The desired change to the input is calculated based on error and three
58
constants (Kp, Ki and Kd). These values can be interpreted in terms of
59
time: P depends on the present error, I on the accumulation of past errors,
60
and D is a prediction of future errors, based on current rate of change.
61
The weighted sum of these three actions is used to adjust the process via a
64
In practice, Kp, Ki and Kd are process-dependent and usually have to
65
be tweaked by hand, but once reasonable constants are arrived at, they
66
can apply to a particular process without further modification.
69
def __init__(self, Kp, Ki, Kd, setpoint=0):
70
""" Creates a PID controller with given constants and setpoint.
73
Kp, Ki, Kd: PID constants, see class description.
74
setpoint: desired output value; calls to input_change with
75
a process output reading will return a desired change
76
to the input to attempt matching output to this value.
78
self.setpoint = setpoint
83
self._previous_error = 0
84
self._change_limit = 0
86
def input_change(self, process_feedback, dt):
87
""" Calculates desired input value change.
89
Based on process feedback and time interval (dt).
91
error = self.setpoint - process_feedback
92
self._integral = self._integral + (error * dt)
93
derivative = (error - self._previous_error) / dt
94
self._previous_error = error
95
input_change = (self.Kp * error) + \
96
(self.Ki * self._integral) + \
97
(self.Kd * derivative)
98
if self._change_limit and abs(input_change) > abs(self._change_limit):
99
sign = input_change / abs(input_change)
100
input_change = sign * self._change_limit
103
def set_change_limit(self, limit):
104
"""Ensures that input value changes are lower than limit.
106
Setting limit of zero disables this.
108
self._change_limit = limit
111
class PAVolumeController(object):
112
pa_types = {'input': 'source', 'output': 'sink'}
114
def __init__(self, type, method=None, logger=None):
115
"""Initializes the volume controller.
118
type: either input or output
119
method: a method that will run a command and return pulseaudio
120
information in the described format, as a single string with
121
line breaks (to be processed with str.splitlines())
126
self.identifier = None
128
if not isinstance(method, collections.Callable):
129
self.method = self._pactl_output
132
def set_volume(self, volume):
133
if not 0 <= volume <= 100:
135
if not self.identifier:
138
'set-%s-volume' % (self.pa_types[self.type]),
139
str(self.identifier[0]),
140
str(int(volume)) + "%"]
141
if False == self.method(command):
143
self._volume = volume
146
def get_volume(self):
147
if not self.identifier:
151
def mute(self, mute):
152
mute = str(int(mute))
153
if not self.identifier:
156
'set-%s-mute' % (self.pa_types[self.type]),
157
str(self.identifier[0]),
159
if False == self.method(command):
163
def get_identifier(self):
165
self.identifier = self._get_identifier_for(self.type)
166
if self.identifier and self.logger:
167
message = "Using PulseAudio identifier %s (%s) for %s" %\
168
(self.identifier + (self.type,))
169
self.logger.info(message)
170
return self.identifier
172
def _get_identifier_for(self, type):
173
"""Gets default PulseAudio identifier for given type.
176
type: either input or output
179
A tuple: (pa_id, pa_description)
183
if type not in self.pa_types:
185
command = ['pactl', 'list', self.pa_types[type] + "s", 'short']
187
#Expect lines of this form (field separator is tab):
188
#<ID>\t<NAME>\t<MODULE>\t<SAMPLE_SPEC_WITH_SPACES>\t<STATE>
189
#What we need to return is the ID for the first element on this list
190
#that does not contain auto_null or monitor.
191
pa_info = self.method(command)
192
valid_elements = None
195
reject_regex = '.*(monitor|auto_null).*'
196
valid_elements = [element for element in pa_info.splitlines() \
197
if not re.match(reject_regex, element)]
198
if not valid_elements:
200
self.logger.error("No valid PulseAudio elements"
201
" for %s" % (self.type))
203
#We only need the pulseaudio numeric ID and long name for each element
204
valid_elements = [(int(element.split()[0]), element.split()[1]) \
205
for e in valid_elements]
206
return valid_elements[0]
208
def _pactl_output(self, command):
209
#This method mainly calls pactl (hence the name). Since pactl may
210
#return a failure if the audio layer is not yet initialized, we will
211
#try running a few times in case of failure. All our invocations of
212
#pactl should be "idempotent" so repeating them should not have
214
for attempt in range(0, 3):
216
return subprocess.check_output(command,
217
universal_newlines=True)
218
except (subprocess.CalledProcessError):
223
class FileDumper(object):
224
def write_to_file(self, filename, data):
226
with open(filename, "wb") as f:
230
except (TypeError, IOError):
235
class SpectrumAnalyzer(object):
236
def __init__(self, points, sampling_frequency=44100,
238
self.spectrum = [0] * points
239
self.number_of_samples = 0
240
self.wanted_samples = wanted_samples
241
self.sampling_frequency = sampling_frequency
242
#Frequencies should contain *real* frequency which is half of
243
#the sampling frequency
244
self.frequencies = [((sampling_frequency / 2.0) / points) * i
245
for i in range(points)]
248
return sum(self.spectrum) / len(self.spectrum)
250
def sample(self, sample):
251
if len(sample) != len(self.spectrum):
253
self.spectrum = [((old * self.number_of_samples) + new) /
254
(self.number_of_samples + 1)
255
for old, new in zip(self.spectrum, sample)]
256
self.number_of_samples += 1
258
def frequencies_over_average(self, threshold=0.0):
259
return [i for i in range(len(self.spectrum)) \
260
if self.spectrum[i] >= self._average() - threshold]
262
def frequency_band_for(self, frequency):
263
"""Convenience function to tell me which band
264
a frequency is contained in
266
#Note that actual frequencies are half of what the sampling
267
#frequency would tell us. If SF is 44100 then maximum actual
268
#frequency is 22050, and if I have 10 frequency bins each will
269
#contain only 2205 Hz, not 4410 Hz.
270
max_frequency = self.sampling_frequency / 2
271
if frequency > max_frequency or frequency < 0:
273
band = float(frequency) / (max_frequency / len(self.spectrum))
274
return int(math.ceil(band)) - 1
276
def frequencies_for_band(self, band):
277
"""Convenience function to tell me the delimiting frequencies
280
if band >= len(self.spectrum) or band < 0:
282
lower = self.frequencies[band]
283
upper = lower + ((self.sampling_frequency / 2.0) / len(self.spectrum))
284
return (lower, upper)
286
def sampling_complete(self):
287
return self.number_of_samples >= self.wanted_samples
290
class GStreamerRawAudioRecorder(object):
292
self.raw_buffers = []
294
def buffer_handler(self, sink):
295
buffer = sink.emit('pull-buffer')
296
self.raw_buffers.append(buffer.data)
298
def get_raw_audio(self):
299
return ''.join(self.raw_buffers)
302
class GStreamerMessageHandler(object):
303
def __init__(self, rec_level_range, logger, volumecontroller,
304
pidcontroller, spectrum_analyzer):
305
"""Initializes the message handler. It knows how to handle
306
spectrum and level gstreamer messages.
309
rec_level_range: tuple with acceptable recording level
311
logger: logging object with debug, info, error methods.
312
volumecontroller: an instance of VolumeController to use
313
to adjust RECORDING level
314
pidcontroller: a PID controller instance which helps control
316
spectrum_analyzer: instance of SpectrumAnalyzer to collect
317
data from spectrum messages
320
self.current_level = sys.maxsize
322
self.pid_controller = pidcontroller
323
self.rec_level_range = rec_level_range
324
self.spectrum_analyzer = spectrum_analyzer
325
self.volume_controller = volumecontroller
327
def set_quit_method(self, method):
328
""" Method that will be called when sampling is complete."""
329
self._quit_method = method
331
def bus_message_handler(self, bus, message):
332
if message.type == gst.MESSAGE_ELEMENT:
333
message_name = message.structure.get_name()
334
if message_name == 'spectrum':
335
fft_magnitudes = message.structure['magnitude']
336
self.spectrum_method(self.spectrum_analyzer, fft_magnitudes)
338
if message_name == 'level':
339
#peak_value is our process feedback
340
peak_value = message.structure['peak'][0]
341
self.level_method(peak_value, self.pid_controller,
342
self.volume_controller)
344
#Adjust recording level
345
def level_method(self, level, pid_controller, volume_controller):
346
#If volume controller doesn't return a valid volume,
347
#we can't control it :(
348
current_volume = volume_controller.get_volume()
349
if current_volume == None:
350
self.logger.error("Unable to control recording volume."
351
"Test results may be wrong")
353
self.current_level = level
354
change = pid_controller.input_change(level, 0.10)
356
self.logger.debug("Peak level: %(peak_level).2f, "
357
"volume: %(volume)d%%, Volume change: %(change)f%%" %
358
{'peak_level': level,
360
'volume': current_volume})
361
volume_controller.set_volume(current_volume + change)
363
#Only sample if level is within the threshold
364
def spectrum_method(self, analyzer, spectrum):
365
if self.rec_level_range[1] <= self.current_level \
366
or self.current_level <= self.rec_level_range[0]:
367
self.logger.debug("Sampling, recorded %d samples" %
368
analyzer.number_of_samples)
369
analyzer.sample(spectrum)
370
if analyzer.sampling_complete() and self._quit_method:
371
self.logger.info("Sampling complete, ending process")
375
class GstAudioObject(object):
377
self.class_name = self.__class__.__name__
379
def _set_state(self, state, description):
380
self.pipeline.set_state(state)
381
message = "%s: %s" % (self.class_name, description)
383
self.logger.info(message)
386
self._set_state(gst.STATE_PLAYING, "Starting")
389
self._set_state(gst.STATE_NULL, "Stopping")
392
class Player(GstAudioObject):
393
def __init__(self, frequency=DEFAULT_TEST_FREQUENCY, logger=None):
394
super(Player, self).__init__()
395
self.pipeline_description = ("audiotestsrc wave=sine freq=%s "
398
"! autoaudiosink" % int(frequency))
401
self.logger.debug(self.pipeline_description)
402
self.pipeline = gst.parse_launch(self.pipeline_description)
405
class Recorder(GstAudioObject):
406
def __init__(self, bins=BINS, sampling_frequency=SAMPLING_FREQUENCY,
407
fft_interval=FFT_INTERVAL, logger=None):
408
super(Recorder, self).__init__()
409
pipeline_description = ('''autoaudiosrc
413
! audio/x-raw-int, channels=1, rate=%(rate)s
415
! spectrum interval=%(fft_interval)s bands = %(bands)s
417
! appsink name=recordersink emit-signals=true''' %
419
'rate': sampling_frequency,
420
'fft_interval': fft_interval})
423
self.logger.debug(pipeline_description)
424
self.pipeline = gst.parse_launch(pipeline_description)
426
def register_message_handler(self, handler_method):
428
message = "Registering message handler: %s" % handler_method
429
self.logger.debug(message)
430
self.bus = self.pipeline.get_bus()
431
self.bus.add_signal_watch()
432
self.bus.connect('message', handler_method)
434
def register_buffer_handler(self, handler_method):
436
message = "Registering buffer handler: %s" % handler_method
437
self.logger.debug(message)
438
self.sink = self.pipeline.get_by_name('recordersink')
439
self.sink.connect('new-buffer', handler_method)
442
def process_arguments():
444
Plays a single frequency through the default output, then records on
445
the default input device. Analyzes the recorded signal to test for
446
presence of the played frequency, if present it exits with success.
448
parser = argparse.ArgumentParser(description=description)
449
parser.add_argument("-t", "--time",
450
dest='test_duration',
454
help="""Maximum test duration, default %(default)s seconds.
455
It may exit sooner if it determines it has enough data.""")
456
parser.add_argument("-a", "--audio",
460
help="File to save recorded audio in .wav format")
461
parser.add_argument("-q", "--quiet",
464
help="Be quiet, no output unless there's an error.")
465
parser.add_argument("-d", "--debug",
468
help="Debugging output")
469
parser.add_argument("-f", "--frequency",
471
default=DEFAULT_TEST_FREQUENCY,
473
help="Frequency for test signal, default %(default)s Hz")
474
parser.add_argument("-u", "--spectrum",
477
help="""File to save spectrum information for plotting
478
(one frequency/magnitude pair per line)""")
479
return parser.parse_args()
485
args = process_arguments()
490
level = logging.DEBUG
492
level = logging.ERROR
493
logging.basicConfig(level=level)
495
#Launches recording pipeline. I need to hook up into the gst
497
recorder = Recorder(logger=logging)
498
#Just launches the playing pipeline
499
player = Player(frequency=args.frequency, logger=logging)
501
logging.critical("Unable to initialize GStreamer pipelines")
504
#This just receives a process feedback and tells me how much to change to
505
#achieve the setpoint
506
pidctrl = PIDController(Kp=0.7, Ki=.01, Kd=0.01,
507
setpoint=REC_LEVEL_RANGE[0])
508
pidctrl.set_change_limit(5)
509
#This gathers spectrum data.
510
analyzer = SpectrumAnalyzer(points=BINS,
511
sampling_frequency=SAMPLING_FREQUENCY)
512
#this receives 'buffer' messages and gathers raw audio data
513
rawaudio = GStreamerRawAudioRecorder()
515
#Volume controllers actually set volumes for their device types.
516
#we should at least issue a warning
517
recorder.volumecontroller = PAVolumeController(type='input',
519
if not recorder.volumecontroller.get_identifier():
520
logging.warning("Unable to get input volume control identifier. "
521
"Test results will probably be invalid")
522
recorder.volumecontroller.set_volume(0)
523
recorder.volumecontroller.mute(False)
525
player.volumecontroller = PAVolumeController(type='output',
527
if not player.volumecontroller.get_identifier():
528
logging.warning("Unable to get output volume control identifier. "
529
"Test results will probably be invalid")
530
player.volumecontroller.set_volume(70)
531
player.volumecontroller.mute(False)
533
#This handles the messages from gstreamer and orchestrates
534
#the passed volume controllers, pid controller and spectrum analyzer
536
gmh = GStreamerMessageHandler(rec_level_range=REC_LEVEL_RANGE,
538
volumecontroller=recorder.volumecontroller,
539
pidcontroller=pidctrl,
540
spectrum_analyzer=analyzer)
542
#I need to tell the recorder which method will handle messages.
543
recorder.register_message_handler(gmh.bus_message_handler)
544
recorder.register_buffer_handler(rawaudio.buffer_handler)
546
#Create the loop and add a few triggers
547
gobject.threads_init()
548
loop = gobject.MainLoop()
549
gobject.timeout_add_seconds(0, player.start)
550
gobject.timeout_add_seconds(0, recorder.start)
551
gobject.timeout_add_seconds(args.test_duration, loop.quit)
553
# Tell the gmh which method to call when enough samples are collected
554
gmh.set_quit_method(loop.quit)
558
#When the loop ends, set things back to reasonable states
561
player.volumecontroller.set_volume(50)
562
recorder.volumecontroller.set_volume(10)
564
#See if data gathering was successful.
565
test_band = analyzer.frequency_band_for(args.frequency)
566
candidate_bands = analyzer.frequencies_over_average(MAGNITUDE_THRESHOLD)
567
if test_band in candidate_bands:
568
freqs_for_band = analyzer.frequencies_for_band(test_band)
569
logging.info("PASS: Test frequency of %s in band (%.2f, %.2f) "
570
"which had a magnitude higher than the average" %
571
((args.frequency,) + freqs_for_band))
574
logging.info("FAIL: Test frequency of %s is not in one of the "
575
"bands with higher-than-average magnitude" % args.frequency)
577
#Is the microphone broken?
578
if len(set(analyzer.spectrum)) <= 1:
579
logging.info("WARNING: Microphone seems broken, didn't even "
580
"record ambient noise")
582
#Write some files to disk for later analysis
584
logging.info("Saving recorded audio as %s" % args.audio)
585
if not FileDumper().write_to_file(args.audio,
586
[rawaudio.get_raw_audio()]):
587
logging.error("Couldn't save recorded audio", file=sys.stderr)
590
logging.info("Saving spectrum data for plotting as %s" %
592
if not FileDumper().write_to_file(args.spectrum,
593
["%s,%s" % t for t in
594
zip(analyzer.frequencies,
595
analyzer.spectrum)]):
596
logging.error("Couldn't save spectrum data for plotting",
601
if __name__ == "__main__":