xref: /aosp_15_r20/external/autotest/client/cros/multimedia/audio_facade.py (revision 9c5db1993ded3edbeafc8092d69fe5de2ee02df7)
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"""Facade to access the audio-related functionality."""
6
7import functools
8import glob
9import logging
10import numpy as np
11import os
12import tempfile
13
14from autotest_lib.client.cros import constants
15from autotest_lib.client.cros.audio import audio_helper
16from autotest_lib.client.cros.audio import cmd_utils
17from autotest_lib.client.cros.audio import cras_dbus_utils
18from autotest_lib.client.cros.audio import cras_utils
19from autotest_lib.client.cros.audio import alsa_utils
20from autotest_lib.client.cros.multimedia import audio_extension_handler
21
22
23class AudioFacadeLocalError(Exception):
24    """Error in AudioFacadeLocal."""
25    pass
26
27
28def check_arc_resource(func):
29    """Decorator function for ARC related functions in AudioFacadeLocal."""
30    @functools.wraps(func)
31    def wrapper(instance, *args, **kwargs):
32        """Wrapper for the methods to check _arc_resource.
33
34        @param instance: Object instance.
35
36        @raises: AudioFacadeLocalError if there is no ARC resource.
37
38        """
39        if not instance._arc_resource:
40            raise AudioFacadeLocalError('There is no ARC resource.')
41        return func(instance, *args, **kwargs)
42    return wrapper
43
44
45def file_contains_all_zeros(path):
46    """Reads a file and checks whether the file contains all zeros."""
47    with open(path, 'rb') as f:
48        binary = f.read()
49        # Assume data is in 16 bit signed int format. The real format
50        # does not matter though since we only care if there is nonzero data.
51        np_array = np.fromstring(binary, dtype='<i2')
52        return not np.any(np_array)
53
54
55class AudioFacadeLocal(object):
56    """Facede to access the audio-related functionality.
57
58    The methods inside this class only accept Python native types.
59
60    """
61    _CAPTURE_DATA_FORMATS = [
62            dict(file_type='raw', sample_format='S16_LE',
63                 channel=1, rate=48000),
64            dict(file_type='raw', sample_format='S16_LE',
65                 channel=2, rate=48000)]
66
67    _PLAYBACK_DATA_FORMAT = dict(
68            file_type='raw', sample_format='S16_LE', channel=2, rate=48000)
69
70    _LISTEN_DATA_FORMATS = [
71            dict(file_type='raw', sample_format='S16_LE',
72                 channel=1, rate=16000)]
73
74    def __init__(self, resource, arc_resource=None):
75        """Initializes an audio facade.
76
77        @param resource: A FacadeResource object.
78        @param arc_resource: An ArcResource object.
79
80        """
81        self._resource = resource
82        self._listener = None
83        self._recorders = {}
84        self._player = None
85        self._counter = None
86        self._loaded_extension_handler = None
87        self._arc_resource = arc_resource
88
89
90    @property
91    def _extension_handler(self):
92        """Multimedia test extension handler."""
93        if not self._loaded_extension_handler:
94            extension = self._resource.get_extension(
95                    constants.AUDIO_TEST_EXTENSION)
96            logging.debug('Loaded extension: %s', extension)
97            self._loaded_extension_handler = (
98                    audio_extension_handler.AudioExtensionHandler(extension))
99        return self._loaded_extension_handler
100
101
102    def get_audio_availability(self):
103        """Returns the availability of chrome.audio API.
104
105        @returns: True if chrome.audio exists
106        """
107        return self._extension_handler.get_audio_api_availability()
108
109
110    def get_audio_devices(self):
111        """Returns the audio devices from chrome.audio API.
112
113        @returns: Checks docstring of get_audio_devices of AudioExtensionHandler.
114
115        """
116        return self._extension_handler.get_audio_devices()
117
118
119    def set_chrome_active_volume(self, volume):
120        """Sets the active audio output volume using chrome.audio API.
121
122        @param volume: Volume to set (0~100).
123
124        """
125        self._extension_handler.set_active_volume(volume)
126
127
128    def set_chrome_active_input_gain(self, gain):
129        """Sets the active audio input gain using chrome.audio API.
130
131        @param volume: Gain to set (0~100).
132
133        """
134        self._extension_handler.set_active_input_gain(gain)
135
136
137    def set_chrome_mute(self, mute):
138        """Mutes the active audio output using chrome.audio API.
139
140        @param mute: True to mute. False otherwise.
141
142        """
143        self._extension_handler.set_mute(mute)
144
145
146    def get_chrome_active_volume_mute(self):
147        """Gets the volume state of active audio output using chrome.audio API.
148
149        @param returns: A tuple (volume, mute), where volume is 0~100, and mute
150                        is True if node is muted, False otherwise.
151
152        """
153        return self._extension_handler.get_active_volume_mute()
154
155
156    def set_chrome_active_node_type(self, output_node_type, input_node_type):
157        """Sets active node type through chrome.audio API.
158
159        The node types are defined in cras_utils.CRAS_NODE_TYPES.
160        The current active node will be disabled first if the new active node
161        is different from the current one.
162
163        @param output_node_type: A node type defined in
164                                 cras_utils.CRAS_NODE_TYPES. None to skip.
165        @param input_node_type: A node type defined in
166                                 cras_utils.CRAS_NODE_TYPES. None to skip
167
168        """
169        if output_node_type:
170            node_id = cras_utils.get_node_id_from_node_type(
171                    output_node_type, False)
172            self._extension_handler.set_active_node_id(node_id)
173        if input_node_type:
174            node_id = cras_utils.get_node_id_from_node_type(
175                    input_node_type, True)
176            self._extension_handler.set_active_node_id(node_id)
177
178
179    def check_audio_stream_at_selected_device(self):
180        """Checks the audio output is at expected node"""
181        output_device_name = cras_utils.get_selected_output_device_name()
182        output_device_type = cras_utils.get_selected_output_device_type()
183        logging.info("Output device name is %s", output_device_name)
184        logging.info("Output device type is %s", output_device_type)
185        alsa_utils.check_audio_stream_at_selected_device(output_device_name,
186                                                         output_device_type)
187
188
189    def cleanup(self):
190        """Clean up the temporary files."""
191        for path in glob.glob('/tmp/playback_*'):
192            os.unlink(path)
193
194        for path in glob.glob('/tmp/capture_*'):
195            os.unlink(path)
196
197        for path in glob.glob('/tmp/listen_*'):
198            os.unlink(path)
199
200        if self._recorders:
201            for _, recorder in self._recorders:
202                recorder.cleanup()
203        self._recorders.clear()
204
205        if self._player:
206            self._player.cleanup()
207        if self._listener:
208            self._listener.cleanup()
209
210        if self._arc_resource:
211            self._arc_resource.cleanup()
212
213
214    def playback(self, file_path, data_format, blocking=False, node_type=None,
215                 block_size=None):
216        """Playback a file.
217
218        @param file_path: The path to the file.
219        @param data_format: A dict containing data format including
220                            file_type, sample_format, channel, and rate.
221                            file_type: file type e.g. 'raw' or 'wav'.
222                            sample_format: One of the keys in
223                                           audio_data.SAMPLE_FORMAT.
224                            channel: number of channels.
225                            rate: sampling rate.
226        @param blocking: Blocks this call until playback finishes.
227        @param node_type: A Cras node type defined in cras_utils.CRAS_NODE_TYPES
228                          that we like to pin at. None to have the playback on
229                          active selected device.
230        @param block_size: The number for frames per callback.
231
232        @returns: True.
233
234        @raises: AudioFacadeLocalError if data format is not supported.
235
236        """
237        logging.info('AudioFacadeLocal playback file: %r. format: %r',
238                     file_path, data_format)
239
240        if data_format != self._PLAYBACK_DATA_FORMAT:
241            raise AudioFacadeLocalError(
242                    'data format %r is not supported' % data_format)
243
244        device_id = None
245        if node_type:
246            device_id = int(cras_utils.get_device_id_from_node_type(
247                    node_type, False))
248
249        self._player = Player()
250        self._player.start(file_path, blocking, device_id, block_size)
251
252        return True
253
254
255    def stop_playback(self):
256        """Stops playback process."""
257        self._player.stop()
258
259
260    def start_recording(self, data_format, node_type=None, block_size=None):
261        """Starts recording an audio file.
262
263        Currently the format specified in _CAPTURE_DATA_FORMATS is the only
264        formats.
265
266        @param data_format: A dict containing:
267                            file_type: 'raw'.
268                            sample_format: 'S16_LE' for 16-bit signed integer in
269                                           little-endian.
270                            channel: channel number.
271                            rate: sampling rate.
272        @param node_type: A Cras node type defined in cras_utils.CRAS_NODE_TYPES
273                          that we like to pin at. None to have the recording
274                          from active selected device.
275        @param block_size: The number for frames per callback.
276
277        @returns: True
278
279        @raises: AudioFacadeLocalError if data format is not supported, no
280                 active selected node or the specified node is occupied.
281
282        """
283        logging.info('AudioFacadeLocal record format: %r', data_format)
284
285        if data_format not in self._CAPTURE_DATA_FORMATS:
286            raise AudioFacadeLocalError(
287                    'data format %r is not supported' % data_format)
288
289        if node_type is None:
290            device_id = None
291            node_type = cras_utils.get_selected_input_device_type()
292            if node_type is None:
293                raise AudioFacadeLocalError('No active selected input node.')
294        else:
295            device_id = int(cras_utils.get_device_id_from_node_type(
296                    node_type, True))
297
298        if node_type in self._recorders:
299            raise AudioFacadeLocalError(
300                    'Node %s is already ocuppied' % node_type)
301
302        self._recorders[node_type] = Recorder()
303        self._recorders[node_type].start(data_format, device_id, block_size)
304
305        return True
306
307
308    def stop_recording(self, node_type=None):
309        """Stops recording an audio file.
310        @param node_type: A Cras node type defined in cras_utils.CRAS_NODE_TYPES
311                          that we like to pin at. None to have the recording
312                          from active selected device.
313
314        @returns: The path to the recorded file.
315                  None if capture device is not functional.
316
317        @raises: AudioFacadeLocalError if no recording is started on
318                 corresponding node.
319        """
320        if node_type is None:
321            device_id = None
322            node_type = cras_utils.get_selected_input_device_type()
323            if node_type is None:
324                raise AudioFacadeLocalError('No active selected input node.')
325        else:
326            device_id = int(cras_utils.get_device_id_from_node_type(
327                    node_type, True))
328
329
330        if node_type not in self._recorders:
331            raise AudioFacadeLocalError(
332                    'No recording is started on node %s' % node_type)
333
334        recorder = self._recorders[node_type]
335        recorder.stop()
336        del self._recorders[node_type]
337
338        file_path = recorder.file_path
339        if file_contains_all_zeros(recorder.file_path):
340            logging.error('Recorded file contains all zeros. '
341                          'Capture device is not functional')
342            return None
343
344        return file_path
345
346
347    def start_listening(self, data_format):
348        """Starts listening to hotword for a given format.
349
350        Currently the format specified in _CAPTURE_DATA_FORMATS is the only
351        formats.
352
353        @param data_format: A dict containing:
354                            file_type: 'raw'.
355                            sample_format: 'S16_LE' for 16-bit signed integer in
356                                           little-endian.
357                            channel: channel number.
358                            rate: sampling rate.
359
360
361        @returns: True
362
363        @raises: AudioFacadeLocalError if data format is not supported.
364
365        """
366        logging.info('AudioFacadeLocal record format: %r', data_format)
367
368        if data_format not in self._LISTEN_DATA_FORMATS:
369            raise AudioFacadeLocalError(
370                    'data format %r is not supported' % data_format)
371
372        self._listener = Listener()
373        self._listener.start(data_format)
374
375        return True
376
377
378    def stop_listening(self):
379        """Stops listening to hotword.
380
381        @returns: The path to the recorded file.
382                  None if hotwording is not functional.
383
384        """
385        self._listener.stop()
386        if file_contains_all_zeros(self._listener.file_path):
387            logging.error('Recorded file contains all zeros. '
388                          'Hotwording device is not functional')
389            return None
390        return self._listener.file_path
391
392
393    def set_selected_output_volume(self, volume):
394        """Sets the selected output volume.
395
396        @param volume: the volume to be set(0-100).
397
398        """
399        cras_utils.set_selected_output_node_volume(volume)
400
401
402    def set_selected_node_types(self, output_node_types, input_node_types):
403        """Set selected node types.
404
405        The node types are defined in cras_utils.CRAS_NODE_TYPES.
406
407        @param output_node_types: A list of output node types.
408                                  None to skip setting.
409        @param input_node_types: A list of input node types.
410                                 None to skip setting.
411
412        """
413        cras_utils.set_selected_node_types(output_node_types, input_node_types)
414
415
416    def get_selected_node_types(self):
417        """Gets the selected output and input node types.
418
419        @returns: A tuple (output_node_types, input_node_types) where each
420                  field is a list of selected node types defined in
421                  cras_utils.CRAS_NODE_TYPES.
422
423        """
424        return cras_utils.get_selected_node_types()
425
426
427    def get_plugged_node_types(self):
428        """Gets the plugged output and input node types.
429
430        @returns: A tuple (output_node_types, input_node_types) where each
431                  field is a list of plugged node types defined in
432                  cras_utils.CRAS_NODE_TYPES.
433
434        """
435        return cras_utils.get_plugged_node_types()
436
437
438    def dump_diagnostics(self, file_path):
439        """Dumps audio diagnostics results to a file.
440
441        @param file_path: The path to dump results.
442
443        """
444        audio_helper.dump_audio_diagnostics(file_path)
445
446
447    def start_counting_signal(self, signal_name):
448        """Starts counting DBus signal from Cras.
449
450        @param signal_name: Signal of interest.
451
452        """
453        if self._counter:
454            raise AudioFacadeLocalError('There is an ongoing counting.')
455        self._counter = cras_dbus_utils.CrasDBusBackgroundSignalCounter()
456        self._counter.start(signal_name)
457
458
459    def stop_counting_signal(self):
460        """Stops counting DBus signal from Cras.
461
462        @returns: Number of signals starting from last start_counting_signal
463                  call.
464
465        """
466        if not self._counter:
467            raise AudioFacadeLocalError('Should start counting signal first')
468        result = self._counter.stop()
469        self._counter = None
470        return result
471
472
473    def wait_for_unexpected_nodes_changed(self, timeout_secs):
474        """Waits for unexpected nodes changed signal.
475
476        @param timeout_secs: Timeout in seconds for waiting.
477
478        """
479        cras_dbus_utils.wait_for_unexpected_nodes_changed(timeout_secs)
480
481
482    def get_noise_cancellation_supported(self):
483        """Gets whether the device supports Noise Cancellation.
484
485        @returns: True is supported; False otherwise.
486
487        """
488        return cras_utils.get_noise_cancellation_supported()
489
490
491    def set_bypass_block_noise_cancellation(self, bypass):
492        """Sets CRAS to bypass the blocking logic of Noise Cancellation.
493
494        @param bypass: True for bypass; False for un-bypass.
495
496        """
497        cras_utils.set_bypass_block_noise_cancellation(bypass)
498
499
500    def set_noise_cancellation_enabled(self, enabled):
501        """Sets the state to enable or disable Noise Cancellation.
502
503        @param enabled: True to enable; False to disable.
504
505        """
506        cras_utils.set_noise_cancellation_enabled(enabled)
507
508    @check_arc_resource
509    def start_arc_recording(self):
510        """Starts recording using microphone app in container."""
511        self._arc_resource.microphone.start_microphone_app()
512
513
514    @check_arc_resource
515    def stop_arc_recording(self):
516        """Checks the recording is stopped and gets the recorded path.
517
518        The recording duration of microphone app is fixed, so this method just
519        copies the recorded result from container to a path on Cros device.
520
521        """
522        _, file_path = tempfile.mkstemp(prefix='capture_', suffix='.amr-nb')
523        self._arc_resource.microphone.stop_microphone_app(file_path)
524        return file_path
525
526
527    @check_arc_resource
528    def set_arc_playback_file(self, file_path):
529        """Copies the audio file to be played into container.
530
531        User should call this method to put the file into container before
532        calling start_arc_playback.
533
534        @param file_path: Path to the file to be played on Cros host.
535
536        @returns: Path to the file in container.
537
538        """
539        return self._arc_resource.play_music.set_playback_file(file_path)
540
541
542    @check_arc_resource
543    def start_arc_playback(self, path):
544        """Start playback through Play Music app.
545
546        Before calling this method, user should call set_arc_playback_file to
547        put the file into container.
548
549        @param path: Path to the file in container.
550
551        """
552        self._arc_resource.play_music.start_playback(path)
553
554
555    @check_arc_resource
556    def stop_arc_playback(self):
557        """Stop playback through Play Music app."""
558        self._arc_resource.play_music.stop_playback()
559
560
561class RecorderError(Exception):
562    """Error in Recorder."""
563    pass
564
565
566class Recorder(object):
567    """The class to control recording subprocess.
568
569    Properties:
570        file_path: The path to recorded file. It should be accessed after
571                   stop() is called.
572
573    """
574    def __init__(self):
575        """Initializes a Recorder."""
576        _, self.file_path = tempfile.mkstemp(prefix='capture_', suffix='.raw')
577        self._capture_subprocess = None
578
579
580    def start(self, data_format, pin_device, block_size):
581        """Starts recording.
582
583        Starts recording subprocess. It can be stopped by calling stop().
584
585        @param data_format: A dict containing:
586                            file_type: 'raw'.
587                            sample_format: 'S16_LE' for 16-bit signed integer in
588                                           little-endian.
589                            channel: channel number.
590                            rate: sampling rate.
591        @param pin_device: A integer of device id to record from.
592        @param block_size: The number for frames per callback.
593        """
594        self._capture_subprocess = cmd_utils.popen(
595                cras_utils.capture_cmd(
596                        capture_file=self.file_path, duration=None,
597                        channels=data_format['channel'],
598                        rate=data_format['rate'],
599                        pin_device=pin_device, block_size=block_size))
600
601
602    def stop(self):
603        """Stops recording subprocess."""
604        if self._capture_subprocess.poll() is None:
605            self._capture_subprocess.terminate()
606        else:
607            raise RecorderError(
608                    'Recording process was terminated unexpectedly.')
609
610
611    def cleanup(self):
612        """Cleanup the resources.
613
614        Terminates the recording process if needed.
615
616        """
617        if self._capture_subprocess and self._capture_subprocess.poll() is None:
618            self._capture_subprocess.terminate()
619
620
621class PlayerError(Exception):
622    """Error in Player."""
623    pass
624
625
626class Player(object):
627    """The class to control audio playback subprocess.
628
629    Properties:
630        file_path: The path to the file to play.
631
632    """
633    def __init__(self):
634        """Initializes a Player."""
635        self._playback_subprocess = None
636
637
638    def start(self, file_path, blocking, pin_device, block_size):
639        """Starts playing.
640
641        Starts playing subprocess. It can be stopped by calling stop().
642
643        @param file_path: The path to the file.
644        @param blocking: Blocks this call until playback finishes.
645        @param pin_device: A integer of device id to play on.
646        @param block_size: The number for frames per callback.
647
648        """
649        self._playback_subprocess = cras_utils.playback(
650                blocking, playback_file=file_path, pin_device=pin_device,
651                block_size=block_size)
652
653
654    def stop(self):
655        """Stops playback subprocess."""
656        cmd_utils.kill_or_log_returncode(self._playback_subprocess)
657
658
659    def cleanup(self):
660        """Cleanup the resources.
661
662        Terminates the playback process if needed.
663
664        """
665        self.stop()
666
667
668class ListenerError(Exception):
669    """Error in Listener."""
670    pass
671
672
673class Listener(object):
674    """The class to control listening subprocess.
675
676    Properties:
677        file_path: The path to recorded file. It should be accessed after
678                   stop() is called.
679
680    """
681    def __init__(self):
682        """Initializes a Listener."""
683        _, self.file_path = tempfile.mkstemp(prefix='listen_', suffix='.raw')
684        self._capture_subprocess = None
685
686
687    def start(self, data_format):
688        """Starts listening.
689
690        Starts listening subprocess. It can be stopped by calling stop().
691
692        @param data_format: A dict containing:
693                            file_type: 'raw'.
694                            sample_format: 'S16_LE' for 16-bit signed integer in
695                                           little-endian.
696                            channel: channel number.
697                            rate: sampling rate.
698
699        @raises: ListenerError: If listening subprocess is terminated
700                 unexpectedly.
701
702        """
703        self._capture_subprocess = cmd_utils.popen(
704                cras_utils.listen_cmd(
705                        capture_file=self.file_path, duration=None,
706                        channels=data_format['channel'],
707                        rate=data_format['rate']))
708
709
710    def stop(self):
711        """Stops listening subprocess."""
712        if self._capture_subprocess.poll() is None:
713            self._capture_subprocess.terminate()
714        else:
715            raise ListenerError(
716                    'Listening process was terminated unexpectedly.')
717
718
719    def cleanup(self):
720        """Cleanup the resources.
721
722        Terminates the listening process if needed.
723
724        """
725        if self._capture_subprocess and self._capture_subprocess.poll() is None:
726            self._capture_subprocess.terminate()
727