195 lines
7.7 KiB
Python
195 lines
7.7 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
"""callaudioshim.py — drop-in replacement for callaudiod on the FP6.
|
||
|
|
|
||
|
|
Stock callaudiod 0.1.99 is broken on this hardware: it never finds a
|
||
|
|
"voice-capable" PulseAudio card (our UCM exposes HiFi with Speaker+Mic, no
|
||
|
|
Earpiece port), so
|
||
|
|
- its MicState property stays at the D-Bus default 0 = MIC_OFF and
|
||
|
|
plasma-dialer's in-call page permanently shows the mic as MUTED;
|
||
|
|
- MuteMic() bails with "card has no usable source" — the mute button does
|
||
|
|
nothing;
|
||
|
|
- SelectMode() never replies, blocking kde-telephony-daemon's main loop for
|
||
|
|
the full 25 s GDBus timeout on every call (journal/ims.md s41).
|
||
|
|
|
||
|
|
A second, independent upstream defect (plasma-dialer 6.7.3) makes the UI state
|
||
|
|
wrong even with a working callaudiod: the mute button binds to
|
||
|
|
kde-telephony-daemon's DialerUtils `m_mute` — an UNINITIALIZED bool that
|
||
|
|
nothing ever syncs with reality (it only changes on button presses, and the
|
||
|
|
daemon never re-reads MicState). So every call page opened showing "muted"
|
||
|
|
while the mic was live, and the first press was wasted re-syncing. This shim
|
||
|
|
papers over that too: whenever the REAL mic state changes (and whenever
|
||
|
|
kde-telephony-daemon [re]appears on the bus), it pushes the truth into
|
||
|
|
DialerUtils.setMute(b), which updates m_mute and fires muteChanged to the UI.
|
||
|
|
The loop is self-terminating (the daemon only re-emits on value change, and
|
||
|
|
the bounce-back MuteMic is a no-op when the state already matches).
|
||
|
|
|
||
|
|
This shim owns org.mobian_project.CallAudio instead and implements the same
|
||
|
|
interface (verified against callaudiod's org.mobian_project.CallAudio.xml):
|
||
|
|
- MuteMic(b) mutes/unmutes @DEFAULT_AUDIO_SOURCE@ via wpctl. The default
|
||
|
|
source is ec_source (the AEC'd mic, s43), which every capture stream —
|
||
|
|
including the call's pw-record — consumes, so mute = real uplink silence
|
||
|
|
(verified: tone capture RMS 94.5 -> 0.0 on mute).
|
||
|
|
- MicState mirrors the ACTUAL source mute (polled every 2 s too, so
|
||
|
|
out-of-band wpctl/pactl changes stay reflected), with PropertiesChanged
|
||
|
|
so kde-telephony-daemon's cached GDBusProxy tracks it.
|
||
|
|
- SelectMode / EnableSpeaker reply immediately (bookkeeping only: the
|
||
|
|
loudspeaker is the only output today, and UCM verb switching is not
|
||
|
|
dynamic in this setup — journal/audio.md 2026-07-18). SpeakerState
|
||
|
|
defaults to ON. When an Earpiece UCM device exists these become real.
|
||
|
|
|
||
|
|
Stock callaudiod must not own the name: pkill it and mask its D-Bus
|
||
|
|
activation (see journal/ims.md s44 deploy notes). Runs as the session user
|
||
|
|
(autostart .desktop), no root needed.
|
||
|
|
"""
|
||
|
|
import subprocess
|
||
|
|
|
||
|
|
import dbus
|
||
|
|
import dbus.service
|
||
|
|
import dbus.mainloop.glib
|
||
|
|
from gi.repository import GLib
|
||
|
|
|
||
|
|
BUS_NAME = "org.mobian_project.CallAudio"
|
||
|
|
OBJ_PATH = "/org/mobian_project/CallAudio"
|
||
|
|
IFACE = "org.mobian_project.CallAudio"
|
||
|
|
PROPS_IFACE = "org.freedesktop.DBus.Properties"
|
||
|
|
SOURCE = "@DEFAULT_AUDIO_SOURCE@"
|
||
|
|
|
||
|
|
# kde-telephony-daemon's DialerUtils — the mute-button state lives there
|
||
|
|
DIALER_BUS = "org.kde.telephony.DialerUtils"
|
||
|
|
DIALER_PATH = "/org/kde/telephony/DialerUtils/tel/mm"
|
||
|
|
DIALER_IFACE = "org.kde.telephony.DialerUtils"
|
||
|
|
|
||
|
|
MODE_DEFAULT, MODE_CALL = 0, 1
|
||
|
|
OFF, ON, UNKNOWN = 0, 1, 255
|
||
|
|
|
||
|
|
|
||
|
|
def log(msg):
|
||
|
|
print(msg, flush=True)
|
||
|
|
|
||
|
|
|
||
|
|
def source_muted():
|
||
|
|
"""True/False from wpctl ('Volume: 1.00 [MUTED]'), None if unreadable."""
|
||
|
|
try:
|
||
|
|
out = subprocess.run(["wpctl", "get-volume", SOURCE],
|
||
|
|
capture_output=True, text=True, timeout=5).stdout
|
||
|
|
except (OSError, subprocess.TimeoutExpired):
|
||
|
|
return None
|
||
|
|
if "Volume:" not in out:
|
||
|
|
return None
|
||
|
|
return "[MUTED]" in out
|
||
|
|
|
||
|
|
|
||
|
|
def set_source_muted(mute):
|
||
|
|
return subprocess.run(
|
||
|
|
["wpctl", "set-mute", SOURCE, "1" if mute else "0"],
|
||
|
|
capture_output=True, timeout=5).returncode == 0
|
||
|
|
|
||
|
|
|
||
|
|
class CallAudio(dbus.service.Object):
|
||
|
|
def __init__(self, bus):
|
||
|
|
super().__init__(bus, OBJ_PATH)
|
||
|
|
self.bus = bus
|
||
|
|
self.audio_mode = MODE_DEFAULT
|
||
|
|
self.speaker_state = ON # loudspeaker is the only output today
|
||
|
|
m = source_muted()
|
||
|
|
self.mic_state = UNKNOWN if m is None else (OFF if m else ON)
|
||
|
|
log(f"callaudioshim: up, MicState={self.mic_state}")
|
||
|
|
GLib.timeout_add_seconds(2, self._poll)
|
||
|
|
self._push_dialer()
|
||
|
|
# re-push whenever kde-telephony-daemon (re)appears: its m_mute is an
|
||
|
|
# uninitialized bool until someone calls setMute
|
||
|
|
bus.watch_name_owner(DIALER_BUS, self._dialer_owner_changed)
|
||
|
|
|
||
|
|
# -------------------------------------------- DialerUtils state push
|
||
|
|
def _dialer_owner_changed(self, owner):
|
||
|
|
if owner:
|
||
|
|
log(f"callaudioshim: DialerUtils owner {owner}; pushing mute state")
|
||
|
|
self._push_dialer()
|
||
|
|
|
||
|
|
def _push_dialer(self):
|
||
|
|
if self.mic_state == UNKNOWN:
|
||
|
|
return
|
||
|
|
try:
|
||
|
|
self.bus.call_async(DIALER_BUS, DIALER_PATH, DIALER_IFACE,
|
||
|
|
"setMute", "b", (self.mic_state == OFF,),
|
||
|
|
lambda *a: None, lambda *a: None)
|
||
|
|
except dbus.DBusException:
|
||
|
|
pass
|
||
|
|
|
||
|
|
# ------------------------------------------------ property plumbing
|
||
|
|
def _props(self):
|
||
|
|
return {
|
||
|
|
"AudioMode": dbus.UInt32(self.audio_mode),
|
||
|
|
"SpeakerState": dbus.UInt32(self.speaker_state),
|
||
|
|
"MicState": dbus.UInt32(self.mic_state),
|
||
|
|
}
|
||
|
|
|
||
|
|
def _set(self, attr, prop, value):
|
||
|
|
if getattr(self, attr) == value:
|
||
|
|
return
|
||
|
|
setattr(self, attr, value)
|
||
|
|
self.PropertiesChanged(IFACE, {prop: dbus.UInt32(value)}, [])
|
||
|
|
if prop == "MicState":
|
||
|
|
self._push_dialer()
|
||
|
|
|
||
|
|
def _poll(self):
|
||
|
|
# catch out-of-band mute changes (wpctl, pactl, other clients)
|
||
|
|
m = source_muted()
|
||
|
|
if m is not None:
|
||
|
|
self._set("mic_state", "MicState", OFF if m else ON)
|
||
|
|
return True
|
||
|
|
|
||
|
|
@dbus.service.method(PROPS_IFACE, in_signature="ss", out_signature="v")
|
||
|
|
def Get(self, interface, prop):
|
||
|
|
return self._props()[prop]
|
||
|
|
|
||
|
|
@dbus.service.method(PROPS_IFACE, in_signature="s", out_signature="a{sv}")
|
||
|
|
def GetAll(self, interface):
|
||
|
|
return self._props()
|
||
|
|
|
||
|
|
@dbus.service.signal(PROPS_IFACE, signature="sa{sv}as")
|
||
|
|
def PropertiesChanged(self, interface, changed, invalidated):
|
||
|
|
pass
|
||
|
|
|
||
|
|
# ------------------------------------------------ org.mobian_project.CallAudio
|
||
|
|
@dbus.service.method(IFACE, in_signature="u", out_signature="b")
|
||
|
|
def SelectMode(self, mode):
|
||
|
|
if mode not in (MODE_DEFAULT, MODE_CALL):
|
||
|
|
raise dbus.exceptions.DBusException(
|
||
|
|
"invalid mode", name="org.freedesktop.DBus.Error.InvalidArgs")
|
||
|
|
log(f"callaudioshim: SelectMode({int(mode)})")
|
||
|
|
self._set("audio_mode", "AudioMode", int(mode))
|
||
|
|
if mode == MODE_DEFAULT:
|
||
|
|
# leaving a call: never leave the mic muted behind
|
||
|
|
if source_muted():
|
||
|
|
set_source_muted(False)
|
||
|
|
self._set("mic_state", "MicState", ON)
|
||
|
|
return True
|
||
|
|
|
||
|
|
@dbus.service.method(IFACE, in_signature="b", out_signature="b")
|
||
|
|
def EnableSpeaker(self, enable):
|
||
|
|
log(f"callaudioshim: EnableSpeaker({bool(enable)})")
|
||
|
|
self._set("speaker_state", "SpeakerState", ON if enable else OFF)
|
||
|
|
return True
|
||
|
|
|
||
|
|
@dbus.service.method(IFACE, in_signature="b", out_signature="b")
|
||
|
|
def MuteMic(self, mute):
|
||
|
|
ok = set_source_muted(bool(mute))
|
||
|
|
log(f"callaudioshim: MuteMic({bool(mute)}) -> {ok}")
|
||
|
|
if not ok:
|
||
|
|
return False
|
||
|
|
self._set("mic_state", "MicState", OFF if mute else ON)
|
||
|
|
return True
|
||
|
|
|
||
|
|
|
||
|
|
def main():
|
||
|
|
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
|
||
|
|
session = dbus.SessionBus()
|
||
|
|
name = dbus.service.BusName(BUS_NAME, session, do_not_queue=True) # noqa: F841
|
||
|
|
CallAudio(session)
|
||
|
|
log(f"callaudioshim: owning {BUS_NAME}")
|
||
|
|
GLib.MainLoop().run()
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
main()
|