diff --git a/life-church/playbooks/thinkcenter-audio.yml b/life-church/playbooks/thinkcenter-audio.yml new file mode 100644 index 0000000..9d5c483 --- /dev/null +++ b/life-church/playbooks/thinkcenter-audio.yml @@ -0,0 +1,200 @@ +--- +# ============================================================================ +# 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: "[facts] Resolve audio_user UID (for XDG_RUNTIME_DIR)" + ansible.builtin.getent: + database: passwd + key: "{{ audio_user }}" + + - name: "[facts] Set audio_uid" + ansible.builtin.set_fact: + audio_uid: "{{ ansible_facts.getent_passwd[audio_user][1] }}" + + - name: "[apt] Update cache" + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + + - name: "[apt] Install unified PipeWire audio stack + qpwgraph" + ansible.builtin.apt: + name: "{{ audio_packages }}" + state: present + register: pkg_install + + - 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 + + - name: "[services] Enable PipeWire user services for {{ audio_user }} (lingering + enable)" + ansible.builtin.command: "loginctl enable-linger {{ audio_user }}" + register: linger + changed_when: "'already' not in (linger.stderr | default(''))" + failed_when: false + + - 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 }}" + failed_when: false + register: user_services + + - 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 }}" + 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."