Hi,

Here is my first draft of a filter module to automatically load
equalizer and/or echo-cancel modules if automagically and in a manual
but convenient way.

Essentially you just set the "filter.want" property on the stream and it
should be magically used. It just tries to load a module with the same
name as the filter.want value but with "module-" prepended to it. So if
you set filter.want=echo-cancel, it will be automatically loaded and the
stream will be moved to it. When the filter sink is no longer needed it
will be automatically unloaded.

For the automagical bit, a separate module will simply automatically set
the filter.want property and set it to echo-cancel for phone streams. If
you want to suppress the automatic loading of a filter sink module, just
set filter.suppress property equal to the module you want to suppress.
e.g. Skype should probably set filter.suppress=echo-cancel.

One thing that I've not really considered is how to deal with the fact
that echo-cancel is probably only needed when the external mic and
speakers are used.... so there may need to be more smarts added to deal
with this, but I'm not really all that clued up on when this should or
shouldn't be used.

I plan to add a couple buttons next to streams in pavucontrol that
toggles manually the equalizer and maybe also echo-cancel too.


Comments welcome. I've not really self-reviewed it too much, so probably
a couple howlers in there :) (I'll move the prop name defines into the
central file that has some common property names before committing)

Col



-- 

Colin Guthrie
gmane(at)colin.guthr.ie
http://colin.guthr.ie/

Day Job:
  Tribalogic Limited [http://www.tribalogic.net/]
Open Source:
  Mageia Contributor [http://www.mageia.org/]
  PulseAudio Hacker [http://www.pulseaudio.org/]
  Trac Hacker [http://trac.edgewall.org/]
>From 328dc6d61bf96246cb5a0e51106d23b58734b810 Mon Sep 17 00:00:00 2001
From: Colin Guthrie <co...@mageia.org>
Date: Thu, 14 Apr 2011 13:04:03 +0200
Subject: [PATCH 1/2] filter-apply: New module to automatically load filter sinks (and move streams) based on sink-input property hints.

This module does not yet deal with modules that need matched inputs/outputs
(i.e. echo-cancel) but this will be added in due course.
---
 src/Makefile.am                   |   10 +-
 src/modules/module-filter-apply.c |  400 +++++++++++++++++++++++++++++++++++++
 2 files changed, 408 insertions(+), 2 deletions(-)
 create mode 100644 src/modules/module-filter-apply.c

diff --git a/src/Makefile.am b/src/Makefile.am
index 38fb569..78076f6 100644
--- a/src/Makefile.am
+++ b/src/Makefile.am
@@ -1053,7 +1053,8 @@ modlibexec_LTLIBRARIES += \
 		module-loopback.la \
 		module-virtual-sink.la \
 		module-virtual-source.la \
-		module-switch-on-connect.la
+		module-switch-on-connect.la \
+		module-filter-apply.la
 
 # See comment at librtp.la above
 if !OS_IS_WIN32
@@ -1341,7 +1342,8 @@ SYMDEF_FILES = \
 		module-loopback-symdef.h \
 		module-virtual-sink-symdef.h \
 		module-virtual-source-symdef.h \
-		module-switch-on-connect-symdef.h
+		module-switch-on-connect-symdef.h \
+		module-filter-apply-symdef.h
 
 EXTRA_DIST += $(SYMDEF_FILES)
 BUILT_SOURCES += $(SYMDEF_FILES) builddirs
@@ -1487,6 +1489,10 @@ module_switch_on_connect_la_SOURCES = modules/module-switch-on-connect.c
 module_switch_on_connect_la_LDFLAGS = $(MODULE_LDFLAGS)
 module_switch_on_connect_la_LIBADD = $(MODULE_LIBADD)
 
+module_filter_apply_la_SOURCES = modules/module-filter-apply.c
+module_filter_apply_la_LDFLAGS = $(MODULE_LDFLAGS)
+module_filter_apply_la_LIBADD = $(MODULE_LIBADD)
+
 module_remap_sink_la_SOURCES = modules/module-remap-sink.c
 module_remap_sink_la_LDFLAGS = $(MODULE_LDFLAGS)
 module_remap_sink_la_LIBADD = $(MODULE_LIBADD)
diff --git a/src/modules/module-filter-apply.c b/src/modules/module-filter-apply.c
new file mode 100644
index 0000000..d4bded5
--- /dev/null
+++ b/src/modules/module-filter-apply.c
@@ -0,0 +1,400 @@
+/***
+  This file is part of PulseAudio.
+
+  Copyright 2011 Colin Guthrie
+
+  PulseAudio is free software; you can redistribute it and/or modify
+  it under the terms of the GNU Lesser General Public License as published
+  by the Free Software Foundation; either version 2.1 of the License,
+  or (at your option) any later version.
+
+  PulseAudio is distributed in the hope that it will be useful, but
+  WITHOUT ANY WARRANTY; without even the implied warranty of
+  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+  General Public License for more details.
+
+  You should have received a copy of the GNU Lesser General Public License
+  along with PulseAudio; if not, write to the Free Software
+  Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
+  USA.
+***/
+
+#ifdef HAVE_CONFIG_H
+#include <config.h>
+#endif
+
+#include <pulse/timeval.h>
+#include <pulse/rtclock.h>
+
+#include <pulsecore/macro.h>
+#include <pulsecore/hashmap.h>
+#include <pulsecore/hook-list.h>
+#include <pulsecore/core.h>
+#include <pulsecore/core-util.h>
+#include <pulsecore/sink-input.h>
+#include <pulsecore/modargs.h>
+
+#include "module-filter-apply-symdef.h"
+
+#define PA_PROP_FILTER_WANT "filter.want"
+#define PA_PROP_FILTER_SUPPRESS "filter.suppress"
+
+
+PA_MODULE_AUTHOR("Colin Guthrie");
+PA_MODULE_DESCRIPTION("Load filter sinks automatically when needed");
+PA_MODULE_VERSION(PACKAGE_VERSION);
+PA_MODULE_LOAD_ONCE(TRUE);
+
+static const char* const valid_modargs[] = {
+    NULL
+};
+
+#define HOUSEKEEPING_INTERVAL (10 * PA_USEC_PER_SEC)
+
+struct filter {
+    char *name;
+    pa_sink* parent_sink;
+    uint32_t module_index;
+    pa_sink* sink;
+};
+
+struct userdata {
+    pa_core *core;
+    pa_hashmap *filters;
+    pa_hook_slot
+        *sink_input_put_slot,
+        *sink_input_proplist_slot,
+        *sink_input_unlink_slot,
+        *sink_unlink_slot;
+    pa_time_event *housekeeping_time_event;
+};
+
+static unsigned filter_hash(const void *p) {
+    const struct filter *f = p;
+
+    return
+        (unsigned) f->parent_sink->index +
+        pa_idxset_string_hash_func(f->name);
+}
+
+static int filter_compare(const void *a, const void *b) {
+    const struct filter *fa = a, *fb = b;
+    int r;
+
+    if (fa->parent_sink != fb->parent_sink)
+        return 1;
+    if ((r = strcmp(fa->name, fb->name)))
+        return r;
+
+    return 0;
+}
+
+static struct filter *filter_new(const char *name, pa_sink* parent_sink) {
+    struct filter *f;
+
+    f = pa_xnew(struct filter, 1);
+    f->name = pa_xstrdup(name);
+    pa_assert_se(f->parent_sink = parent_sink);
+    f->module_index = PA_INVALID_INDEX;
+    f->sink = NULL;
+    return f;
+}
+
+static void filter_free(struct filter *f) {
+    pa_assert(f);
+
+    pa_xfree(f->name);
+    pa_xfree(f);
+}
+
+static const char* should_filter(pa_sink_input *i) {
+    const char *want;
+
+    /* If the stream doesn't what any filter, then let it be. */
+    if ((want = pa_proplist_gets(i->proplist, PA_PROP_FILTER_WANT)) && !pa_streq(want, "")) {
+        const char* suppress = pa_proplist_gets(i->proplist, PA_PROP_FILTER_SUPPRESS);
+
+        if (!suppress || !pa_streq(suppress, want))
+            return want;
+    }
+
+    return NULL;
+}
+
+static void housekeeping_time_callback(pa_mainloop_api*a, pa_time_event* e, const struct timeval *t, void *userdata) {
+    struct userdata *u = userdata;
+    struct filter *filter;
+    void *state;
+
+    pa_assert(a);
+    pa_assert(e);
+    pa_assert(u);
+
+    pa_assert(e == u->housekeeping_time_event);
+    u->core->mainloop->time_free(u->housekeeping_time_event);
+    u->housekeeping_time_event = NULL;
+
+    PA_HASHMAP_FOREACH(filter, u->filters, state) {
+        if (filter->sink && pa_idxset_size(filter->sink->inputs) == 0) {
+            uint32_t idx;
+
+            pa_log_debug("Detected filter %s as no longer used on sink %s. Unloading.", filter->name, filter->sink->name);
+            idx = filter->module_index;
+            pa_hashmap_remove(u->filters, filter);
+            filter_free(filter);
+            pa_module_unload_request_by_index(u->core, idx, TRUE);
+        }
+    }
+
+    pa_log_info("Housekeeping Done.");
+}
+
+static void trigger_housekeeping(struct userdata *u) {
+    pa_assert(u);
+
+    if (u->housekeeping_time_event)
+        return;
+
+    u->housekeeping_time_event = pa_core_rttime_new(u->core, pa_rtclock_now() + HOUSEKEEPING_INTERVAL, housekeeping_time_callback, u);
+}
+
+static void move_input_for_filter(pa_sink_input *i, struct filter* filter, pa_bool_t restore) {
+    pa_sink *sink;
+
+    pa_assert(i);
+    pa_assert(filter);
+
+    pa_assert_se(sink = (restore ? filter->parent_sink : filter->sink));
+
+    if (pa_sink_input_move_to(i, sink, FALSE) < 0)
+        pa_log_info("Failed to move sink input %u \"%s\" to <%s>.", i->index,
+                    pa_strnull(pa_proplist_gets(i->proplist, PA_PROP_APPLICATION_NAME)), sink->name);
+    else
+        pa_log_info("Sucessfully moved sink input %u \"%s\" to <%s>.", i->index,
+                    pa_strnull(pa_proplist_gets(i->proplist, PA_PROP_APPLICATION_NAME)), sink->name);
+}
+
+static pa_hook_result_t process(struct userdata *u, pa_sink_input *i) {
+    const char *want;
+    pa_bool_t done_something = FALSE;
+
+    pa_assert(u);
+    pa_sink_input_assert_ref(i);
+
+    /* If there is no sink yet, we can't do much */
+    if (!i->sink)
+        return PA_HOOK_OK;
+
+    /* If the stream doesn't what any filter, then let it be. */
+    if ((want = should_filter(i))) {
+        char *module_name;
+        struct filter *fltr, *filter;
+
+        /* We need to ensure the SI is playing on a sink of this type
+         * attached to the sink it's "officially" playing on */
+
+        if (!i->sink->module)
+            return PA_HOOK_OK;
+
+        module_name = pa_sprintf_malloc("module-%s", want);
+        if (pa_streq(i->sink->module->name, module_name)) {
+            pa_log_debug("Stream appears to be playing on an appropriate sink already. Ignoring.");
+            pa_xfree(module_name);
+            return PA_HOOK_OK;
+        }
+
+        fltr = filter_new(want, i->sink);
+
+        if (!(filter = pa_hashmap_get(u->filters, fltr))) {
+            char *args;
+            pa_module *m;
+
+            args = pa_sprintf_malloc("sink_master=%s", i->sink->name);
+            pa_log_debug("Loading %s with arguments '%s'", module_name, args);
+
+            if ((m = pa_module_load(u->core, module_name, args))) {
+                uint32_t idx;
+                pa_sink *sink;
+
+                fltr->module_index = m->index;
+                /* We cannot use the SINK_PUT hook here to detect our sink as it'll
+                 * be called during the module load so we wont yet have put the filter
+                 * in our hashmap to compare... so we have to search for it */
+                PA_IDXSET_FOREACH(sink, u->core->sinks, idx) {
+                    if (sink->module == m) {
+                        fltr->sink = sink;
+                        break;
+                    }
+                }
+                pa_hashmap_put(u->filters, fltr, fltr);
+                filter = fltr;
+                fltr = NULL;
+                done_something = TRUE;
+            }
+            pa_xfree(args);
+        }
+        pa_xfree(fltr);
+
+        if (!filter) {
+            pa_log("Unable to load %s for sink <%s>", module_name, i->sink->name);
+            pa_xfree(module_name);
+            return PA_HOOK_OK;
+        }
+        pa_xfree(module_name);
+
+        if (filter->sink) {
+            /* We can move the sink_input now as the know the destination.
+             * If this isn't true, we will do it later when the sink appears. */
+            move_input_for_filter(i, filter, FALSE);
+            done_something = TRUE;
+        }
+    } else {
+        void *state;
+        struct filter *filter = NULL;
+
+        /* We do not want to filter... but are we already filtered?
+         * This can happen if an input's proplist changes */
+        PA_HASHMAP_FOREACH(filter, u->filters, state) {
+            if (i->sink == filter->sink) {
+                move_input_for_filter(i, filter, TRUE);
+                done_something = TRUE;
+                break;
+            }
+        }
+    }
+
+    if (done_something)
+        trigger_housekeeping(u);
+
+    return PA_HOOK_OK;
+}
+
+static pa_hook_result_t sink_input_put_cb(pa_core *core, pa_sink_input *i, struct userdata *u) {
+    pa_core_assert_ref(core);
+    pa_sink_input_assert_ref(i);
+
+    return process(u, i);
+}
+
+static pa_hook_result_t sink_input_proplist_cb(pa_core *core, pa_sink_input *i, struct userdata *u) {
+    pa_core_assert_ref(core);
+    pa_sink_input_assert_ref(i);
+
+    return process(u, i);
+}
+
+static pa_hook_result_t sink_input_unlink_cb(pa_core *core, pa_sink_input *i, struct userdata *u) {
+    pa_core_assert_ref(core);
+    pa_sink_input_assert_ref(i);
+
+    pa_assert(u);
+
+    if (pa_hashmap_size(u->filters) > 0)
+        trigger_housekeeping(u);
+
+    return PA_HOOK_OK;
+}
+
+static pa_hook_result_t sink_unlink_cb(pa_core *core, pa_sink *sink, struct userdata *u) {
+    void *state;
+    struct filter *filter = NULL;
+
+    pa_core_assert_ref(core);
+    pa_sink_assert_ref(sink);
+    pa_assert(u);
+
+    /* If either the parent or the sink we've loaded disappears,
+     * we should remove it from our hashmap */
+    PA_HASHMAP_FOREACH(filter, u->filters, state) {
+        if (filter->parent_sink == sink || filter->sink == sink) {
+            uint32_t idx;
+
+            /* Attempt to rescue any streams to the parent sink as this is likely
+             * the best course of action (as opposed to a generic rescue via
+             * module-rescue-streams */
+            if (filter->sink == sink) {
+                pa_sink_input *i;
+
+                PA_IDXSET_FOREACH(i, sink->inputs, idx)
+                    move_input_for_filter(i, filter, TRUE);
+            }
+
+            idx = filter->module_index;
+            pa_hashmap_remove(u->filters, filter);
+            filter_free(filter);
+            pa_module_unload_request_by_index(u->core, idx, TRUE);
+        }
+    }
+
+    return PA_HOOK_OK;
+}
+
+
+int pa__init(pa_module *m) {
+    pa_modargs *ma = NULL;
+    struct userdata *u;
+
+    pa_assert(m);
+
+    if (!(ma = pa_modargs_new(m->argument, valid_modargs))) {
+        pa_log("Failed to parse module arguments");
+        goto fail;
+    }
+
+    m->userdata = u = pa_xnew0(struct userdata, 1);
+
+    u->core = m->core;
+
+    u->filters = pa_hashmap_new(filter_hash, filter_compare);
+
+    u->sink_input_put_slot = pa_hook_connect(&m->core->hooks[PA_CORE_HOOK_SINK_INPUT_PUT], PA_HOOK_LATE, (pa_hook_cb_t) sink_input_put_cb, u);
+    u->sink_input_proplist_slot = pa_hook_connect(&m->core->hooks[PA_CORE_HOOK_SINK_INPUT_PROPLIST_CHANGED], PA_HOOK_LATE, (pa_hook_cb_t) sink_input_proplist_cb, u);
+    u->sink_input_unlink_slot = pa_hook_connect(&m->core->hooks[PA_CORE_HOOK_SINK_INPUT_UNLINK], PA_HOOK_LATE, (pa_hook_cb_t) sink_input_unlink_cb, u);
+    u->sink_unlink_slot = pa_hook_connect(&m->core->hooks[PA_CORE_HOOK_SINK_UNLINK], PA_HOOK_LATE, (pa_hook_cb_t) sink_unlink_cb, u);
+
+    pa_modargs_free(ma);
+
+    return 0;
+
+fail:
+    pa__done(m);
+
+    if (ma)
+        pa_modargs_free(ma);
+
+    return -1;
+}
+
+void pa__done(pa_module *m) {
+    struct userdata* u;
+
+    pa_assert(m);
+
+    if (!(u = m->userdata))
+        return;
+
+    if (u->sink_input_put_slot)
+        pa_hook_slot_free(u->sink_input_put_slot);
+    if (u->sink_input_proplist_slot)
+        pa_hook_slot_free(u->sink_input_proplist_slot);
+    if (u->sink_input_unlink_slot)
+        pa_hook_slot_free(u->sink_input_unlink_slot);
+    if (u->sink_unlink_slot)
+        pa_hook_slot_free(u->sink_unlink_slot);
+
+    if (u->housekeeping_time_event)
+        u->core->mainloop->time_free(u->housekeeping_time_event);
+
+    if (u->filters) {
+        struct filter *f;
+
+        while ((f = pa_hashmap_steal_first(u->filters))) {
+            pa_module_unload_request_by_index(u->core, f->module_index, TRUE);
+            filter_free(f);
+        }
+
+        pa_hashmap_free(u->filters, NULL, NULL);
+    }
+
+    pa_xfree(u);
+}
-- 
1.7.4.3

>From 051af263aed71326b24eeea0ea35aaecf30017ae Mon Sep 17 00:00:00 2001
From: Colin Guthrie <co...@mageia.org>
Date: Thu, 14 Apr 2011 13:05:45 +0200
Subject: [PATCH 2/2] filter-heuristics: New module that applies some basic heuristics regarding filters.

At present the only heuristic is one to apply the echo-cancel filter
when dealing with phone streams.
---
 src/Makefile.am                        |   10 ++-
 src/modules/module-filter-heuristics.c |  117 ++++++++++++++++++++++++++++++++
 2 files changed, 125 insertions(+), 2 deletions(-)
 create mode 100644 src/modules/module-filter-heuristics.c

diff --git a/src/Makefile.am b/src/Makefile.am
index 78076f6..fb846cd 100644
--- a/src/Makefile.am
+++ b/src/Makefile.am
@@ -1054,7 +1054,8 @@ modlibexec_LTLIBRARIES += \
 		module-virtual-sink.la \
 		module-virtual-source.la \
 		module-switch-on-connect.la \
-		module-filter-apply.la
+		module-filter-apply.la \
+		module-filter-heuristics.la
 
 # See comment at librtp.la above
 if !OS_IS_WIN32
@@ -1343,7 +1344,8 @@ SYMDEF_FILES = \
 		module-virtual-sink-symdef.h \
 		module-virtual-source-symdef.h \
 		module-switch-on-connect-symdef.h \
-		module-filter-apply-symdef.h
+		module-filter-apply-symdef.h \
+		module-filter-heuristics-symdef.h
 
 EXTRA_DIST += $(SYMDEF_FILES)
 BUILT_SOURCES += $(SYMDEF_FILES) builddirs
@@ -1493,6 +1495,10 @@ module_filter_apply_la_SOURCES = modules/module-filter-apply.c
 module_filter_apply_la_LDFLAGS = $(MODULE_LDFLAGS)
 module_filter_apply_la_LIBADD = $(MODULE_LIBADD)
 
+module_filter_heuristics_la_SOURCES = modules/module-filter-heuristics.c
+module_filter_heuristics_la_LDFLAGS = $(MODULE_LDFLAGS)
+module_filter_heuristics_la_LIBADD = $(MODULE_LIBADD)
+
 module_remap_sink_la_SOURCES = modules/module-remap-sink.c
 module_remap_sink_la_LDFLAGS = $(MODULE_LDFLAGS)
 module_remap_sink_la_LIBADD = $(MODULE_LIBADD)
diff --git a/src/modules/module-filter-heuristics.c b/src/modules/module-filter-heuristics.c
new file mode 100644
index 0000000..fb01f85
--- /dev/null
+++ b/src/modules/module-filter-heuristics.c
@@ -0,0 +1,117 @@
+/***
+  This file is part of PulseAudio.
+
+  Copyright 2011 Colin Guthrie
+
+  PulseAudio is free software; you can redistribute it and/or modify
+  it under the terms of the GNU Lesser General Public License as published
+  by the Free Software Foundation; either version 2.1 of the License,
+  or (at your option) any later version.
+
+  PulseAudio is distributed in the hope that it will be useful, but
+  WITHOUT ANY WARRANTY; without even the implied warranty of
+  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+  General Public License for more details.
+
+  You should have received a copy of the GNU Lesser General Public License
+  along with PulseAudio; if not, write to the Free Software
+  Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
+  USA.
+***/
+
+#ifdef HAVE_CONFIG_H
+#include <config.h>
+#endif
+
+#include <pulsecore/macro.h>
+#include <pulsecore/hashmap.h>
+#include <pulsecore/hook-list.h>
+#include <pulsecore/core.h>
+#include <pulsecore/core-util.h>
+#include <pulsecore/sink-input.h>
+#include <pulsecore/modargs.h>
+
+#include "module-filter-heuristics-symdef.h"
+
+#define PA_PROP_FILTER_WANT "filter.want"
+#define PA_PROP_FILTER_SUPPRESS "filter.suppress"
+
+
+PA_MODULE_AUTHOR("Colin Guthrie");
+PA_MODULE_DESCRIPTION("Detect when various filters are desirable");
+PA_MODULE_VERSION(PACKAGE_VERSION);
+PA_MODULE_LOAD_ONCE(TRUE);
+
+static const char* const valid_modargs[] = {
+    NULL
+};
+
+struct userdata {
+    pa_core *core;
+    pa_hook_slot
+        *sink_input_put_slot;
+};
+
+static pa_hook_result_t sink_input_put_cb(pa_core *core, pa_sink_input *i, struct userdata *u) {
+    const char *role;
+
+    pa_core_assert_ref(core);
+    pa_sink_input_assert_ref(i);
+    pa_assert(u);
+
+    /* If the stream already specifies what it wants, then let it be. */
+    if (pa_proplist_gets(i->proplist, PA_PROP_FILTER_WANT))
+        return PA_HOOK_OK;
+
+    if ((role = pa_proplist_gets(i->proplist, PA_PROP_MEDIA_ROLE)) && pa_streq(role, "phone"))
+        pa_proplist_sets(i->proplist, PA_PROP_FILTER_WANT, "echo-cancel");
+
+    return PA_HOOK_OK;
+}
+
+int pa__init(pa_module *m) {
+    pa_modargs *ma = NULL;
+    struct userdata *u;
+
+    pa_assert(m);
+
+    if (!(ma = pa_modargs_new(m->argument, valid_modargs))) {
+        pa_log("Failed to parse module arguments");
+        goto fail;
+    }
+
+    m->userdata = u = pa_xnew(struct userdata, 1);
+
+    u->core = m->core;
+
+    u->sink_input_put_slot = pa_hook_connect(&m->core->hooks[PA_CORE_HOOK_SINK_INPUT_PUT], PA_HOOK_LATE-1, (pa_hook_cb_t) sink_input_put_cb, u);
+
+    pa_modargs_free(ma);
+
+    return 0;
+
+fail:
+    pa__done(m);
+
+    if (ma)
+        pa_modargs_free(ma);
+
+    return -1;
+
+
+}
+
+void pa__done(pa_module *m) {
+    struct userdata* u;
+
+    pa_assert(m);
+
+    if (!(u = m->userdata))
+        return;
+
+    if (u->sink_input_put_slot)
+        pa_hook_slot_free(u->sink_input_put_slot);
+
+    pa_xfree(u);
+
+}
-- 
1.7.4.3

_______________________________________________
pulseaudio-discuss mailing list
pulseaudio-discuss@mail.0pointer.de
https://tango.0pointer.de/mailman/listinfo/pulseaudio-discuss

Reply via email to