Files
wireplumber/modules/module-mixer-api.c
2023-07-31 13:26:28 +02:00

635 lines
19 KiB
C

/* WirePlumber
*
* Copyright © 2021 Collabora Ltd.
* @author George Kiagiadakis <george.kiagiadakis@collabora.com>
*
* SPDX-License-Identifier: MIT
*/
#include <wp/wp.h>
#include <math.h>
#include <pipewire/pipewire.h>
#include <spa/pod/iter.h>
#include <spa/param/audio/raw.h>
WP_DEFINE_LOCAL_LOG_TOPIC ("m-mixer-api")
struct volume {
uint8_t channels;
float values[SPA_AUDIO_MAX_CHANNELS];
};
struct channel_map {
uint8_t channels;
uint32_t map[SPA_AUDIO_MAX_CHANNELS];
};
struct node_info {
guint32 seq;
guint32 device_id;
gint32 route_index;
gint32 route_device;
struct volume volume;
struct volume monitorVolume;
struct channel_map map;
bool mute;
bool monitorMute;
float svolume;
float base;
float step;
};
struct _WpMixerApi
{
WpPlugin parent;
WpObjectManager *om;
GHashTable *node_infos;
guint32 seq;
/* properties */
gint scale;
};
enum {
ACTION_SET_VOLUME,
ACTION_GET_VOLUME,
SIGNAL_CHANGED,
N_SIGNALS
};
enum {
PROP_0,
PROP_SCALE,
};
static guint signals[N_SIGNALS] = {0};
G_DECLARE_FINAL_TYPE (WpMixerApi, wp_mixer_api, WP, MIXER_API, WpPlugin)
G_DEFINE_TYPE (WpMixerApi, wp_mixer_api, WP_TYPE_PLUGIN)
enum {
SCALE_LINEAR,
SCALE_CUBIC,
};
static GType
wp_mixer_api_volume_scale_enum_get_type (void)
{
static gsize gtype_id = 0;
static const GEnumValue values[] = {
{ (gint) SCALE_LINEAR, "SCALE_LINEAR", "linear" },
{ (gint) SCALE_CUBIC, "SCALE_CUBIC", "cubic" },
{ 0, NULL, NULL }
};
if (g_once_init_enter (&gtype_id)) {
GType new_type = g_enum_register_static (
g_intern_static_string ("WpMixerApiVolumeScale"), values);
g_once_init_leave (&gtype_id, new_type);
}
return (GType) gtype_id;
}
static void
wp_mixer_api_init (WpMixerApi * self)
{
}
static void
wp_mixer_api_get_property (GObject * object, guint property_id,
GValue * value, GParamSpec * pspec)
{
WpMixerApi *self = WP_MIXER_API (object);
switch (property_id) {
case PROP_SCALE:
g_value_set_enum (value, self->scale);
break;
default:
G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec);
break;
}
}
static void
wp_mixer_api_set_property (GObject * object, guint property_id,
const GValue * value, GParamSpec * pspec)
{
WpMixerApi *self = WP_MIXER_API (object);
switch (property_id) {
case PROP_SCALE:
self->scale = g_value_get_enum (value);
break;
default:
G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec);
break;
}
}
static gboolean
node_info_fill (struct node_info * info, WpSpaPod * props)
{
g_autoptr (WpSpaPod) channelVolumes = NULL;
g_autoptr (WpSpaPod) channelMap = NULL;
g_autoptr (WpSpaPod) monitorVolumes = NULL;
if (!wp_spa_pod_get_object (props, NULL,
"mute", "b", &info->mute,
"channelVolumes", "P", &channelVolumes,
NULL))
return FALSE;
/* default values */
info->svolume = 1.0;
info->base = 1.0;
info->step = 1.0 / 65536.0;
wp_spa_pod_get_object (props, NULL,
"channelMap", "?P", &channelMap,
"volumeBase", "?f", &info->base,
"volumeStep", "?f", &info->step,
"volume", "?f", &info->svolume,
"monitorVolumes", "?P", &monitorVolumes,
"monitorMute", "?b", &info->monitorMute,
NULL);
info->volume.channels = spa_pod_copy_array (
wp_spa_pod_get_spa_pod (channelVolumes), SPA_TYPE_Float,
info->volume.values, SPA_AUDIO_MAX_CHANNELS);
if (channelMap)
info->map.channels = spa_pod_copy_array (
wp_spa_pod_get_spa_pod (channelMap), SPA_TYPE_Id,
info->map.map, SPA_AUDIO_MAX_CHANNELS);
if (monitorVolumes)
info->monitorVolume.channels = spa_pod_copy_array (
wp_spa_pod_get_spa_pod (monitorVolumes), SPA_TYPE_Float,
info->monitorVolume.values, SPA_AUDIO_MAX_CHANNELS);
return TRUE;
}
static void
collect_node_info (WpMixerApi * self, struct node_info *info,
WpPipewireObject * node)
{
g_autoptr (WpPipewireObject) dev = NULL;
const gchar *str = NULL;
gboolean have_volume = FALSE;
info->device_id = SPA_ID_INVALID;
info->route_index = -1;
info->route_device = -1;
if ((str = wp_pipewire_object_get_property (node, PW_KEY_DEVICE_ID))) {
dev = wp_object_manager_lookup (self->om, WP_TYPE_DEVICE,
WP_CONSTRAINT_TYPE_G_PROPERTY, "bound-id", "=s", str, NULL);
}
if (dev && (str = wp_pipewire_object_get_property (node, "card.profile.device"))) {
gint32 p_device = atoi (str);
g_autoptr (WpIterator) it = NULL;
g_auto (GValue) val = G_VALUE_INIT;
it = wp_pipewire_object_enum_params_sync (dev, "Route", NULL);
for (; it && wp_iterator_next (it, &val); g_value_unset (&val)) {
WpSpaPod *param = g_value_get_boxed (&val);
gint32 r_index = -1, r_device = -1;
g_autoptr (WpSpaPod) props = NULL;
if (!wp_spa_pod_get_object (param, NULL,
"index", "i", &r_index,
"device", "i", &r_device,
"props", "P", &props,
NULL))
continue;
if (r_device != p_device)
continue;
if (props && node_info_fill (info, props)) {
info->device_id = wp_proxy_get_bound_id (WP_PROXY (dev));
info->route_index = r_index;
info->route_device = r_device;
have_volume = TRUE;
g_value_unset (&val);
break;
}
}
}
if (!have_volume) {
g_autoptr (WpIterator) it = NULL;
g_auto (GValue) val = G_VALUE_INIT;
it = wp_pipewire_object_enum_params_sync (node, "Props", NULL);
for (; it && wp_iterator_next (it, &val); g_value_unset (&val)) {
WpSpaPod *param = g_value_get_boxed (&val);
if (node_info_fill (info, param)) {
g_value_unset (&val);
break;
}
}
}
}
static void on_objects_changed (WpObjectManager * om, WpMixerApi * self);
static void
on_sync_done (WpCore * core, GAsyncResult * res, WpMixerApi * self)
{
g_autoptr (GError) error = NULL;
if (!wp_core_sync_finish (core, res, &error))
wp_warning_object (core, "sync error: %s", error->message);
if (self->om) {
on_objects_changed (self->om, self);
}
}
static void
on_params_changed (WpPipewireObject * obj, const gchar * param_name,
WpMixerApi * self)
{
if ((WP_IS_NODE (obj) && !g_strcmp0 (param_name, "Props")) ||
(WP_IS_DEVICE (obj) && !g_strcmp0 (param_name, "Route"))) {
g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (self));
wp_core_sync (core, NULL, (GAsyncReadyCallback) on_sync_done, self);
}
}
static void
on_objects_changed (WpObjectManager * om, WpMixerApi * self)
{
g_autoptr (WpIterator) it =
wp_object_manager_new_filtered_iterator (om, WP_TYPE_NODE, NULL);
g_auto (GValue) val = G_VALUE_INIT;
GHashTableIter infos_it;
struct node_info *info;
struct node_info old;
self->seq++;
for (; wp_iterator_next (it, &val); g_value_unset (&val)) {
WpPipewireObject *node = g_value_get_object (&val);
guint id = wp_proxy_get_bound_id (WP_PROXY (node));
info = g_hash_table_lookup (self->node_infos, GUINT_TO_POINTER (id));
if (!info) {
info = g_slice_new0 (struct node_info);
g_hash_table_insert (self->node_infos, GUINT_TO_POINTER (id), info);
}
info->seq = self->seq;
old = *info;
collect_node_info (self, info, node);
if (memcmp (&old, info, sizeof (struct node_info)) != 0) {
wp_debug_object (self, "node %u changed volume props", id);
g_signal_emit (self, signals[SIGNAL_CHANGED], 0, id);
}
}
/* remove node_info of nodes that were removed from the object manager */
g_hash_table_iter_init (&infos_it, self->node_infos);
while (g_hash_table_iter_next (&infos_it, NULL, (gpointer *) &info)) {
if (info->seq != self->seq)
g_hash_table_iter_remove (&infos_it);
}
}
static void
on_object_added (WpObjectManager * om, WpProxy * obj, WpMixerApi * self)
{
g_signal_connect (obj, "params-changed", G_CALLBACK (on_params_changed), self);
}
static void
on_object_removed (WpObjectManager * om, WpProxy * obj, WpMixerApi * self)
{
g_signal_handlers_disconnect_by_func (obj, G_CALLBACK (on_params_changed), self);
}
static void
node_info_free (gpointer info)
{
g_slice_free (struct node_info, info);
}
static void
on_om_installed (WpObjectManager * om, WpMixerApi * self)
{
wp_object_update_features (WP_OBJECT (self), WP_PLUGIN_FEATURE_ENABLED, 0);
}
static void
wp_mixer_api_enable (WpPlugin * plugin, WpTransition * transition)
{
WpMixerApi * self = WP_MIXER_API (plugin);
g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (plugin));
g_return_if_fail (core);
self->node_infos = g_hash_table_new_full (g_direct_hash, g_direct_equal,
NULL, node_info_free);
self->om = wp_object_manager_new ();
wp_object_manager_add_interest (self->om, WP_TYPE_NODE,
WP_CONSTRAINT_TYPE_PW_GLOBAL_PROPERTY, "media.class", "#s", "*Audio*",
NULL);
wp_object_manager_add_interest (self->om, WP_TYPE_DEVICE,
WP_CONSTRAINT_TYPE_PW_GLOBAL_PROPERTY, "media.class", "=s", "Audio/Device",
NULL);
wp_object_manager_request_object_features (self->om,
WP_TYPE_GLOBAL_PROXY, WP_OBJECT_FEATURES_ALL);
g_signal_connect_object (self->om, "objects-changed",
G_CALLBACK (on_objects_changed), self, 0);
g_signal_connect_object (self->om, "object-added",
G_CALLBACK (on_object_added), self, 0);
g_signal_connect_object (self->om, "object-removed",
G_CALLBACK (on_object_removed), self, 0);
g_signal_connect_object (self->om, "installed",
G_CALLBACK (on_om_installed), self, 0);
wp_core_install_object_manager (core, self->om);
}
static void
wp_mixer_api_disable (WpPlugin * plugin)
{
WpMixerApi * self = WP_MIXER_API (plugin);
{
g_autoptr (WpIterator) it = wp_object_manager_new_iterator (self->om);
g_auto (GValue) val = G_VALUE_INIT;
for (; wp_iterator_next (it, &val); g_value_unset (&val)) {
WpProxy *obj = g_value_get_object (&val);
on_object_removed (self->om, obj, self);
}
}
g_clear_object (&self->om);
g_clear_pointer (&self->node_infos, g_hash_table_unref);
}
static inline gdouble
volume_from_linear (float vol, gint scale)
{
if (vol <= 0.0f)
return 0.0;
else if (scale == SCALE_CUBIC)
return cbrt(vol);
else
return vol;
}
static inline float
volume_to_linear (gdouble vol, gint scale)
{
if (vol <= 0.0f)
return 0.0;
else if (scale == SCALE_CUBIC)
return vol * vol * vol;
else
return vol;
}
static gboolean
wp_mixer_api_set_volume (WpMixerApi * self, guint32 id, GVariant * vvolume)
{
struct node_info *info = self->node_infos ?
g_hash_table_lookup (self->node_infos, GUINT_TO_POINTER (id)) : NULL;
struct volume new_volume = {0};
struct volume new_monVolume = {0};
gboolean has_mute = FALSE;
gboolean has_monitorMute = FALSE;
gboolean mute = FALSE;
gboolean monitorMute = FALSE;
WpSpaIdTable t_audioChannel =
wp_spa_id_table_from_name ("Spa:Enum:AudioChannel");
if (!info || !vvolume)
return FALSE;
if (g_variant_is_of_type (vvolume, G_VARIANT_TYPE_DOUBLE)) {
gdouble val = g_variant_get_double (vvolume);
new_volume = info->volume;
for (uint i = 0; i < new_volume.channels; i++)
new_volume.values[i] = volume_to_linear (val, self->scale);
}
else if (g_variant_is_of_type (vvolume, G_VARIANT_TYPE_VARDICT)) {
GVariantIter *iter;
const gchar *idx_str;
GVariant *v;
gdouble val;
has_mute = g_variant_lookup (vvolume, "mute", "b", &mute);
has_monitorMute = g_variant_lookup (vvolume, "monitorMute", "b", &monitorMute);
if (g_variant_lookup (vvolume, "volume", "d", &val)) {
new_volume = info->volume;
for (uint i = 0; i < new_volume.channels; i++)
new_volume.values[i] = volume_to_linear (val, self->scale);
}
if (g_variant_lookup (vvolume, "monitorVolume", "d", &val)) {
new_monVolume = info->monitorVolume;
for (uint i = 0; i < new_monVolume.channels; i++)
new_monVolume.values[i] = volume_to_linear (val, self->scale);
}
if (g_variant_lookup (vvolume, "channelVolumes", "a{sv}", &iter)) {
/* keep the existing volume values for unspecified channels */
new_volume = info->volume;
new_monVolume = info->monitorVolume;
while (g_variant_iter_loop (iter, "{&sv}", &idx_str, &v)) {
guint index = atoi (idx_str);
const gchar *channel_str = NULL;
WpSpaIdValue channel = NULL;
if (g_variant_lookup (v, "channel", "&s", &channel_str)) {
channel = wp_spa_id_table_find_value_from_short_name (
t_audioChannel, channel_str);
if (!channel)
wp_notice_object (self, "invalid channel: %s", channel_str);
}
if (channel) {
for (uint i = 0; i < info->map.channels; i++)
if (info->map.map[i] == wp_spa_id_value_number (channel)) {
index = i;
break;
}
}
if (index >= MIN(new_volume.channels, SPA_AUDIO_MAX_CHANNELS)) {
wp_notice_object (self, "invalid channel index: %u", index);
continue;
}
if (g_variant_lookup (v, "volume", "d", &val)) {
new_volume.values[index] = volume_to_linear (val, self->scale);
}
if (g_variant_lookup (v, "monitorVolume", "d", &val)) {
new_monVolume.values[index] = volume_to_linear (val, self->scale);
}
}
g_variant_iter_free (iter);
}
} else {
return FALSE;
}
/* set param */
g_autoptr (WpSpaPod) props = NULL;
g_autoptr (WpSpaPodBuilder) b =
wp_spa_pod_builder_new_object ("Spa:Pod:Object:Param:Props", "Props");
if (new_volume.channels > 0)
wp_spa_pod_builder_add (b, "channelVolumes", "a",
sizeof(float), SPA_TYPE_Float,
new_volume.channels, new_volume.values, NULL);
if (new_monVolume.channels > 0)
wp_spa_pod_builder_add (b, "monitorVolumes", "a",
sizeof(float), SPA_TYPE_Float,
new_monVolume.channels, new_monVolume.values, NULL);
if (has_mute)
wp_spa_pod_builder_add (b, "mute", "b", mute, NULL);
if (has_monitorMute)
wp_spa_pod_builder_add (b, "monitorMute", "b", monitorMute, NULL);
props = wp_spa_pod_builder_end (b);
if (info->device_id != SPA_ID_INVALID) {
WpPipewireObject *device = wp_object_manager_lookup (self->om,
WP_TYPE_DEVICE, WP_CONSTRAINT_TYPE_G_PROPERTY,
"bound-id", "=u", info->device_id, NULL);
g_return_val_if_fail (device != NULL, FALSE);
wp_pipewire_object_set_param (device, "Route", 0, wp_spa_pod_new_object (
"Spa:Pod:Object:Param:Route", "Route",
"index", "i", info->route_index,
"device", "i", info->route_device,
"props", "P", props,
"save", "b", true,
NULL));
} else {
WpPipewireObject *node = wp_object_manager_lookup (self->om,
WP_TYPE_NODE, WP_CONSTRAINT_TYPE_G_PROPERTY,
"bound-id", "=u", id, NULL);
g_return_val_if_fail (node != NULL, FALSE);
wp_pipewire_object_set_param (node, "Props", 0, g_steal_pointer (&props));
}
return TRUE;
}
static GVariant *
wp_mixer_api_get_volume (WpMixerApi * self, guint32 id)
{
struct node_info *info = self->node_infos ?
g_hash_table_lookup (self->node_infos, GUINT_TO_POINTER (id)) : NULL;
g_auto (GVariantBuilder) b =
G_VARIANT_BUILDER_INIT (G_VARIANT_TYPE_VARDICT);
g_auto (GVariantBuilder) b_vol =
G_VARIANT_BUILDER_INIT (G_VARIANT_TYPE_VARDICT);
WpSpaIdTable t_audioChannel =
wp_spa_id_table_from_name ("Spa:Enum:AudioChannel");
if (!info)
return NULL;
g_variant_builder_add (&b, "{sv}", "id", g_variant_new_uint32 (id));
g_variant_builder_add (&b, "{sv}", "mute", g_variant_new_boolean (info->mute));
g_variant_builder_add (&b, "{sv}", "base", g_variant_new_double (info->base));
g_variant_builder_add (&b, "{sv}", "step", g_variant_new_double (info->step));
g_variant_builder_add (&b, "{sv}", "volume", g_variant_new_double (
volume_from_linear ((info->volume.channels > 0) ?
info->volume.values[0] : info->svolume, self->scale)));
if (info->monitorVolume.channels > 0) {
g_variant_builder_add (&b, "{sv}", "monitorVolume", g_variant_new_double (
volume_from_linear (info->monitorVolume.values[0], self->scale)));
}
g_variant_builder_add (&b, "{sv}", "monitorMute", g_variant_new_boolean (info->monitorMute));
for (guint i = 0; i < info->volume.channels; i++) {
gchar index_str[10];
g_auto (GVariantBuilder) b_vol_nested =
G_VARIANT_BUILDER_INIT (G_VARIANT_TYPE_VARDICT);
g_variant_builder_add (&b_vol_nested, "{sv}",
"volume", g_variant_new_double (
volume_from_linear (info->volume.values[i], self->scale)));
if (i < info->map.channels) {
WpSpaIdValue v =
wp_spa_id_table_find_value (t_audioChannel, info->map.map[i]);
if (v) {
const gchar *channel_str = wp_spa_id_value_short_name (v);
g_variant_builder_add (&b_vol_nested, "{sv}",
"channel", g_variant_new_string (channel_str));
}
}
if (i < info->monitorVolume.channels) {
g_variant_builder_add (&b_vol_nested, "{sv}",
"monitorVolume", g_variant_new_double (
volume_from_linear (info->monitorVolume.values[i], self->scale)));
}
g_snprintf (index_str, 10, "%u", i);
g_variant_builder_add (&b_vol, "{sv}", index_str,
g_variant_builder_end (&b_vol_nested));
}
g_variant_builder_add (&b, "{sv}",
"channelVolumes", g_variant_builder_end (&b_vol));
return g_variant_builder_end (&b);
}
static void
wp_mixer_api_class_init (WpMixerApiClass * klass)
{
GObjectClass *object_class = (GObjectClass *) klass;
WpPluginClass *plugin_class = (WpPluginClass *) klass;
object_class->set_property = wp_mixer_api_set_property;
object_class->get_property = wp_mixer_api_get_property;
plugin_class->enable = wp_mixer_api_enable;
plugin_class->disable = wp_mixer_api_disable;
g_object_class_install_property (object_class, PROP_SCALE,
g_param_spec_enum ("scale", "scale", "scale",
wp_mixer_api_volume_scale_enum_get_type (),
SCALE_LINEAR, G_PARAM_READWRITE | G_PARAM_STATIC_STRINGS));
signals[ACTION_SET_VOLUME] = g_signal_new_class_handler (
"set-volume", G_TYPE_FROM_CLASS (klass),
G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
(GCallback) wp_mixer_api_set_volume,
NULL, NULL, NULL,
G_TYPE_BOOLEAN, 2, G_TYPE_UINT, G_TYPE_VARIANT);
signals[ACTION_GET_VOLUME] = g_signal_new_class_handler (
"get-volume", G_TYPE_FROM_CLASS (klass),
G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
(GCallback) wp_mixer_api_get_volume,
NULL, NULL, NULL,
G_TYPE_VARIANT, 1, G_TYPE_UINT);
signals[SIGNAL_CHANGED] = g_signal_new (
"changed", G_TYPE_FROM_CLASS (klass),
G_SIGNAL_RUN_LAST, 0, NULL, NULL, NULL, G_TYPE_NONE, 1, G_TYPE_UINT);
}
WP_PLUGIN_EXPORT GObject *
wireplumber__module_init (WpCore * core, WpSpaJson * args, GError ** error)
{
return G_OBJECT (g_object_new (wp_mixer_api_get_type (),
"name", "mixer-api",
"core", core,
NULL));
}