fp6-img/aports/device/callaudioshim/callaudioshim.py
Jorijn van der Graaf 12a65a5574
Some checks failed
image / image (push) Has been cancelled
callaudioshim 1-r1: own callaudiod's activation file, so Phosh gets the shim
Phosh D-Bus-activates org.mobian_project.CallAudio at startup (cui_init ->
call_audio_init), before any autostart runs. So the stock callaudiod took
the name, segfaulted once, and then refused every mute ("card has no usable
source"): Calls' mute did nothing and Calls asserted on each refusal. Under
Plasma the shim won only because its phase-1 autostart ran first.

The package now installs the activation file at callaudiod's path
(replaces="callaudiod"), pointing at the shim, so whoever asks first gets
the shim. apk keeps the file through callaudiod reinstalls and upgrades,
including both packages upgrading in one transaction (tested in
alpine:edge). Removing the shim removes the file; 'apk fix callaudiod'
restores the stock one. A second instance exits quietly instead of
tracing back.

Tested on the dev phone: Phosh (activation at login, mute silences the
uplink, no Calls assertions) and Plasma (autostart owns the name, the
dialer's mute button follows the mic state).
2026-09-23 17:53:07 +02:00

202 lines
8.1 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. The package replaces callaudiod's
D-Bus activation file with one that starts this shim, so whichever client asks
first — Phosh activates the name at startup, before autostart runs — gets the
shim; the KDE early-phase autostart stays for the DialerUtils push at session
start. Runs as the session user, 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()
try:
name = dbus.service.BusName(BUS_NAME, session, do_not_queue=True) # noqa: F841
except dbus.exceptions.NameExistsException:
# activated and autostarted in the same session: the first one serves
log(f"callaudioshim: {BUS_NAME} already owned; exiting")
return
CallAudio(session)
log(f"callaudioshim: owning {BUS_NAME}")
GLib.MainLoop().run()
if __name__ == "__main__":
main()