253 lines
11 KiB
YAML
253 lines
11 KiB
YAML
---
|
|
# ============================================================================
|
|
# life-church/playbooks/thinkcenter-audio.yml
|
|
# Unified audio stack on thinkcenter: PipeWire as the ONE server, with its
|
|
# PulseAudio + JACK + ALSA compatibility layers so nothing competes.
|
|
# CHANGES the system. thinkcenter ONLY. Idempotent. Native .deb packages only
|
|
# (no flatpak for audio — matches the homelab audio rule).
|
|
#
|
|
# Design (why nothing competes):
|
|
# - PipeWire = the single audio server/graph.
|
|
# - WirePlumber = session/policy manager.
|
|
# - pipewire-pulse = REPLACES the PulseAudio daemon (PA apps -> PipeWire).
|
|
# - pipewire-jack = REPLACES the JACK daemon (JACK apps -> PipeWire).
|
|
# - pipewire-alsa = routes ALSA apps through PipeWire.
|
|
# The standalone `pulseaudio` and `jackd2` daemons are intentionally NOT run;
|
|
# installing them as separate servers is the thing that causes contention.
|
|
# qpwgraph = the patchbay/graph GUI for wiring it all.
|
|
#
|
|
# UNDO: see life-church/playbooks/undo/thinkcenter-audio-undo.sh notes in header.
|
|
# ============================================================================
|
|
|
|
# --- Play 1 (localhost): fetch thinkcenter sudo pass from Infisical ----------
|
|
- name: "thinkcenter audio — resolve become password from Infisical"
|
|
hosts: localhost
|
|
gather_facts: false
|
|
tasks:
|
|
- name: "[resolve] Infisical creds from env"
|
|
ansible.builtin.set_fact:
|
|
infisical_client_id: "{{ lookup('env','INFISICAL_CLIENT_ID') }}"
|
|
infisical_client_secret: "{{ lookup('env','INFISICAL_CLIENT_SECRET') }}"
|
|
infisical_project_id: "{{ lookup('env','INFISICAL_PROJECT_ID') }}"
|
|
infisical_url: "{{ lookup('env','INFISICAL_API_URL') | default('http://192.168.122.41:8080', true) }}"
|
|
infisical_env_slug: "{{ lookup('env','INFISICAL_ENV_SLUG') | default('prod', true) }}"
|
|
|
|
- name: "[resolve] Assert Infisical creds present"
|
|
ansible.builtin.assert:
|
|
that:
|
|
- infisical_client_id | length > 0
|
|
- infisical_client_secret | length > 0
|
|
- infisical_project_id | length > 0
|
|
fail_msg: "Infisical creds missing from Semaphore environment 'infisical-dev'."
|
|
|
|
- name: "[infisical] Log in"
|
|
infisical.vault.login:
|
|
url: "{{ infisical_url }}"
|
|
auth_method: universal_auth
|
|
universal_auth_client_id: "{{ infisical_client_id }}"
|
|
universal_auth_client_secret: "{{ infisical_client_secret }}"
|
|
register: infisical_login
|
|
no_log: true
|
|
|
|
- name: "[infisical] Read /lc-hosts"
|
|
infisical.vault.read_secrets:
|
|
login_data: "{{ infisical_login.login_data }}"
|
|
project_id: "{{ infisical_project_id }}"
|
|
env_slug: "{{ infisical_env_slug }}"
|
|
path: "/lc-hosts"
|
|
as_dict: true
|
|
register: lc_secrets
|
|
no_log: true
|
|
|
|
- name: "[set] Stash thinkcenter sudo pass + login user"
|
|
ansible.builtin.set_fact:
|
|
thinkcenter_become_pass: "{{ lc_secrets.secrets['THINKCENTER_SUDO_PASS'] }}"
|
|
thinkcenter_login_user: "{{ lc_secrets.secrets['THINKCENTER_USER'] }}"
|
|
no_log: true
|
|
|
|
# --- Play 2: install + configure the unified audio stack (thinkcenter only) --
|
|
- name: "Unified PipeWire audio stack on thinkcenter"
|
|
hosts: thinkcenter
|
|
gather_facts: true
|
|
become: true
|
|
vars:
|
|
ansible_become_pass: "{{ hostvars['localhost'].thinkcenter_become_pass }}"
|
|
audio_user: "{{ hostvars['localhost'].thinkcenter_login_user }}"
|
|
# Everything PipeWire-native. NOTE: we do NOT install jackd2 or pulseaudio
|
|
# as daemons — pipewire-jack / pipewire-pulse provide those APIs.
|
|
audio_packages:
|
|
- pipewire # core server
|
|
- pipewire-audio # audio meta
|
|
- pipewire-bin # pw-* CLI tools
|
|
- pipewire-alsa # ALSA apps -> PipeWire
|
|
- pipewire-pulse # PulseAudio API -> PipeWire (replaces PA daemon)
|
|
- pipewire-jack # JACK API -> PipeWire (replaces JACK daemon)
|
|
- libspa-0.2-bluetooth # bluetooth audio
|
|
- wireplumber # session manager
|
|
- qpwgraph # PipeWire patchbay/graph GUI
|
|
- pavucontrol # PulseAudio volume control (works via pw-pulse)
|
|
- alsa-utils # alsamixer, aplay, etc.
|
|
- rtkit # realtime scheduling broker (low-latency)
|
|
tasks:
|
|
- name: "[safety] Confirm we are on thinkcenter"
|
|
ansible.builtin.assert:
|
|
that: inventory_hostname == 'thinkcenter'
|
|
fail_msg: "Refusing to run — this play is scoped to thinkcenter only."
|
|
|
|
- name: "[safety] Assert audio_user resolved from Infisical"
|
|
ansible.builtin.assert:
|
|
that:
|
|
- audio_user is defined
|
|
- audio_user | length > 0
|
|
fail_msg: "audio_user is empty — THINKCENTER_USER not read from Infisical /lc-hosts."
|
|
|
|
- name: "[facts] Resolve audio_user UID (for XDG_RUNTIME_DIR)"
|
|
ansible.builtin.getent:
|
|
database: passwd
|
|
key: "{{ audio_user }}"
|
|
|
|
- name: "[facts] Assert the user actually exists on the host"
|
|
ansible.builtin.assert:
|
|
that: audio_user in ansible_facts.getent_passwd
|
|
fail_msg: "User '{{ audio_user }}' does not exist on {{ inventory_hostname }}."
|
|
|
|
- name: "[facts] Set audio_uid"
|
|
ansible.builtin.set_fact:
|
|
audio_uid: "{{ ansible_facts.getent_passwd[audio_user][1] }}"
|
|
|
|
- name: "[apt] Update cache (retry on transient network/lock)"
|
|
ansible.builtin.apt:
|
|
update_cache: true
|
|
cache_valid_time: 3600
|
|
register: apt_cache
|
|
until: apt_cache is succeeded
|
|
retries: 3
|
|
delay: 10
|
|
|
|
- name: "[apt] Install unified PipeWire audio stack + qpwgraph (retry on dpkg lock)"
|
|
ansible.builtin.apt:
|
|
name: "{{ audio_packages }}"
|
|
state: present
|
|
register: pkg_install
|
|
until: pkg_install is succeeded
|
|
retries: 3
|
|
delay: 15
|
|
|
|
- name: "[conflict] Detect a STANDALONE pulseaudio daemon package (not the pw-pulse shim)"
|
|
ansible.builtin.shell: "dpkg -l pulseaudio 2>/dev/null | awk '/^ii/{print $2}'"
|
|
register: standalone_pa
|
|
changed_when: false
|
|
failed_when: false
|
|
|
|
- name: "[conflict] Detect a STANDALONE jackd2 daemon package"
|
|
ansible.builtin.shell: "dpkg -l jackd2 2>/dev/null | awk '/^ii/{print $2}'"
|
|
register: standalone_jack
|
|
changed_when: false
|
|
failed_when: false
|
|
|
|
# Only mask the competing daemons if their standalone packages are present.
|
|
# pipewire-pulse/pipewire-jack are NOT affected by these (different units).
|
|
- name: "[conflict] Mask standalone PulseAudio user daemon if present (prevents competing server)"
|
|
ansible.builtin.command: "systemctl --global mask pulseaudio.service pulseaudio.socket"
|
|
when: standalone_pa.stdout | length > 0
|
|
register: mask_pa
|
|
changed_when: mask_pa.rc == 0
|
|
|
|
# --- User-session services: made robust ---------------------------------
|
|
# systemctl --user needs a live user runtime dir + D-Bus. Enabling linger
|
|
# FIRST makes systemd create a persistent /run/user/<uid> even with no GUI
|
|
# login, so this works whether or not steve is currently logged in.
|
|
- name: "[services] Enable lingering for {{ audio_user }} (persistent user manager)"
|
|
ansible.builtin.command: "loginctl enable-linger {{ audio_user }}"
|
|
register: linger
|
|
changed_when: "linger.rc == 0 and 'already enabled' not in (linger.stderr | default(''))"
|
|
failed_when: linger.rc != 0 and 'already enabled' not in (linger.stderr | default('') + linger.stdout | default(''))
|
|
|
|
- name: "[services] Wait for user runtime dir /run/user/{{ audio_uid }} to exist"
|
|
ansible.builtin.stat:
|
|
path: "/run/user/{{ audio_uid }}"
|
|
register: runtime_dir
|
|
until: runtime_dir.stat.exists
|
|
retries: 6
|
|
delay: 5
|
|
|
|
- name: "[services] Enable PipeWire user units (with rescue if session bus is not ready)"
|
|
block:
|
|
- name: "[services] Enable pipewire / pipewire-pulse / wireplumber for the user"
|
|
become: true
|
|
become_user: "{{ audio_user }}"
|
|
ansible.builtin.systemd_service:
|
|
name: "{{ item }}"
|
|
scope: user
|
|
enabled: true
|
|
loop:
|
|
- pipewire.socket
|
|
- pipewire-pulse.socket
|
|
- wireplumber.service
|
|
environment:
|
|
XDG_RUNTIME_DIR: "/run/user/{{ audio_uid }}"
|
|
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ audio_uid }}/bus"
|
|
register: user_services
|
|
rescue:
|
|
- name: "[services] FALLBACK — enable units via systemctl --user by uid (no D-Bus dependency)"
|
|
become: true
|
|
become_user: "{{ audio_user }}"
|
|
ansible.builtin.command: "systemctl --user enable {{ item }}"
|
|
loop:
|
|
- pipewire.socket
|
|
- pipewire-pulse.socket
|
|
- wireplumber.service
|
|
environment:
|
|
XDG_RUNTIME_DIR: "/run/user/{{ audio_uid }}"
|
|
register: user_services_fallback
|
|
changed_when: "'Created symlink' in (user_services_fallback.stderr | default(''))"
|
|
failed_when: false
|
|
|
|
- name: "[services] Note the fallback path was used"
|
|
ansible.builtin.debug:
|
|
msg: "User D-Bus not ready; enabled PipeWire units via the systemctl fallback. They will start on next login/linger."
|
|
|
|
- name: "[verify] Which server does pactl report?"
|
|
become: true
|
|
become_user: "{{ audio_user }}"
|
|
ansible.builtin.command: pactl info
|
|
environment:
|
|
XDG_RUNTIME_DIR: "/run/user/{{ audio_uid }}"
|
|
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ audio_uid }}/bus"
|
|
register: pactl_info
|
|
changed_when: false
|
|
failed_when: false
|
|
|
|
- name: "[verify] Is the PipeWire-JACK bridge available?"
|
|
ansible.builtin.stat:
|
|
path: /usr/lib/x86_64-linux-gnu/pipewire-0.3/jack/libjack.so.0
|
|
register: pw_jack_lib
|
|
|
|
- name: "[verify] qpwgraph installed?"
|
|
ansible.builtin.command: which qpwgraph
|
|
register: qpwgraph_which
|
|
changed_when: false
|
|
failed_when: false
|
|
|
|
- name: "[verify] Assert PipeWire is the audio server (no competing daemon)"
|
|
ansible.builtin.assert:
|
|
that:
|
|
- "'PipeWire' in pactl_info.stdout"
|
|
- standalone_jack.stdout | length == 0
|
|
fail_msg: >-
|
|
Audio server is not unified under PipeWire, or a standalone jackd2 daemon is
|
|
installed and would compete. pactl said: {{ pactl_info.stdout_lines | default([]) }}
|
|
success_msg: "Audio unified under PipeWire — PulseAudio + JACK + ALSA all via PipeWire."
|
|
|
|
- name: "[report] Audio stack summary"
|
|
ansible.builtin.debug:
|
|
msg:
|
|
- "===== thinkcenter unified audio ====="
|
|
- "Packages changed this run : {{ pkg_install.changed }}"
|
|
- "Server (pactl) : {{ (pactl_info.stdout_lines | select('match','Server Name.*') | list | first) | default('n/a') }}"
|
|
- "PipeWire-JACK bridge : {{ 'present' if pw_jack_lib.stat.exists else 'MISSING' }}"
|
|
- "qpwgraph : {{ 'installed' if qpwgraph_which.rc == 0 else 'MISSING' }}"
|
|
- "Standalone pulseaudio pkg : {{ 'present (masked)' if standalone_pa.stdout | length > 0 else 'not installed (good)' }}"
|
|
- "Standalone jackd2 pkg : {{ 'PRESENT (conflict!)' if standalone_jack.stdout | length > 0 else 'not installed (good)' }}"
|
|
- "NOTE: user must log out/in (or reboot) for JACK apps to pick up the PipeWire backend."
|