Unverified Commit c8a84a01 authored by rnhmjoj's avatar rnhmjoj
Browse files

nixos/alsa: rebirth from the ashes

The ALSA module was essentially removed in 3eeff547, with the main
motivation of avoiding confusion as to what `sound.enable` really meant.

As that could be achieved with a simple rename, this change brings back
the module in full force under the `hardware.alsa` namespace (with clear
beware signs for the pulse and pipewire folks) and adds a lot of useful
extra features. These include

 - `defaultDevice` to set the default playback and capture devices

 - `cardAliases`,`deviceAliases` to assign meaningful names to sound cards
   and devices (instead of say, `hw:0,1`)

 - `controls` to create virtual volume controls

 - `enableRecorder` to easily configure a loopback device to record
   the computer audio

 - fixes to the udev restore rules
parent d75618dc
Loading
Loading
Loading
Loading
+5 −4
Original line number Diff line number Diff line
@@ -919,15 +919,16 @@ The `sound` options have been largely removed, as they are unnecessary for most

If you set `sound.enable` in your configuration:
  - If you are using Pulseaudio or PipeWire, simply remove that option
  - If you are not using an external sound server, and want volumes to be persisted across shutdowns, set `hardware.alsa.enablePersistence = true` instead
  - If you are using ALSA as your only sound system (no sound server), set `hardware.alsa.enable = true` instead

If you set `sound.enableOSSEmulation` in your configuration:
  - Make sure it is still necessary, as very few applications actually use OSS
  - If necessary, set `boot.kernelModules = [ "snd_pcm_oss" ]`
  - If necessary, set `hardware.alsa.enableOSSEmulation = true`

If you set `sound.extraConfig` in your configuration:
  - If you are using another sound server, like Pulseaudio, JACK or PipeWire, migrate your configuration to that
  - If you are not using an external sound server, set `environment.etc."asound.conf".text = yourExtraConfig` instead
  - If you are using a sound server, like Pulseaudio, JACK or PipeWire, migrate your configuration to that
  - If you are using ALSA as your only sound system, check if you can use the new structured ALSA options `hardware.alsa.defaultDevice`, `hardware.alsa.cardAliases`, `hardware.alsa.controls`, etc.
  - Otherwise, move your configuration directly into `hardware.alsa.config`

If you set `sound.mediaKeys` in your configuration:
  - Preferably switch to handling media keys in your desktop environment/compositor
+415 −16
Original line number Diff line number Diff line
# ALSA sound support.
{ config, lib, pkgs, ... }:
{
  config,
  lib,
  pkgs,
  ...
}:

let
  cfg = config.hardware.alsa;

  quote = x: ''"${lib.escape [ "\"" ] x}"'';

  alsactl = lib.getExe' pkgs.alsa-utils "alsactl";

  # Creates a volume control
  mkControl = name: opts: ''
    pcm.${name} {
      type softvol
      slave.pcm ${quote opts.device}
      control.name ${quote (if opts.name != null then opts.name else name)}
      control.card ${quote opts.card}
      max_dB ${toString opts.maxVolume}
    }
  '';

  # modprobe.conf for naming sound cards
  cardsConfig =
    let
      # Reverse the mapping from card name→driver to card driver→name
      drivers = lib.unique (lib.mapAttrsToList (n: v: v.driver) cfg.cardAliases);
      options = lib.forEach drivers (
        drv:
        let
          byDriver = lib.filterAttrs (n: v: v.driver == drv);
          ids = lib.mapAttrs (n: v: v.id) (byDriver cfg.cardAliases);
        in
        {
          driver = drv;
          names = lib.attrNames ids;
          ids = lib.attrValues ids;
        }
      );
      toList = x: lib.concatStringsSep "," (map toString x);
    in
    lib.forEach options (i: "options ${i.driver} index=${toList i.ids} id=${toList i.names}");

  defaultDeviceVars = {
    "ALSA_AUDIO_OUT" = cfg.defaultDevice.playback;
    "ALSA_AUDIO_IN" = cfg.defaultDevice.capture;
  };

in

{
  imports = [
    (lib.mkRemovedOptionModule [ "sound" ] "The option was heavily overloaded and can be removed from most configurations.")
    (lib.mkRemovedOptionModule [ "sound" "enable" ] ''
      The option was heavily overloaded and can be removed from most configurations.
      To specifically configure the user space part of ALSA, see `hardware.alsa`.
    '')
    (lib.mkRemovedOptionModule [ "sound" "mediaKeys" ] ''
      The media keys can be configured with any hotkey daemon (that better
      integrates with your desktop setup). To continue using the actkbd daemon
      (which was used up to NixOS 24.05), add these lines to your configuration:

        services.actkbd.enable = true;
        services.actkbd.bindings = [
          # Mute
          { keys = [ 113 ]; events = [ "key" ];
            command = "''${pkgs.alsa-utils}/bin/amixer -q set Master toggle";
          }
          # Volume down
          { keys = [ 114 ]; events = [ "key" "rep" ];
            command = "''${pkgs.alsa-utils}/bin/amixer -q set Master 1- unmute";
          }
          # Volume up
          { keys = [ 115 ]; events = [ "key" "rep" ];
            command = "''${pkgs.alsa-utils}/bin/amixer -q set Master 1+ unmute";
          }
          # Mic Mute
          { keys = [ 190 ]; events = [ "key" ];
            command = "''${pkgs.alsa-utils}/bin/amixer -q set Capture toggle";
          }
        ];
    '')
    (lib.mkRenamedOptionModule
      [ "sound" "enableOSSEmulation" ]
      [ "hardware" "alsa" "enableOSSEmulation" ]
    )
    (lib.mkRenamedOptionModule
      [ "sound" "extraConfig" ]
      [ "hardware" "alsa" "config" ])
  ];

  options.hardware.alsa.enablePersistence = lib.mkOption {
  options.hardware.alsa = {

    enable = lib.mkOption {
      type = lib.types.bool;
      default = false;
      description = ''
      Whether to enable ALSA sound card state saving on shutdown.
      This is generally not necessary if you're using an external sound server.
        Whether to set up the user space part of the Advanced Linux Sound Architecture (ALSA)

        ::: {.warning}
        Enable this option only if you want to use ALSA as your main sound system,
        not if you're using a sound server (e.g. PulseAudio or Pipewire).
        :::
      '';
    };

    enableOSSEmulation = lib.mkEnableOption "the OSS emulation";

    enableRecorder = lib.mkOption {
      type = lib.types.bool;
      default = false;
      description = ''
        Whether to set up a loopback device that continuously records and
        allows to play back audio from the computer.

        The loopback device is named `pcm.recorder`, audio can be saved
        by capturing from this device as with any microphone.

        ::: {.note}
        By default the output is duplicated to the recorder assuming stereo
        audio, for a more complex layout you have to override the pcm.splitter
        device using `hardware.alsa.config`.
        See the generated /etc/asound.conf for its definition.
        :::
      '';
    };

    defaultDevice.playback = lib.mkOption {
      type = lib.types.str;
      default = "";
      example = "dmix:CARD=1,DEV=0";
      description = ''
        The default playback device.
        Leave empty to let ALSA pick the default automatically.

        ::: {.note}
        The device can be changed at runtime by setting the ALSA_AUDIO_OUT
        environment variables (but only before starting a program).
        :::
      '';
    };

    defaultDevice.capture = lib.mkOption {
      type = lib.types.str;
      default = "";
      example = "dsnoop:CARD=0,DEV=2";
      description = ''
        The default capture device (i.e. microphone).
        Leave empty to let ALSA pick the default automatically.

        ::: {.note}
        The device can be changed at runtime by setting the ALSA_AUDIO_IN
        environment variables (but only before starting a program).
        :::
      '';
    };

    controls = lib.mkOption {
      type = lib.types.attrsOf (
        lib.types.submodule ({
          options.name = lib.mkOption {
            type = lib.types.nullOr lib.types.str;
            default = null;
            description = ''
              Name of the control, as it appears in `alsamixer`.
              If null it will be the same as the softvol device name.
            '';
          };
          options.device = lib.mkOption {
            type = lib.types.str;
            default = "default";
            description = ''
              Name of the PCM device to control (slave).
            '';
          };
          options.card = lib.mkOption {
            type = lib.types.str;
            default = "default";
            description = ''
              Name of the PCM card to control (slave).
            '';
          };
          options.maxVolume = lib.mkOption {
            type = lib.types.float;
            default = 0.0;
            description = ''
              The maximum volume in dB.
            '';
          };
        })
      );
      default = {};
      example = lib.literalExpression ''
        {
          firefox = { device = "front"; maxVolume = -25.0; };
          mpv     = { device = "front"; maxVolume = -25.0; };
          # and run programs with `env ALSA_AUDIO_OUT=<name>`
        }
      '';
      description = ''
        Virtual volume controls (softvols) to add to a sound card.
        These can be used to control the volume of specific applications
        or a digital output device (HDMI video card).
      '';
    };

  config = lib.mkIf config.hardware.alsa.enablePersistence {
    # ALSA provides a udev rule for restoring volume settings.
    services.udev.packages = [ pkgs.alsa-utils ];
    cardAliases = lib.mkOption {
      type = lib.types.attrsOf (
        lib.types.submodule ({
          options.driver = lib.mkOption {
            type = lib.types.str;
            description = ''
              Name of the kernel module that provides the card.
            '';
          };
          options.id = lib.mkOption {
            type = lib.types.int;
            default = "default";
            description = ''
              The ID of the sound card
            '';
          };
        })
      );
      default = { };
      example = lib.literalExpression ''
        {
          soundchip = { driver = "snd_intel_hda"; id = 0; };
          videocard = { driver = "snd_intel_hda"; id = 1; };
          usb       = { driver = "snd_usb_audio"; id = 2; };
        }
      '';
      description = ''
        Assign custom names and reorder the sound cards.

        ::: {.note}
        You can find the card ids by looking at `/proc/asound/cards`.
        :::
      '';
    };

    deviceAliases = lib.mkOption {
      type = lib.types.attrsOf lib.types.str;
      default = { };
      example = lib.literalExpression ''
        {
          hdmi1 = "hw:CARD=videocard,DEV=5";
          hdmi2 = "hw:CARD=videocard,DEV=6";
        }
      '';
      description = ''
        Assign custom names to sound cards.
      '';
    };

    config = lib.mkOption {
      type = lib.types.lines;
      default = "";
      example = lib.literalExpression ''
        # Send audio to a remote host via SSH
        pcm.remote {
          @args [ HOSTNAME ]
          @args.HOSTNAME { type string }
          type file
          format raw
          slave.pcm pcm.null
          file {
            @func concat
            strings [
              "| ''${lib.getExec pkgs.openssh} -C "
              $HOSTNAME
              " aplay -f %f -c %c -r %r -"
            ]
          }
        }
      '';
      description = ''
        The content of the system-wide ALSA configuration (/etc/asound.conf).

        Documentation of the configuration language and examples can be found
        in the unofficial ALSA wiki: https://alsa.opensrc.org/Asoundrc
      '';
    };

  };

  config = lib.mkIf cfg.enable {

    # Disable sound servers enabled by default and,
    # if the user enabled one manually, cause a conflict.
    services.pipewire.enable = false;
    hardware.pulseaudio.enable = false;

    hardware.alsa.config =
      let
        conf = [
          ''
            pcm.!default fromenv

            # Read the capture and playback device from
            # the ALSA_AUDIO_IN, ALSA_AUDIO_OUT variables
            pcm.fromenv {
              type asym
              playback.pcm {
                type plug
                slave.pcm {
                  @func getenv
                  vars [ ALSA_AUDIO_OUT ]
                  default pcm.sysdefault
                }
              }
              capture.pcm {
                type plug
                slave.pcm {
                  @func getenv
                  vars [ ALSA_AUDIO_IN ]
                  default pcm.sysdefault
                }
              }
            }
          ''
          (lib.optional cfg.enableRecorder ''
            pcm.!default "splitter:fromenv,recorder"

            # Send audio to two stereo devices
            pcm.splitter {
              @args [ A B ]
              @args.A.type string
              @args.B.type string
              type asym
              playback.pcm {
                type plug
                route_policy "duplicate"
                slave.pcm {
                  type multi
                  slaves.a.pcm $A
                  slaves.b.pcm $B
                  slaves.a.channels 2
                  slaves.b.channels 2
                  bindings [
                   { slave a channel 0 }
                   { slave a channel 1 }
                   { slave b channel 0 }
                   { slave b channel 1 }
                  ]
                }
              }
              capture.pcm $A
            }

            # Device which records and plays back audio
            pcm.recorder {
              type asym
              capture.pcm {
                type dsnoop
                ipc_key 9165218
                ipc_perm 0666
                slave.pcm "hw:loopback,1,0"
                slave.period_size 1024
                slave.buffer_size 8192
              }
              playback.pcm {
                type dmix
                ipc_key 6181923
                ipc_perm 0666
                slave.pcm "hw:loopback,0,0"
                slave.period_size 1024
                slave.buffer_size 8192
              }
            }
          '')
          (lib.mapAttrsToList mkControl cfg.controls)
          (lib.mapAttrsToList (n: v: "pcm.${n} ${quote v}") cfg.deviceAliases)
        ];
      in
      lib.mkBefore (lib.concatStringsSep "\n" (lib.flatten conf));

    hardware.alsa.cardAliases = lib.mkIf cfg.enableRecorder {
      loopback.driver = "snd_aloop";
      loopback.id = 2;
    };

    # Set default PCM devices
    environment.sessionVariables = defaultDeviceVars;
    systemd.globalEnvironment = defaultDeviceVars;

    environment.etc."asound.conf".text = cfg.config;

    boot.kernelModules =
      [ ]
      ++ lib.optionals cfg.enableOSSEmulation [ "snd_pcm_oss" "snd_mixer_oss" ]
      ++ lib.optionals cfg.enableRecorder [ "snd_aloop" ];

    # Assign names to the sound cards
    boot.extraModprobeConfig = lib.concatStringsSep "\n" cardsConfig;

    # Provide alsamixer, aplay, arecord, etc.
    environment.systemPackages = [ pkgs.alsa-utils ];

    # Install udev rules for restoring card settings on boot
    services.udev.extraRules = ''
      ACTION=="add", SUBSYSTEM=="sound", KERNEL=="controlC*", KERNELS!="card*", GOTO="alsa_restore_go"
      GOTO="alsa_restore_end"

      LABEL="alsa_restore_go"
      TEST!="/etc/alsa/state-daemon.conf", RUN+="${alsactl} restore -gU $attr{device/number}"
      TEST=="/etc/alsa/state-daemon.conf", RUN+="${alsactl} nrestore -gU $attr{device/number}"
      LABEL="alsa_restore_end"
    '';

    # Service to store/restore the sound card settings
    systemd.services.alsa-store = {
      description = "Store Sound Card State";
      wantedBy = [ "multi-user.target" ];
      restartIfChanged = false;
      unitConfig = {
        RequiresMountsFor = "/var/lib/alsa";
        ConditionVirtualization = "!systemd-nspawn";
@@ -29,10 +422,16 @@
      serviceConfig = {
        Type = "oneshot";
        RemainAfterExit = true;
        ExecStartPre = "${pkgs.coreutils}/bin/mkdir -p /var/lib/alsa";
        ExecStart = "${pkgs.alsa-utils}/sbin/alsactl restore --ignore";
        ExecStop = "${pkgs.alsa-utils}/sbin/alsactl store --ignore";
        StateDirectory = "alsa";
        # Note: the service should never be restated, otherwise any
        # setting changed between the last `store` and now will be lost.
        # To prevent NixOS from starting it in case it has failed we
        # expand the exit codes considered successful
        SuccessExitStatus = [ 0 99 ];
        ExecStart = "${alsactl} restore -gU";
        ExecStop = "${alsactl} store -gU";
      };
    };
  };

}