blob: 229454b8e20689efa92b16416a4f4efb0059eba1 [file] [edit]
#!/usr/bin/env python3
# Copyright 2024 The ChromiumOS Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""
The offline query tool of audio features support for devices.
This tool resolves queries by the core parser according to the audio config
files in ChromiumOS repo on local ends. The parser applies the common patterns
for config matching. Therefore, the queried result may be inaccurate due to
local changes in repo, or exceptional cases of config pattern by particular
devices.
"""
from __future__ import annotations
import argparse
import collections
import csv
import dataclasses
import inspect
import logging
import pathlib
from typing import Optional
from ucmlint import ucmlint
from ucmlint.ucmlint import k
PROG_DESCRIPTION = """
The offline query tool of audio features support for devices.
notes:
* Before use, sync the local-end Chromiumos repo to the latest.
* Results may have the chance to be misjudged by unforeseen cases for the core
parser.
"""
PROG_EPILOG = """
usage examples:
feature_support.py -d redrix
* Query by the specified device 'redrix' for the list of supported features.
feature_support.py -f post_proc -b volteer brya nissa
* Query by the specified feature 'post_proc' (post-processing) for the list
of devices with support.
* Specify the query pool among devices upon board 'volteer', 'brya', and
'nissa'.
feature_support.py -f -v
* Query by the specified feature while the decision is deferred to the
selection I/O later.
* Run in verbose mode (may produce enormous messages from ucmlint and this).
feature_support.py -o csv.txt --no-report-stdout
* Query the full support matrix of features for all available devices.
* Generate output file 'csv.txt' to save the support matrix in csv format.
* Don't show the report on stdout.
"""
# const str for indicating feature selection I/O
SELECT_WITH_IO = 'tbd'
def match_hifi_modifier_name(key: str, hifi: ucmlint.HiFiParser) -> bool:
"""Matches key with the names of SectionModifier in HiFi.conf.
Args:
key: the key word in string for matching.
hifi: the ucmlint.HiFiParser object.
Returns:
True if matches, otherwise False.
"""
return any([mod.name == key for mod in hifi.modifiers])
def match_hifi_device_name(key: str, hifi: ucmlint.HiFiParser) -> bool:
"""Matches key with the names of SectionDevice in HiFi.conf.
Args:
key: the key word in string for matching.
hifi: the ucmlint.HiFiParser object.
Returns:
True if matches, otherwise False.
"""
return any([device.name == key for device in hifi.devices])
def match_hifi_playback_seq(key: str, hifi: ucmlint.HiFiParser) -> bool:
"""Matches key with EnableSequence of playback in HiFi.conf.
The matching target includes the main section, and playback devices such as
Speaker, Headphone, and HDMI.
Args:
key: the key word in string for matching.
hifi: the ucmlint.HiFiParser object.
Returns:
True if matches, otherwise False.
"""
if hifi.verb is None:
return False # empty HiFiConf
for item in hifi.verb.enable_sequence.items:
if item.lhs == 'cset-tlv' and key in item.rhs:
return True
applied_device_types = [
ucmlint.DeviceType.Speaker,
ucmlint.DeviceType.Headphone,
ucmlint.DeviceType.HDMI,
]
for device in hifi.devices:
if ucmlint.DeviceType.fromstring(device.name) in applied_device_types:
for item in device.enable_sequence.items:
if item.lhs == 'cset-tlv' and key in item.rhs:
return True
return False
def match_sound_card_amp(key: str, sound_card_amp: str) -> bool:
"""Matches key with the sound card amplifier name.
Args:
key: the key word in string for matching.
sound_card_amp: the amplifier name in string from sound-card-init config
file; None when no config file is found.
Returns:
True if matches, otherwise False.
"""
if sound_card_amp is None:
return False
return key in sound_card_amp
@dataclasses.dataclass
class MatchMethod:
keys: list[str] # the list of key words for matching
func: function # the applied matching function
def is_matched(self, **kwargs) -> bool:
"""Checks if the input args can match.
Args:
kwargs may contain the following items which are available arguments
requested by matching function:
'hifi: ucmlint.HiFiParser'
'sound_card_amp: str'
Returns:
True if at least one key word in keys is matched via func.
"""
func_params = inspect.signature(self.func).parameters
# get the subset of arguments required by func
func_kwargs = dict((k, v) for k, v in kwargs.items() if k in func_params)
return any([self.func(key, **func_kwargs) for key in self.keys])
@dataclasses.dataclass
class Feature:
name: str # the short name used for identification
full_name: str # the full name which is readable by users
match_methods: list[MatchMethod] # the list of sufficient matching methods
def is_supported(self, hifi: ucmlint.HiFiParser, sound_card_amp: str) -> bool:
"""Whether this feature is supported.
Args:
hifi: the ucmlint.HiFiParser object.
sound_card_amp: the amplifier name in string from sound-card-init config
file; None when no config file is found.
Returns:
True if this feature is supported; False otherwise.
"""
return any(
[mm.is_matched(hifi=hifi, sound_card_amp=sound_card_amp) for mm in self.match_methods]
)
FEATURES = [
Feature(
name='nc',
full_name='DSP Noise Cancellation',
match_methods=[
MatchMethod(keys=['Internal Mic Noise Cancellation'], func=match_hifi_modifier_name),
],
),
Feature(
name='aec',
full_name='DSP Acoustic Echo Cancellation',
match_methods=[
MatchMethod(keys=['RTC Proc Echo Cancellation'], func=match_hifi_modifier_name),
],
),
Feature(
name='waves',
full_name='Post-processor - Waves',
match_methods=[
MatchMethod(keys=['waves_params'], func=match_hifi_playback_seq),
],
),
Feature(
name='dts',
full_name='Post-processor - DTS',
match_methods=[
MatchMethod(keys=['dts_sdk'], func=match_hifi_playback_seq),
],
),
Feature(
name='cirrus',
full_name='Smart Amplifier - Cirrus',
match_methods=[
# known Cirrus-supplied smart amp names and aliases so far
MatchMethod(keys=['cs35l41'], func=match_sound_card_amp),
],
),
Feature(
name='dsm',
full_name='Smart Amplifier - Maxim DSM',
match_methods=[
# known Maxim-supplied DSM smart amp names and aliases so far
MatchMethod(
keys=['max98373', 'max98390', 'm98390', 'r1011'], func=match_sound_card_amp
),
MatchMethod(keys=['dsmparam'], func=match_hifi_playback_seq),
],
),
Feature(
name='hfp',
full_name='Bluetooth Offload - HFP',
match_methods=[
# match device "SCO Line In" only. "SCO Line Out" can be skipped
# because it is always paired with input.
MatchMethod(keys=['SCO Line In'], func=match_hifi_device_name),
],
),
]
@dataclasses.dataclass
class GenericFeature:
name: str # the short name used for identification
full_name: str # the full name which is readable by users
subfeatures: set # the set of feature names belong to this
def is_supported(self, support_features: set) -> bool:
"""Returns true if this feature is supported, false otherwise."""
return any(support_features & self.subfeatures)
GENERIC_FEATURES = [
GenericFeature(
name='post_proc', full_name='Post-processor (any)', subfeatures={'waves', 'dts'}
),
GenericFeature(
name='smart_amp', full_name='Smart Amplifier (any)', subfeatures={'cirrus', 'dsm'}
),
GenericFeature(name='bt_off', full_name='Bluetooth Offload (any)', subfeatures={'hfp'}),
]
ALL_FEATURES = FEATURES + GENERIC_FEATURES
def get_full_name(feature_name: str) -> str:
"""Gets the full name of feature from the short name."""
for f in ALL_FEATURES:
if f.name == feature_name:
return f.full_name
return 'Unknown Feature ({feature_name})'
@dataclasses.dataclass
class DeviceInfo:
"""The collected information of a device."""
board: str
path: pathlib.Path
sound_card_amp: str
class FeatureSupportArgsError(Exception):
"""Error from the assigned arguments."""
class FeatureSupportParsingError(Exception):
"""Error on parsing the feature support."""
def raise_parsing_error(msg: str):
"""Raises FeatureSupportParsingError.
Args:
msg: the error message in string.
Raises:
FeatureSupportParsingError
"""
raise FeatureSupportParsingError(msg)
def hifi_filename(info: DeviceInfo) -> str:
"""The coral board devices are special cases which name 'HiFi'."""
return 'HiFi' if info.board == 'coral' else 'HiFi.conf'
def fetch_common_hifi_paths(device: str, info: DeviceInfo) -> list[PosixPath]:
"""Gets the possible paths of the common HiFi.conf file.
Args:
device: the device name in string.
info: the DeviceInfo object for the device.
Returns:
The list of possible paths of the common HiFi.conf file.
"""
# replace PosixPath('**/files/<device>/audio') to PosixPath('**/files/common/audio')
common_audio_path = info.path.parent.parent / 'common/audio'
if not common_audio_path.is_dir():
return []
# try to extract the sound card name from CRAS config files.
cras_conf_paths = sorted(info.path.glob('cras-config/*'))
prox_card_names = [p.stem for p in cras_conf_paths if p.suffix != 'ini']
if len(prox_card_names) == 0:
logging.warning(
'Unable to fetch any HiFi.conf from either device '
f'`{device}` or common. Leave it feature-unsupported.'
)
return []
# try to identify the right path of the applied common HiFi.conf
common_hifi_paths = []
for card_name in prox_card_names:
common_hifi_paths.extend(
sorted(common_audio_path.glob(f'ucm-config/{card_name}*/' + hifi_filename(info)))
)
return common_hifi_paths
def get_sound_card_init_amp(audio_path: pathlib.Path) -> Optional[str]:
"""Gets the amplifier name from sound-card-init config filename.
The sound-card-init config is a YAML-typed file which name contains the
amplifier name, e.g. vell.CS35L41.yaml and sofmt8188m98390.yaml.
Args:
audio_path: the device audio path.
Returns:
The amplifier name extracted from the sound-card-init config filename,
or None if the config file is not found.
"""
# folders are named "sound-card-init-config" or "sound_card_init-config"
sound_card_init_paths = sorted(audio_path.glob('sound[_-]card[_-]init-config/*.yaml'))
if len(sound_card_init_paths) == 0:
return None
if len(sound_card_init_paths) > 1:
logging.warning(
'Expect 1 yaml file for sound-card-init config. '
f'Found {len(sound_card_init_paths)} under {audio_path}.'
)
return sound_card_init_paths[0].stem.lower()
def get_support_feature_set(device: str, info: DeviceInfo, error: function) -> Optional[set]:
"""Gets the supporting feature set for the specified device.
Args:
device: the device name in string.
info: the DeviceInfo object for the device.
error: the function for reporting errors.
Returns:
A set consisting of supporting feature names, e.g. {'nc', 'aec', 'hfp'}.
Raises:
FeatureSupportParsingError: unexpected error is occurred while parsing,
as long as the raise is included in the error function.
"""
logging.info(f'Parsing device `{device}`')
hifi_paths = sorted(info.path.glob('ucm-config/**/' + hifi_filename(info)))
if len(hifi_paths) == 0:
logging.info(
f'Unable to fetch HiFi.conf from device `{device}`, '
'try to fetch the common one instead...'
)
hifi_paths = fetch_common_hifi_paths(device, info)
if len(hifi_paths) == 0:
error(f'Unable to fetch HiFi.conf from either device `{device}` or common.')
if len(hifi_paths) > 1:
ucm_skews = [s.parent.name for s in hifi_paths]
logging.warning(
f'HiFi.conf skews found in device `{device}`: {ucm_skews}, '
'feature support will report True if any skew supports that feature.'
)
support_feature_set = set()
for hifi_path in hifi_paths:
relpath = str(hifi_path)
hifi = ucmlint.HiFiParser(
hifi_path.read_text(encoding='utf-8').splitlines(), path=relpath
).parse()
if hifi is None:
error(f'Unable to parse file: {relpath}')
continue
support_feature_set.update(
set([f.name for f in FEATURES if f.is_supported(hifi, info.sound_card_amp)])
)
if len(hifi_paths) == 0 and info.sound_card_amp:
# create an empty hifi to let feature support functions run smoothly
empty_hifi = ucmlint.HiFiConf(verb=None, devices=[], modifiers=[], path="")
support_feature_set.update(
set([f.name for f in FEATURES if f.is_supported(empty_hifi, info.sound_card_amp)])
)
for generic_feature in GENERIC_FEATURES:
if generic_feature.is_supported(support_feature_set):
support_feature_set.add(generic_feature.name)
return support_feature_set
def get_devices_for_query(args) -> Optional[dict]:
"""Gets devices with the corresponding infos as candidates for query.
Args:
args: the ArgumentParser object.
Returns:
A dict mapping devices to the corresponding DeviceInfo objects for ones
to be queried.
Raises:
FeatureSupportArgsError: unexpected error is occurred while getting
device candidates.
"""
# check overlays dir is valid
base_path = pathlib.Path(args.overlays_dir).absolute()
if not base_path.is_dir():
raise FeatureSupportArgsError(f'Invalid overlays dirpath: {args.overlays_dir}')
if args.board:
# if board(s) are set to args.board, make sure all of them are valid.
for board in args.board:
board_path = base_path / (k.OVERLAY_ + board)
if not board_path.is_dir():
raise FeatureSupportArgsError(f'Invalid board name: {board}')
boards = args.board
else:
# if args.board is not set, fetch all valid boards.
board_paths = sorted(base_path.glob(k.OVERLAY_ + '*'))
boards = []
for board_path in board_paths:
if not board_path.is_dir():
logging.warning(f'Unexpected non-dir path: {board_path}')
continue
boards.append(board_path.name[len(k.OVERLAY_) :])
device_info = {}
for board in boards:
# <base>/overlay-<board>/chromeos-base/chromeos-bsp-<board>/files
device_root_path = (
base_path / (k.OVERLAY_ + board) / k.CHROMEOS_BASE / (k.CHROMEOS_BSP_ + board) / k.FILES
)
if not device_root_path.is_dir():
logging.info(f'Skipped board `{board}` due to invalid path: {device_root_path}')
continue
# try to match audio path with the non-unibuild pattern, i.e. device
# name is equivalent to board name.
audio_path = device_root_path / 'audio-config'
if audio_path.is_dir():
device = board
sound_card_amp = get_sound_card_init_amp(audio_path)
device_info[device] = DeviceInfo(board, audio_path, sound_card_amp)
continue
# try to match audio path with the unibuild pattern. Collect all device
# names while traversing sub-folders for each reference board.
audio_paths = sorted(device_root_path.glob('*/audio/'))
for audio_path in audio_paths:
if not audio_path.is_dir():
logging.warning(f'Unexpected non-dir path: {audio_path}')
continue
# get <device> from <device_root_path>/<device>/audio
device = audio_path.parent.name
# filter out reserved words
if device in ['common']:
continue
sound_card_amp = get_sound_card_init_amp(audio_path)
device_info[device] = DeviceInfo(board, audio_path, sound_card_amp)
if args.device:
# if args.device is set, only return with the corresponding item.
if args.device not in device_info:
raise FeatureSupportArgsError(f'Unable to fetch device: {args.device}')
return {args.device: device_info[args.device]}
# if args.device is not set, return all collected items.
return device_info
def log_device_list(board_feature_dict: dict):
"""Makes the log for listing the queried devices.
Args:
board_feature_dict: a two-level dict mapping boards and devices to the
supporting feature set.
"""
device_list_log = ''
num_devices = 0
for board, device_feature_dict in sorted(board_feature_dict.items()):
devices = sorted(list(device_feature_dict.keys()))
device_list_log += f'{board}({len(devices)}):[{", ".join(devices)}] '
num_devices += len(devices)
logging.info(
f'Reported from {num_devices} device models ({len(board_feature_dict)} boards) in total:'
)
logging.info(device_list_log)
def write_csv_output(output_path: str, board_feature_dict: dict):
"""Writes feature support info matrix to file.
Args:
output_path: the output filepath.
board_feature_dict: a two-level dict mapping boards and devices to the
supporting feature set.
"""
features = [f.name for f in ALL_FEATURES]
header_row = ['Board', 'Device'] + [f.full_name for f in ALL_FEATURES]
with open(output_path, 'w', newline='') as csvfile:
writer = csv.writer(csvfile)
# write header row
writer.writerow(header_row)
# write feature support info row per device
for board, device_feature_dict in sorted(board_feature_dict.items()):
for device, device_feature in sorted(device_feature_dict.items()):
info_row = [(lambda f: '1' if f in device_feature else '0')(f) for f in features]
writer.writerow([board, device] + info_row)
def report_unrecognized_amps(device_infos: dict, board_feature_dict: dict):
"""Reports the unrecognized sound card amps for devices if any.
A device is considered to have unrecognized amp if sound_card_amp of
DeviceInfo is non-empty, but 'smart_amp' is not in its support features.
Args:
device_infos: a dict mapping device names to DeviceInfo.
board_feature_dict: a two-level dict mapping boards and devices to the
supporting feature set.
"""
for device, info in device_infos.items():
if info.sound_card_amp is None:
continue
support_feature_set = board_feature_dict.get(info.board, {}).get(device, set())
if 'smart_amp' in support_feature_set:
continue
print(
f'\n[WARN] UNRECOGNIZED SMART AMP: DEVICE={device}, BOARD={info.board}, AMP={info.sound_card_amp}'
)
def report_device(device: str, board: str, support_feature_set: set):
"""Shows the report for the specified device.
Args:
device: specified device name in string.
board: board name in string.
support_feature_set: the feature set supported by the device.
"""
print('\nREPORT FOR DEVICE FEATURE SUPPORT:')
print(f' MODEL: {device}')
print(f' BOARD: {board}')
print(' SUPPORT FEATURES:')
for f in FEATURES:
if f.name in support_feature_set:
print(f' {f.full_name}')
print('END OF REPORT')
def report_feature(feature: Feature, board_feature_dict: dict):
"""Shows the report for the specified feature.
Args:
feature: the Feature object for specified feature.
board_feature_dict: a two-level dict mapping boards and devices to the
supporting feature set.
"""
print(f'\nREPORT FOR FEATURE `{feature.full_name}`:')
print(' BOARD | COVERAGE RATIO |DIST HISTOGRAM | SUPPORT MODELS')
print(' -------------|-------------------|-------------------------|----------------')
for board, device_feature_dict in sorted(board_feature_dict.items()):
num_devices = len(device_feature_dict)
support_devices = sorted(
[d for d, fset in device_feature_dict.items() if feature.name in fset]
)
ratio = (len(support_devices) / num_devices) * 100
coverage_str = f'{ratio:3.2f}% ({len(support_devices):3d}/{num_devices:3d})'
histogram = '*' * len(support_devices) + '.' * (num_devices - len(support_devices))
if len(histogram) > 24:
# clip to the max length
histogram = histogram[:23] + '>'
print(f' {board:12s}|{coverage_str:>18s} |{histogram:24s} | {", ".join(support_devices)}')
print('END OF REPORT')
def get_feature_from_io() -> Optional[str]:
"""Gets feature selected from prompt user I/O.
Returns:
The feature name in string.
Raises:
FeatureSupportArgsError: invalid user input is received.
"""
print('\nAvailable features for query:')
for index, feature in enumerate(ALL_FEATURES, 1):
print(f' ({index}) {feature.full_name}')
user_input = input('Select a feature by entering the number: ')
try:
num = int(user_input)
except ValueError:
raise FeatureSupportArgsError(f'Invalid input on selection: {user_input}')
if 0 < num < len(ALL_FEATURES) + 1:
return ALL_FEATURES[num - 1].name
raise FeatureSupportArgsError(f'Selection out of range: {user_input}')
def main():
parser = argparse.ArgumentParser(
description=PROG_DESCRIPTION,
epilog=PROG_EPILOG,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
'-d',
'--device',
type=str,
help='device name for querying the list of supported features',
)
parser.add_argument(
'-f',
'--feature',
nargs='?',
help='feature name for querying the list of devices with support. If '
'set without following argument, prompt I/O to select feature later',
const=SELECT_WITH_IO,
choices=[f.name for f in ALL_FEATURES] + [SELECT_WITH_IO],
)
parser.add_argument(
'-b',
'--board',
nargs='*',
help='reference board name(s) to be queried',
)
parser.add_argument(
'-o',
'--csv-output',
type=str,
help='output filepath to write the feature support table in CSV format',
)
parser.add_argument(
'-l',
'--overlays-dir',
type=str,
help='path of overlays dir (the root dir, i.e. $CROS_ROOT/src/overlays/), '
'default is the relative path assuming pwd is the same location as this '
'python script',
default='../../../overlays/',
)
parser.add_argument(
'-v',
'--verbose',
action='store_true',
help='run in verbose mode (logging level set to INFO; ERROR in default)',
default=False,
)
parser.add_argument(
'--no-report-stdout',
action='store_false',
dest='report_stdout',
help='run without printing report to stdout (requires "-o CSV_OUTPUT" along)',
)
parser.add_argument(
'--stop-on-error',
action='store_true',
help='terminate once encountering parsing error',
)
args = parser.parse_args()
loglevel = logging.INFO if args.verbose else logging.ERROR
logging.basicConfig(level=loglevel, format='[%(levelname)8s] %(module)s: %(message)s')
error_func = raise_parsing_error if args.stop_on_error else logging.error
# "--no-report-stdout" requires "-o CSV_OUTPUT" along.
if not args.report_stdout and not args.csv_output:
raise FeatureSupportArgsError('"-o CSV_OUTPUT" is required along with "--no-report-stdout"')
# honor "-d DEVICE" to provide feature support list once given.
if args.device:
args.board = None
args.feature = None
# when "-f" is set without following assignment, prompt the user selection I/O
if args.feature == SELECT_WITH_IO:
args.feature = get_feature_from_io()
# device_infos: dict[str, DeviceInfo] with device names as keys
device_infos = get_devices_for_query(args)
# board_feature_dict: dict[str(board), dict[str(device), set]], a two-level
# bucket categorized by board as the 1st key, device name as the 2nd.
board_feature_dict = {}
for device, info in device_infos.items():
try:
feature_set = get_support_feature_set(device, info, error_func)
except Exception as e:
e.add_note(f'While parsing device model `{device}`.')
raise
# update supporting feature set into board_feature_dict
if info.board not in board_feature_dict:
board_feature_dict[info.board] = {}
board_feature_dict[info.board].update({device: feature_set})
log_device_list(board_feature_dict)
if args.csv_output:
write_csv_output(args.csv_output, board_feature_dict)
print(
'\nNote: Tool may have inaccuracy in sparse cases. Please report to '
'johnylin@ when you locate one.'
)
if not args.report_stdout:
return
if args.device:
# report for device
board = device_infos[args.device].board
device_feature_dict = board_feature_dict.get(board)
if (device_feature_dict is None) or (args.device not in device_feature_dict):
raise FeatureSupportParsingError(
f'Unable to parse feature support for device: {args.device}'
)
report_device(args.device, board, device_feature_dict[args.device])
# report unrecognized sound card amp name if it is
report_unrecognized_amps(device_infos, board_feature_dict)
return
# report for features
for feature in ALL_FEATURES:
# if args.feature is set, only report that feature
if (args.feature is None) or (args.feature == feature.name):
report_feature(feature, board_feature_dict)
# report unrecognized sound card amp names if any
if feature.name == 'smart_amp':
report_unrecognized_amps(device_infos, board_feature_dict)
if __name__ == '__main__':
main()