You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 2b72120
Browse filesBrowse the repository at this point in the historyBrowse files
feat: stereo, 48 kHz AAC for phones, CarPlay and AirPods (0.6.1) (#42)
## Problem
The AAC-256 output is back because the iPhone (Amperfy, AirPods, CarPlay) plays through it. The 0.6.0 encoder copied the source's layout into AAC as is, so a 96 kHz FLAC became 96 kHz AAC and a 5.1 FLAC became 6-channel AAC, neither of which portable players handle well. On top of that: Dependabot has three open urllib3 alerts on `uv.lock`, the repo carried 37 ruff findings and 15 unformatted files, and an Ogg transcoded into a lossy output as `X.mp3` blocked a later `X.flac` from ever being encoded there.
## Changes
One commit per item:
1. **`style:`** runs `ruff format` over `src` and `tests` and applies the mechanical fixes: unused imports, import order, an explicit `check=False`, `logger.exception`, one merged `if`, and `Path.read_text` in two tests. There is no behaviour change; the suite passed right after it. 23 findings are left (20 `BLE001` blind `except Exception`, 3 `S110` try/except/pass). Narrowing those changes which errors stop the watcher, so that's a decision for later, not a style fix.
2. **`fix(deps):`** adds `urllib3>=2.8.0` to the project dependencies, because the image installs from `pyproject.toml`. `uv.lock` now resolves 2.8.0. This covers GHSA-vxq7-64xx-v4gw, GHSA-8988-9cw3-xx77 and GHSA-gh4c-6fx4-qh6g.
3. **`feat:`** adds per-output `channels` and `max_sample_rate`. An `aac` output defaults to `channels: 2` and `max_sample_rate: 48000`; every other codec has no limit unless it sets one, and `0` turns a limit off. More than two channels get `-ac 2`. 88.2 and 176.4 kHz drop to 44.1 kHz, 96 and 192 kHz to 48 kHz. Nothing is upsampled or upmixed. mutagen probes the source only when the output has a limit, so ALAC commands are byte-for-byte the same. The version is now 0.6.1.
4. **`fix:`** adds `.atw-manifest.json` in each output folder, mapping each output file to its source (path, size, mtime) and how it was made (`encode`, `copy` or `transcode`). When a lossless source finds its output was made from a lossy source, it re-encodes it. A missing or unreadable manifest means "unknown" and behaves exactly as before. The orphan pass drops rows whose file is gone. Writes are batched: at most every 5 s, plus once at the end of each sync.
5. **Review fixes:** a lossy AAC source that exceeds the limits is transcoded rather than copied. A 44.1 kHz-family rate falls back to the cap when its family has no rate under it. A manifest row records the output's size and mtime, and a file changed since it was written is never replaced on the row's word.
Existing AAC files are not rebuilt by the upgrade. The CHANGELOG says so and gives a one-line command that deletes only the AAC files above 48 kHz or 2 channels, so the next periodic sync re-encodes them. I ran that command on sample files: a 96 kHz file, a 5.1 file and a file with an apostrophe in a subfolder were removed, and a 44.1 kHz stereo file was kept.
## Testing
- CI ran in `python:3.14-slim` with FFmpeg 7.1.5: 418 passed, 0 skipped, 97% coverage. Codecov patch is 100% and project 96.27%. Locally, before the review fixes, it was 409 passed, with `manifest.py` at 100%.
- The real-file tests use ffmpeg 9 locally and 7.1 in CI:
- a 96 kHz FLAC through the AAC encode comes out at 48 kHz;
- a 5.1 FLAC comes out as 2 channels;
- a 96 kHz 5.1 FLAC through ALAC stays 96 kHz and 6 channels.
- Every new test failed against a deliberate break, about 30 mutations, run with bytecode caching off. One break first passed (the halving step when the cap is below 44.1 or 48 kHz), so I added two rate cases for it.
- Ruff: `ruff check src tests` is down from 37 findings to 23 (the 20 `BLE001` and 3 `S110` above), and `ruff format --check` is clean on all 19 files.
Copy file name to clipboardExpand all lines: CHANGELOG.md
+19Lines changed: 19 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -2,6 +2,24 @@
2
2
3
3
All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses [Semantic Versioning](https://semver.org/).
4
4
5
+
## [0.6.1] - 2026-10-04
6
+
7
+
### Added
8
+
9
+
- Per-output `channels` and `max_sample_rate`. An `aac` output now defaults to `channels: 2` and `max_sample_rate: 48000`, so the AAC copies play everywhere a phone, AirPods or CarPlay can take them: anything with more than two channels is downmixed to stereo, 88.2 and 176.4 kHz sources become 44.1 kHz, and 96 and 192 kHz sources become 48 kHz. Nothing is ever upsampled or upmixed. A lossy AAC source that exceeds the limits is transcoded rather than copied. Other codecs have no limit unless they set one, and `0` turns a limit off. ALAC is unchanged.
10
+
11
+
### Fixed
12
+
13
+
- A lossless file that arrives after a lossy one of the same name now replaces the lossy file's transcode too, not only its copy. An Ogg transcoded into an MP3 output as `X.mp3` used to block the later `X.flac` from ever being encoded there. Each output folder now keeps `.atw-manifest.json`, recording which source made each file and how (encode, copy or transcode). A row is trusted only while the output still has the size and modification time it was written with. A missing or unreadable manifest means "unknown", which behaves exactly as before, and the orphan pass drops rows whose file is gone.
14
+
15
+
### Upgrading
16
+
17
+
- Existing AAC files are not rebuilt by the upgrade. They are re-encoded only when their source changes, or with `force_reencode: true` on startup. To rebuild just the hi-res and multichannel ones, delete the AAC files above 48 kHz or 2 channels and the next periodic sync encodes them again: `docker exec audio_transcoder find /music/aac -name '*.m4a' -exec sh -c 'ffprobe -v error -select_streams a:0 -show_entries stream=sample_rate,channels -of default=nw=1 "$1" | awk -F= "/^sample_rate/{r=\$2} /^channels/{c=\$2} END{exit !(r>48000||c>2)}" && rm -v "$1"' _ {} \;`
18
+
19
+
### Security
20
+
21
+
-`urllib3` is now required at 2.8.0 or newer, for GHSA-vxq7-64xx-v4gw, GHSA-8988-9cw3-xx77 and GHSA-gh4c-6fx4-qh6g. It comes in through syncedlyrics and requests; the image installs from `pyproject.toml`, so the floor is declared there and `uv.lock` resolves 2.8.0.
22
+
5
23
## [0.6.0] - 2026-10-04
6
24
7
25
### Changed
@@ -35,4 +53,5 @@ All notable changes to this project are documented here. The format follows [Kee
35
53
- Lossy copies are written through a temp file and renamed, like encodes, so a half-written copy is never visible.
36
54
-`force_reencode: true` purges the outputs once at startup. It used to purge them again on every periodic sync, which re-encoded the whole library every five minutes.
Edit `config.yaml` first so its outputs match the folders you mount; the example writes ALAC, MP3 and AAC. [Getting started](https://github.com/GeiserX/audio-transcode-watcher/blob/main/docs/getting-started.md) has Docker Compose and the full `docker run`.
Copy file name to clipboardExpand all lines: docs/configuration.md
+24-1Lines changed: 24 additions & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -75,7 +75,30 @@ A lossless source is encoded to every output.
75
75
76
76
A lossy source is never encoded into a lossless output, because that only makes a big file that looks lossless. It is copied there unchanged, with its own extension, so an ALAC folder can hold `.m4a` encodes next to `.mp3` or `.ogg` copies. Into a lossy output it is copied unchanged when it already has that output's codec (`.mp3` into `mp3`, `.m4a` or `.aac` into `aac`, `.opus` into `opus`) and transcoded otherwise. Copies keep their tags and cover as they are.
77
77
78
-
When a lossless and a lossy file share a name in the source folder, the lossless one is used for every output. If the lossy file came first and was already copied, the copy is removed and the lossless file is encoded as soon as it is processed.
78
+
When a lossless and a lossy file share a name in the source folder, the lossless one is used for every output. If the lossy file came first and was already copied or transcoded, that file is replaced by an encode of the lossless one as soon as it is processed. To tell those files apart, each output folder keeps a hidden `.atw-manifest.json` recording which source made each file and whether it was an encode, a copy or a transcode. Deleting it is safe: files it does not know about are treated as they were before 0.6.1.
79
+
80
+
## Portable limits (channels and sample rate)
81
+
82
+
Each output can cap the channel count and the sample rate:
83
+
84
+
```yaml
85
+
- name: aac-256
86
+
codec: aac
87
+
bitrate: 256k
88
+
path: /music/aac
89
+
channels: 2 # downmix anything with more channels to stereo
`aac` outputs get `channels: 2` and `max_sample_rate: 48000` when they don't set them, because they are for phones, AirPods and CarPlay. Every other codec has no limit unless you set one, and `0` turns a limit off (also for `aac`).
94
+
95
+
The sample rate never goes up, and a rate above the cap drops within its own family: 88.2 and 176.4 kHz become 44.1 kHz, 96 and 192 kHz become 48 kHz. A rate from neither family goes to the cap. A mono or stereo source is never upmixed. A lossy source that would normally be copied into the output (an `.m4a` into `aac`) is transcoded instead when it exceeds a limit. ALAC and the other lossless outputs keep the source's rate and channels.
96
+
97
+
Changing these settings does not rebuild files that already exist. They are re-encoded when their source changes, or on startup with `force_reencode: true`. To rebuild only the files above the limits, delete them and let the next periodic sync encode them again:
Copy file name to clipboardExpand all lines: docs/how-it-works.md
+4Lines changed: 4 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -11,6 +11,10 @@
11
11
12
12
Lossy sources are copied rather than encoded where that keeps quality honest; see [Source formats](configuration.md#source-formats).
13
13
14
+
## Provenance manifest
15
+
16
+
Each output folder holds `.atw-manifest.json`, a map from each output file to the source that made it (path, size, modification time) and how: `encode`, `copy` or `transcode`. It is written in batches at most every 5 seconds and at the end of each sync. It is only used to replace a file made from a lossy source once a lossless source of the same name appears. A missing or unreadable manifest just means "unknown".
17
+
14
18
## Corrupt sources
15
19
16
20
FFmpeg runs with `-xerror` and `-err_detect crccheck+explode`, so a frame whose checksum does not match stops the encode. If a source does not decode cleanly, the encode fails even when FFmpeg exits 0 but printed a decode error. The error is logged with the file name and no output is written. The file is not tried again until its modification time changes, or the service restarts.
0 commit comments