diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 00000000..1c6e4cf8 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,35 @@ +name: Nix + +on: + pull_request: + paths: + - "flake.nix" + - "flake.lock" + - "nix/**" + - ".github/workflows/nix.yml" + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +# WHY path-filtered on PRs: `nix flake check` builds the full workspace plus +# two NixOS VM module tests, which is far too heavy for every PR. Main always +# runs it — the flake is a release surface (#610). No store cache action: +# magic-nix-cache requires FlakeHub credentials the repo does not carry. +jobs: + flake-check: + name: nix flake check + runs-on: ubuntu-latest + timeout-minutes: 90 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # v22 + - name: nix flake check + run: nix flake check --print-build-logs diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..d2a87638 --- /dev/null +++ b/flake.lock @@ -0,0 +1,112 @@ +{ + "nodes": { + "crane": { + "locked": { + "lastModified": 1785782307, + "narHash": "sha256-MPaRdVkf6zZP5fCPxYCi8Dr4pZzgmXzg8T9nVEbp3Mw=", + "owner": "ipetkov", + "repo": "crane", + "rev": "2c71e194474d13de031d729b729c968ddbe3507f", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1786384358, + "narHash": "sha256-RzPPiWeUtuvymnpuEWsdtzli5w4kjZs49FqEs3/1u+I=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "2fcb964de67fcf60b43471c55d5d99e61a9ccb5a", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1744536153, + "narHash": "sha256-awS2zRgF4uTwrOKwwiJcByDzDOdo3Q1rPZbiHQg/N38=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "18dd725c29603f582cf1900e0d25f9f1063dbf11", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "crane": "crane", + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": "nixpkgs_2" + }, + "locked": { + "lastModified": 1786507911, + "narHash": "sha256-w5aZRLbiu7H6TqsYXVMdRKg0S4DRaJpxyqxx86AwxVk=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "39db48099ad16834af7e27485a4babf9c28b3897", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/nix/module.nix b/nix/module.nix index d322f9d8..b9fa7c7f 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -18,6 +18,12 @@ let downloadDir = cfg.settings.ergasia.download_dir or "/data/downloads"; podcastDir = cfg.settings.komide.podcast_dir or "/data/podcasts"; in { + # WHY: current nixpkgs ships an unrelated binary-cache server under the + # same services.harmonia namespace (nixos/modules/services/networking/ + # harmonia.nix); importing both declares the option path twice. This flake's + # module is the media platform — the upstream one is disabled here. + disabledModules = [ "services/networking/harmonia.nix" ]; + options.programs.harmonia-desktop = { enable = lib.mkEnableOption "Harmonia desktop application"; @@ -168,26 +174,31 @@ in { networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ (cfg.settings.paroche.port or 8096) ]; } // lib.mkIf desktopCfg.enable { - # Desktop application launcher entry. - xdg.desktopEntries.harmonia = { - name = "Harmonia"; - genericName = "Music Player"; - comment = "Self-hosted music, podcasts, and audiobooks"; - exec = "${desktopCfg.package}/bin/harmonia %U"; - icon = "harmonia"; - categories = [ "Audio" "Music" "Player" "AudioVideo" ]; - startupNotify = true; - mimeType = lib.optionals desktopCfg.installMimeTypes [ - "audio/flac" - "audio/mpeg" - "audio/mp4" - "audio/ogg" - "audio/opus" - "audio/wav" - "audio/aac" - "x-scheme-handler/harmonia" - ]; - }; + # Desktop application launcher entry. NixOS has no xdg.desktopEntries + # option (that namespace is Home Manager); a system-wide launcher is a + # package carrying share/applications/*.desktop. + environment.systemPackages = [ + (pkgs.makeDesktopItem { + name = "harmonia"; + desktopName = "Harmonia"; + genericName = "Music Player"; + comment = "Self-hosted music, podcasts, and audiobooks"; + exec = "${desktopCfg.package}/bin/harmonia %U"; + icon = "harmonia"; + categories = [ "Audio" "Music" "Player" "AudioVideo" ]; + startupNotify = true; + mimeTypes = lib.optionals desktopCfg.installMimeTypes [ + "audio/flac" + "audio/mpeg" + "audio/mp4" + "audio/ogg" + "audio/opus" + "audio/wav" + "audio/aac" + "x-scheme-handler/harmonia" + ]; + }) + ]; # D-Bus service file — allows the desktop environment to activate # Harmonia for MPRIS without it already running. diff --git a/nix/modules/harmonia-dac.nix b/nix/modules/harmonia-dac.nix index f89bfe9a..56d1eeca 100644 --- a/nix/modules/harmonia-dac.nix +++ b/nix/modules/harmonia-dac.nix @@ -12,13 +12,16 @@ let # Sources: HiFiBerry docs, IQaudio docs, Raspberry Pi overlay index. overlayMap = { # HiFiBerry DAC+ (standard, non-HD) - hifiberry-dacplus = { overlay = "hifiberry-dacplus"; alsaCard = "sndrpihifiberry"; }; + hifiberry-dacplus = { overlayFile = "hifiberry-dacplus"; alsaCard = "sndrpihifiberry"; }; # HiFiBerry DAC2 HD uses the "adcpro" overlay (same silicon, extra ADC channels) - hifiberry-dac2hd = { overlay = "hifiberry-dacplusadcpro"; alsaCard = "sndrpihifiberry"; }; + hifiberry-dac2hd = { overlayFile = "hifiberry-dacplusadcpro"; alsaCard = "sndrpihifiberry"; }; # IQaudio DAC+ (standard) - iqaudio-dacplus = { overlay = "iqaudio-dacplus"; alsaCard = "IQaudIODAC"; }; - # IQaudio DAC Pro uses the same overlay with auto-detection flag - iqaudio-dacpro = { overlay = "iqaudio-dacplus,auto"; alsaCard = "IQaudIODAC"; }; + iqaudio-dacplus = { overlayFile = "iqaudio-dacplus"; alsaCard = "IQaudIODAC"; }; + # IQaudio DAC Pro — same base overlay. Upstream recommends the ",auto" + # parameter for Pro auto-detection; hardware.deviceTree.overlays cannot + # express overlay parameters, so the Pro is driven by the base overlay + # and detection should be verified on the hardware. + iqaudio-dacpro = { overlayFile = "iqaudio-dacplus"; alsaCard = "IQaudIODAC"; }; }; selected = overlayMap.${dacCfg.model}; @@ -43,16 +46,24 @@ in { }; config = lib.mkIf (cfg.enable && dacCfg.enable) { - # Enable the DAC overlay and disable onboard audio on Pi 4. - # Pi 5 uses a compatible mechanism — see nix/README.md for Pi 5 notes. - hardware.raspberry-pi."4" = { - dt-overlays.${selected.overlay}.enable = true; - - # WHY: Onboard BCM audio and the DAC HAT share the I2S bus; - # both cannot run simultaneously. - audio.enable = false; + # Current-nixpkgs Pi device-tree configuration: the + # hardware.raspberry-pi."4" namespace was removed upstream, so the DAC + # overlay is applied through the generic hardware.deviceTree mechanism + # (the dtbo ships in the rpi kernel's dtbs/overlays directory). + hardware.deviceTree = { + enable = true; + overlays = [{ + name = selected.overlayFile; + dtboFile = "${config.boot.kernelPackages.kernel}/dtbs/overlays/${selected.overlayFile}.dtbo"; + }]; }; + # WHY: onboard BCM audio and the DAC HAT share the I2S bus and cannot + # run simultaneously; the removed hardware.raspberry-pi audio switch + # did this by withholding the overlay, and the current-API equivalent + # is to keep the onboard audio driver from loading. + boot.blacklistedKernelModules = [ "snd_bcm2835" ]; + # Set the DAC as the ALSA default card system-wide. environment.etc."asound.conf".text = '' pcm.!default { diff --git a/nix/modules/harmonia-render.nix b/nix/modules/harmonia-render.nix index e99e6f65..730cc090 100644 --- a/nix/modules/harmonia-render.nix +++ b/nix/modules/harmonia-render.nix @@ -13,7 +13,7 @@ let }; dsp = { - volume.level_db = 20.0 * (builtins.log cfg.dsp.volume / builtins.log 10.0); + volume.level_db = cfg.dsp.levelDb; replaygain = { enabled = cfg.dsp.replayGain != "off"; mode = if cfg.dsp.replayGain == "album" then "album" else "track"; @@ -101,10 +101,10 @@ in { }; dsp = { - volume = lib.mkOption { + levelDb = lib.mkOption { type = lib.types.float; - default = 1.0; - description = "Linear volume multiplier (1.0 = 0 dBFS, 0.5 = -6 dBFS)."; + default = 0.0; + description = "Output level in dBFS (0.0 = full scale, -6.0 = half amplitude). Rendered directly as the engine's volume.level_db."; }; replayGain = lib.mkOption { diff --git a/nix/tests/module-test.nix b/nix/tests/module-test.nix index 59fab9a2..ee760d96 100644 --- a/nix/tests/module-test.nix +++ b/nix/tests/module-test.nix @@ -1,6 +1,6 @@ { pkgs }: -pkgs.nixosTest { +pkgs.testers.nixosTest { name = "harmonia-basic"; nodes.server = { ... }: { diff --git a/nix/tests/render-module-test.nix b/nix/tests/render-module-test.nix index eabd8937..0c569d50 100644 --- a/nix/tests/render-module-test.nix +++ b/nix/tests/render-module-test.nix @@ -34,7 +34,7 @@ let } )).config; -in pkgs.nixosTest { +in pkgs.testers.nixosTest { name = "harmonia-render-module"; nodes.machine = { ... }: {