1# Copyright 2014 The Chromium OS Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5"""This module provides the audio widgets used in audio tests."""
6
7import abc
8import copy
9import logging
10import tempfile
11
12from autotest_lib.client.cros.audio import audio_data
13from autotest_lib.client.cros.audio import audio_test_data
14from autotest_lib.client.cros.audio import sox_utils
15from autotest_lib.client.cros.chameleon import chameleon_audio_ids as ids
16from autotest_lib.client.cros.chameleon import chameleon_port_finder
17
18
19class AudioWidget(object):
20    """
21    This class abstracts an audio widget in audio test framework. A widget
22    is identified by its audio port. The handler passed in at __init__ will
23    handle action on the audio widget.
24
25    Properties:
26        audio_port: The AudioPort this AudioWidget resides in.
27        handler: The handler that handles audio action on the widget. It is
28                  actually a (Chameleon/Cros)(Input/Output)WidgetHandler object.
29
30    """
31    def __init__(self, audio_port, handler):
32        """Initializes an AudioWidget on a AudioPort.
33
34        @param audio_port: An AudioPort object.
35        @param handler: A WidgetHandler object which handles action on the widget.
36
37        """
38        self.audio_port = audio_port
39        self.handler = handler
40
41
42    @property
43    def port_id(self):
44        """Port id of this audio widget.
45
46        @returns: A string. The port id defined in chameleon_audio_ids for this
47                  audio widget.
48        """
49        return self.audio_port.port_id
50
51
52class AudioInputWidget(AudioWidget):
53    """
54    This class abstracts an audio input widget. This class provides the audio
55    action that is available on an input audio port.
56
57    Properties:
58        _remote_rec_path: The path to the recorded file on the remote host.
59        _rec_binary: The recorded binary data.
60        _rec_format: The recorded data format. A dict containing
61                     file_type: 'raw' or 'wav'.
62                     sample_format: 'S32_LE' for 32-bit signed integer in
63                                    little-endian. Refer to aplay manpage for
64                                    other formats.
65                     channel: channel number.
66                     rate: sampling rate.
67
68        _channel_map: A list containing current channel map. Checks docstring
69                      of channel_map method for details.
70
71    """
72    def __init__(self, *args, **kwargs):
73        """Initializes an AudioInputWidget."""
74        super(AudioInputWidget, self).__init__(*args, **kwargs)
75        self._remote_rec_path = None
76        self._rec_binary = None
77        self._rec_format = None
78        self._channel_map = None
79        self._init_channel_map_without_link()
80
81
82    def start_recording(self):
83        """Starts recording."""
84        self._remote_rec_path = None
85        self._rec_binary = None
86        self._rec_format = None
87        self.handler.start_recording()
88
89
90    def stop_recording(self):
91        """Stops recording."""
92        self._remote_rec_path, self._rec_format = self.handler.stop_recording()
93
94
95    def read_recorded_binary(self):
96        """Gets recorded file from handler and fills _rec_binary."""
97        self._rec_binary = self.handler.get_recorded_binary(
98                self._remote_rec_path, self._rec_format)
99
100
101    def save_file(self, file_path):
102        """Saves recorded data to a file.
103
104        @param file_path: The path to save the file.
105
106        """
107        with open(file_path, 'wb') as f:
108            logging.debug('Saving recorded raw file to %s', file_path)
109            f.write(self._rec_binary)
110
111        wav_file_path = file_path + '.wav'
112        logging.debug('Saving recorded wav file to %s', wav_file_path)
113        sox_utils.convert_raw_file(
114                path_src=file_path,
115                channels_src=self._channel,
116                rate_src=self._sampling_rate,
117                bits_src=self._sample_size_bits,
118                path_dst=wav_file_path)
119
120
121    def get_binary(self):
122        """Gets recorded binary data.
123
124        @returns: The recorded binary data.
125
126        """
127        return self._rec_binary
128
129
130    @property
131    def data_format(self):
132        """The recorded data format.
133
134        @returns: The recorded data format.
135
136        """
137        return self._rec_format
138
139
140    @property
141    def channel_map(self):
142        """The recorded data channel map.
143
144        @returns: The recorded channel map. A list containing channel mapping.
145                  E.g. [1, 0, None, None, None, None, None, None] means
146                  channel 0 of recorded data should be mapped to channel 1 of
147                  data played to the recorder. Channel 1 of recorded data should
148                  be mapped to channel 0 of data played to recorder.
149                  Channel 2 to 7 of recorded data should be ignored.
150
151        """
152        return self._channel_map
153
154
155    @channel_map.setter
156    def channel_map(self, new_channel_map):
157        """Sets channel map.
158
159        @param new_channel_map: A list containing new channel map.
160
161        """
162        self._channel_map = copy.deepcopy(new_channel_map)
163
164
165    def _init_channel_map_without_link(self):
166        """Initializes channel map without WidgetLink.
167
168        WidgetLink sets channel map to a sink widget when the link combines
169        a source widget to a sink widget. For simple cases like internal
170        microphone on Cros device, or Mic port on Chameleon, the audio signal
171        is over the air, so we do not use link to combine the source to
172        the sink. We just set a default channel map in this case.
173
174        """
175        if self.port_id in [ids.ChameleonIds.MIC, ids.CrosIds.INTERNAL_MIC]:
176            self._channel_map = [0]
177
178
179    @property
180    def _sample_size_bytes(self):
181        """Gets sample size in bytes of recorded data."""
182        return audio_data.SAMPLE_FORMATS[
183                self._rec_format['sample_format']]['size_bytes']
184
185
186    @property
187    def _sample_size_bits(self):
188        """Gets sample size in bits of recorded data."""
189        return self._sample_size_bytes * 8
190
191
192    @property
193    def _channel(self):
194        """Gets number of channels of recorded data."""
195        return self._rec_format['channel']
196
197
198    @property
199    def _sampling_rate(self):
200        """Gets sampling rate of recorded data."""
201        return self._rec_format['rate']
202
203
204    def remove_head(self, duration_secs):
205        """Removes a duration of recorded data from head.
206
207        @param duration_secs: The duration in seconds to be removed from head.
208
209        """
210        offset = int(self._sampling_rate * duration_secs *
211                     self._sample_size_bytes * self._channel)
212        self._rec_binary = self._rec_binary[offset:]
213
214
215    def lowpass_filter(self, frequency):
216        """Passes the recorded data to a lowpass filter.
217
218        @param frequency: The 3dB frequency of lowpass filter.
219
220        """
221        with tempfile.NamedTemporaryFile(
222                prefix='original_') as original_file:
223            with tempfile.NamedTemporaryFile(
224                    prefix='filtered_') as filtered_file:
225
226                original_file.write(self._rec_binary)
227                original_file.flush()
228
229                sox_utils.lowpass_filter(
230                        original_file.name, self._channel,
231                        self._sample_size_bits, self._sampling_rate,
232                        filtered_file.name, frequency)
233
234                self._rec_binary = filtered_file.read()
235
236
237class AudioOutputWidget(AudioWidget):
238    """
239    This class abstracts an audio output widget. This class provides the audio
240    action that is available on an output audio port.
241
242    """
243    def __init__(self, *args, **kwargs):
244        """Initializes an AudioOutputWidget."""
245        super(AudioOutputWidget, self).__init__(*args, **kwargs)
246        self._remote_playback_path = None
247
248
249    def set_playback_data(self, test_data):
250        """Sets data to play.
251
252        Sets the data to play in the handler and gets the remote file path.
253
254        @param test_data: An AudioTestData object.
255
256        """
257        self._remote_playback_path = self.handler.set_playback_data(test_data)
258
259
260    def start_playback(self, blocking=False):
261        """Starts playing audio specified in previous set_playback_data call.
262
263        @param blocking: Blocks this call until playback finishes.
264
265        """
266        self.handler.start_playback(self._remote_playback_path, blocking)
267
268
269    def stop_playback(self):
270        """Stops playing audio."""
271        self.handler.stop_playback()
272
273
274class WidgetHandler(object):
275    """This class abstracts handler for basic actions on widget."""
276    __metaclass__ = abc.ABCMeta
277
278    @abc.abstractmethod
279    def plug(self):
280        """Plug this widget."""
281        pass
282
283
284    @abc.abstractmethod
285    def unplug(self):
286        """Unplug this widget."""
287        pass
288
289
290class ChameleonWidgetHandler(WidgetHandler):
291    """
292    This class abstracts a Chameleon audio widget handler.
293
294    Properties:
295        interface: A string that represents the interface name on
296                   Chameleon, e.g. 'HDMI', 'LineIn', 'LineOut'.
297        scale: The scale is the scaling factor to be applied on the data of the
298               widget before playing or after recording.
299        _chameleon_board: A ChameleonBoard object to control Chameleon.
300        _port: A ChameleonPort object to control port on Chameleon.
301
302    """
303    # The mic port on chameleon has a small gain. We need to scale
304    # the recorded value up, otherwise, the recorded value will be
305    # too small and will be falsely judged as not meaningful in the
306    # processing, even when the recorded audio is clear.
307    _DEFAULT_MIC_SCALE = 50.0
308
309    def __init__(self, chameleon_board, interface):
310        """Initializes a ChameleonWidgetHandler.
311
312        @param chameleon_board: A ChameleonBoard object.
313        @param interface: A string that represents the interface name on
314                          Chameleon, e.g. 'HDMI', 'LineIn', 'LineOut'.
315
316        """
317        self.interface = interface
318        self._chameleon_board = chameleon_board
319        self._port = self._find_port(interface)
320        self.scale = None
321        self._init_scale_without_link()
322
323
324    @abc.abstractmethod
325    def _find_port(self, interface):
326        """Finds the port by interface."""
327        pass
328
329
330    def plug(self):
331        """Plugs this widget."""
332        self._port.plug()
333
334
335    def unplug(self):
336        """Unplugs this widget."""
337        self._port.unplug()
338
339
340    def _init_scale_without_link(self):
341        """Initializes scale for widget handler not used with link.
342
343        Audio widget link sets scale when it connects two audio widgets.
344        For audio widget not used with link, e.g. Mic on Chameleon, we set
345        a default scale here.
346
347        """
348        if self.interface == 'Mic':
349            self.scale = self._DEFAULT_MIC_SCALE
350
351
352class ChameleonInputWidgetHandler(ChameleonWidgetHandler):
353    """
354    This class abstracts a Chameleon audio input widget handler.
355
356    """
357    def start_recording(self):
358        """Starts recording."""
359        self._port.start_capturing_audio()
360
361
362    def stop_recording(self):
363        """Stops recording.
364
365        Gets remote recorded path and format from Chameleon. The format can
366        then be used in get_recorded_binary()
367
368        @returns: A tuple (remote_path, data_format) for recorded data.
369                  Refer to stop_capturing_audio call of ChameleonAudioInput.
370
371        """
372        return self._port.stop_capturing_audio()
373
374
375    def get_recorded_binary(self, remote_path, record_format):
376        """Gets remote recorded file binary.
377
378        Reads file from Chameleon host and handles scale if needed.
379
380        @param remote_path: The path to the recorded file on Chameleon.
381        @param record_format: The recorded data format. A dict containing
382                     file_type: 'raw' or 'wav'.
383                     sample_format: 'S32_LE' for 32-bit signed integer in
384                                    little-endian. Refer to aplay manpage for
385                                    other formats.
386                     channel: channel number.
387                     rate: sampling rate.
388
389        @returns: The recorded binary.
390
391        """
392        with tempfile.NamedTemporaryFile(prefix='recorded_') as f:
393            self._chameleon_board.host.get_file(remote_path, f.name)
394
395            # Handles scaling using audio_test_data.
396            test_data = audio_test_data.AudioTestData(record_format, f.name)
397            converted_test_data = test_data.convert(record_format, self.scale)
398            try:
399                return converted_test_data.get_binary()
400            finally:
401                converted_test_data.delete()
402
403
404    def _find_port(self, interface):
405        """Finds a Chameleon audio port by interface(port name).
406
407        @param interface: string, the interface. e.g: HDMI.
408
409        @returns: A ChameleonPort object.
410
411        @raises: ValueError if port is not connected.
412
413        """
414        finder = chameleon_port_finder.ChameleonAudioInputFinder(
415                self._chameleon_board)
416        chameleon_port = finder.find_port(interface)
417        if not chameleon_port:
418            raise ValueError(
419                    'Port %s is not connected to Chameleon' % interface)
420        return chameleon_port
421
422
423class ChameleonOutputWidgetHandler(ChameleonWidgetHandler):
424    """
425    This class abstracts a Chameleon audio output widget handler.
426
427    """
428    def __init__(self, *args, **kwargs):
429        """Initializes an ChameleonOutputWidgetHandler."""
430        super(ChameleonOutputWidgetHandler, self).__init__(*args, **kwargs)
431        self._test_data_for_chameleon_format = None
432
433
434    def set_playback_data(self, test_data):
435        """Sets data to play.
436
437        Handles scale if needed. Creates a path and sends the scaled data to
438        Chameleon at that path.
439
440        @param test_data: An AudioTestData object.
441
442        @return: The remote data path on Chameleon.
443
444        """
445        self._test_data_for_chameleon_format = test_data.data_format
446        return self._scale_and_send_playback_data(test_data)
447
448
449    def _scale_and_send_playback_data(self, test_data):
450        """Sets data to play on Chameleon.
451
452        Creates a path and sends the scaled test data to Chameleon at that path.
453
454        @param test_data: An AudioTestData object.
455
456        @return: The remote data path on Chameleon.
457
458        """
459        test_data_for_chameleon = test_data.convert(
460                self._test_data_for_chameleon_format, self.scale)
461
462        try:
463            with tempfile.NamedTemporaryFile(prefix='audio_') as f:
464                self._chameleon_board.host.send_file(
465                        test_data_for_chameleon.path, f.name)
466            return f.name
467        finally:
468            test_data_for_chameleon.delete()
469
470
471    def start_playback(self, path, blocking=False):
472        """Starts playback.
473
474        @param path: The path to the file to play on Chameleon.
475        @param blocking: Blocks this call until playback finishes.
476
477        """
478        if blocking:
479            raise NotImplementedError(
480                    'Blocking playback on chameleon is not supported')
481
482        self._port.start_playing_audio(
483                path, self._test_data_for_chameleon_format)
484
485
486    def stop_playback(self):
487        """Stops playback."""
488        self._port.stop_playing_audio()
489
490
491    def _find_port(self, interface):
492        """Finds a Chameleon audio port by interface(port name).
493
494        @param interface: string, the interface. e.g: LineOut.
495
496        @returns: A ChameleonPort object.
497
498        @raises: ValueError if port is not connected.
499
500        """
501        finder = chameleon_port_finder.ChameleonAudioOutputFinder(
502                self._chameleon_board)
503        chameleon_port = finder.find_port(interface)
504        if not chameleon_port:
505            raise ValueError(
506                    'Port %s is not connected to Chameleon' % interface)
507        return chameleon_port
508
509
510class ChameleonLineOutOutputWidgetHandler(ChameleonOutputWidgetHandler):
511    """
512    This class abstracts a Chameleon usb audio output widget handler.
513
514    """
515
516    _DEFAULT_DATA_FORMAT = dict(file_type='raw',
517                                sample_format='S32_LE',
518                                channel=8,
519                                rate=48000)
520
521    def set_playback_data(self, test_data):
522        """Sets data to play.
523
524        Handles scale if needed. Creates a path and sends the scaled data to
525        Chameleon at that path.
526
527        @param test_data: An AudioTestData object.
528
529        @return: The remote data path on Chameleon.
530
531        """
532        self._test_data_for_chameleon_format = self._DEFAULT_DATA_FORMAT
533        return self._scale_and_send_playback_data(test_data)
534
535
536
537class CrosWidgetHandler(WidgetHandler):
538    """
539    This class abstracts a Cros device audio widget handler.
540
541    Properties:
542        _audio_facade: An AudioFacadeRemoteAdapter to access Cros device
543                       audio functionality.
544        _plug_handler: A PlugHandler for performing plug and unplug.
545
546    """
547    def __init__(self, audio_facade, plug_handler):
548        """Initializes a CrosWidgetHandler.
549
550        @param audio_facade: An AudioFacadeRemoteAdapter to access Cros device
551                             audio functionality.
552        @param plug_handler: A PlugHandler object for plug and unplug.
553
554        """
555        self._audio_facade = audio_facade
556        self._plug_handler = plug_handler
557
558
559    def plug(self):
560        """Plugs this widget."""
561        logging.info('CrosWidgetHandler: plug')
562        self._plug_handler.plug()
563
564
565    def unplug(self):
566        """Unplugs this widget."""
567        logging.info('CrosWidgetHandler: unplug')
568        self._plug_handler.unplug()
569
570
571class PlugHandler(object):
572    """This class abstracts plug/unplug action for widgets on Cros device.
573
574    This class will be used by CrosWidgetHandler when performinng plug/unplug.
575
576    """
577    def __init__(self):
578        """Initializes a PlugHandler."""
579
580
581    def plug(self):
582        """Plugs in the widget/device."""
583        raise NotImplementedError('plug() not implemented.')
584
585
586    def unplug(self):
587        """Unplugs the widget/device."""
588        raise NotImplementedError('unplug() not implemented.')
589
590
591class DummyPlugHandler(PlugHandler):
592    """A dummy class that does not do anything for plug() or unplug().
593
594    This class can be used by Cros widgets that have alternative ways of
595    performing plug and unplug.
596
597    """
598
599    def plug(self):
600        """Does nothing for plug."""
601        logging.info('DummyPlugHandler: plug')
602
603
604    def unplug(self):
605        """Does nothing for unplug."""
606        logging.info('DummyPlugHandler: unplug')
607
608
609class JackPluggerPlugHandler(PlugHandler):
610    """This class abstracts plug/unplug action with motor on Cros device.
611
612    Properties:
613        _jack_plugger: A JackPlugger object to access the jack plugger robot
614
615    """
616
617    def __init__(self, jack_plugger):
618        """Initializes a JackPluggerPlugHandler.
619
620        @param jack_plugger: A JackPlugger object
621        """
622        self._jack_plugger = jack_plugger
623
624
625    def plug(self):
626        """plugs in the jack to the cros device."""
627        self._jack_plugger.plug()
628
629
630    def unplug(self):
631        """Unplugs the jack from the cros device."""
632        self._jack_plugger.unplug()
633
634
635class USBPlugHandler(PlugHandler):
636    """This class abstracts plug/unplug action for USB widgets on Cros device.
637
638    Properties:
639        _usb_facade: An USBFacadeRemoteAdapter to access Cros device USB-
640                     specific functionality.
641
642    """
643
644    def __init__(self, usb_facade):
645        """Initializes a USBPlugHandler.
646
647        @param usb_facade: A USBFacadeRemoteAdapter to access Cros device USB-
648                           specific funtionality.
649
650        """
651        self._usb_facade = usb_facade
652
653
654    def plug(self):
655        """plugs in the usb audio widget to the cros device."""
656        self._usb_facade.plug()
657
658
659    def unplug(self):
660        """Unplugs the usb audio widget from the cros device."""
661        self._usb_facade.unplug()
662
663
664class CrosInputWidgetHandlerError(Exception):
665    """Error in CrosInputWidgetHandler."""
666
667
668class CrosInputWidgetHandler(CrosWidgetHandler):
669    """
670    This class abstracts a Cros device audio input widget handler.
671
672    """
673    _DEFAULT_DATA_FORMAT = dict(file_type='raw',
674                                sample_format='S16_LE',
675                                channel=1,
676                                rate=48000)
677
678    def start_recording(self):
679        """Starts recording audio."""
680        self._audio_facade.start_recording(self._DEFAULT_DATA_FORMAT)
681
682
683    def stop_recording(self):
684        """Stops recording audio.
685
686        @returns:
687            A tuple (remote_path, format).
688                remote_path: The path to the recorded file on Cros device.
689                format: A dict containing:
690                    file_type: 'raw'.
691                    sample_format: 'S16_LE' for 16-bit signed integer in
692                                   little-endian.
693                    channel: channel number.
694                    rate: sampling rate.
695
696        """
697        return self._audio_facade.stop_recording(), self._DEFAULT_DATA_FORMAT
698
699
700    def get_recorded_binary(self, remote_path, record_format):
701        """Gets remote recorded file binary.
702
703        Gets and reads recorded file from Cros device.
704
705        @param remote_path: The path to the recorded file on Cros device.
706        @param record_format: The recorded data format. A dict containing
707                     file_type: 'raw' or 'wav'.
708                     sample_format: 'S32_LE' for 32-bit signed integer in
709                                    little-endian. Refer to aplay manpage for
710                                    other formats.
711                     channel: channel number.
712                     rate: sampling rate.
713
714        @returns: The recorded binary.
715
716        @raises: CrosInputWidgetHandlerError if record_format is not correct.
717        """
718        if record_format != self._DEFAULT_DATA_FORMAT:
719            raise CrosInputWidgetHandlerError(
720                    'Record format %r is not valid' % record_format)
721
722        with tempfile.NamedTemporaryFile(prefix='recorded_') as f:
723            self._audio_facade.get_recorded_file(remote_path, f.name)
724            return open(f.name).read()
725
726
727class CrosUSBInputWidgetHandler(CrosInputWidgetHandler):
728    """
729    This class abstracts a Cros device audio input widget handler.
730
731    """
732    _DEFAULT_DATA_FORMAT = dict(file_type='raw',
733                                sample_format='S16_LE',
734                                channel=2,
735                                rate=48000)
736
737
738class CrosOutputWidgetHandlerError(Exception):
739    """The error in CrosOutputWidgetHandler."""
740    pass
741
742
743class CrosOutputWidgetHandler(CrosWidgetHandler):
744    """
745    This class abstracts a Cros device audio output widget handler.
746
747    """
748    _DEFAULT_DATA_FORMAT = dict(file_type='raw',
749                                sample_format='S16_LE',
750                                channel=2,
751                                rate=48000)
752
753    def set_playback_data(self, test_data):
754        """Sets data to play.
755
756        @param test_data: An AudioTestData object.
757
758        @returns: The remote file path on Cros device.
759
760        """
761        # TODO(cychiang): Do format conversion on Cros device if this is
762        # needed.
763        if test_data.data_format != self._DEFAULT_DATA_FORMAT:
764            raise CrosOutputWidgetHandlerError(
765                    'File format conversion for cros device is not supported.')
766        return self._audio_facade.set_playback_file(test_data.path)
767
768
769    def start_playback(self, path, blocking=False):
770        """Starts playing audio.
771
772        @param path: The path to the file to play on Cros device.
773        @param blocking: Blocks this call until playback finishes.
774
775        """
776        self._audio_facade.playback(path, self._DEFAULT_DATA_FORMAT, blocking)
777
778
779    def stop_playback(self):
780        """Stops playing audio."""
781        raise NotImplementedError
782
783
784class PeripheralWidgetHandler(object):
785    """
786    This class abstracts an action handler on peripheral.
787    Currently, as there is no action to take on the peripheral speaker and mic,
788    this class serves as a place-holder.
789
790    """
791    pass
792
793
794class PeripheralWidget(AudioWidget):
795    """
796    This class abstracts a peripheral widget which only acts passively like
797    peripheral speaker or microphone, or acts transparently like bluetooth
798    module on audio board which relays the audio siganl between Chameleon board
799    and Cros device. This widget does not provide playback/record function like
800    AudioOutputWidget or AudioInputWidget. The main purpose of this class is
801    an identifier to find the correct AudioWidgetLink to do the real work.
802    """
803    pass
804