Skip to content

Commit 2b72120

Browse files
authored
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.
1 parent dd2708d commit 2b72120

27 files changed

Lines changed: 1738 additions & 465 deletions

‎CHANGELOG.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,24 @@
22

33
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/).
44

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+
523
## [0.6.0] - 2026-10-04
624

725
### Changed
@@ -35,4 +53,5 @@ All notable changes to this project are documented here. The format follows [Kee
3553
- Lossy copies are written through a temp file and renamed, like encodes, so a half-written copy is never visible.
3654
- `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.
3755

56+
[0.6.1]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.6.1
3857
[0.6.0]: https://github.com/GeiserX/audio-transcode-watcher/releases/tag/v0.6.0

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Keep one library in several formats: lossless for the archive, lossy for phones
3535
curl -fsSL -o config.yaml https://raw.githubusercontent.com/GeiserX/audio-transcode-watcher/main/config.example.yaml
3636
docker run -d --name audio_transcoder -e CONFIG_FILE=/app/config.yaml \
3737
-v ./config.yaml:/app/config.yaml:ro -v /path/to/flac:/music/flac:ro -v /path/to/mp3:/music/mp3 \
38-
drumsergio/audio-transcoder:0.6.0
38+
drumsergio/audio-transcoder:0.6.1
3939
```
4040

4141
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`.

‎config.example.yaml‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Audio Transcode Watcher Configuration (0.6.0)
1+
# Audio Transcode Watcher Configuration (0.6.1)
22
# ======================================
33
#
44
# This configuration file defines the source folder and output destinations
@@ -28,12 +28,18 @@ outputs:
2828
path: /music/mp3
2929
include_artwork: true
3030

31-
# AAC 256kbps for modern devices
31+
# AAC 256kbps for phones, AirPods and CarPlay
3232
- name: aac-256
3333
codec: aac
3434
bitrate: 256k
3535
path: /music/aac
3636
include_artwork: true
37+
# Portable limits. These are the AAC defaults, shown for reference:
38+
# more than 2 channels are downmixed to stereo, and rates above 48 kHz
39+
# drop to 44.1 kHz (88.2/176.4 kHz sources) or 48 kHz (96/192 kHz).
40+
# Lower rates are never upsampled. Any codec can opt in; 0 = no limit.
41+
channels: 2
42+
max_sample_rate: 48000
3743

3844
# Optional settings
3945
settings:

‎docs/ROADMAP.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Roadmap
22

3-
Current version: **0.6.0**
3+
Current version: **0.6.1**
44

55
## Completed
66

@@ -12,6 +12,7 @@ Current version: **0.6.0**
1212
- **Whisper local transcription fallback** for lyrics (v0.4.0, removed in v0.6.0)
1313
- **Recursive directory support** -- mirror source folder hierarchy in outputs (v0.5.0)
1414
- **Lossy sources copied, not inflated**, corrupt sources fail loudly, ReplayGain and MusicBrainz tags kept in M4A outputs, configurable periodic sync (v0.6.0)
15+
- **Portable AAC**: stereo downmix and a 48 kHz cap for AAC outputs, per-output `channels` and `max_sample_rate` (v0.6.1)
1516

1617
## v0.7.0 -- Quality of Life
1718

‎docs/configuration.md‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,30 @@ A lossless source is encoded to every output.
7575

7676
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.
7777

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
90+
max_sample_rate: 48000 # resample anything above 48 kHz
91+
```
92+
93+
`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:
98+
99+
```bash
100+
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"' _ {} \;
101+
```
79102

80103
## Tags in ALAC and AAC outputs
81104

‎docs/getting-started.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ outputs:
2929
```yaml
3030
services:
3131
audio-transcoder:
32-
image: drumsergio/audio-transcoder:0.6.0
32+
image: drumsergio/audio-transcoder:0.6.1
3333
container_name: audio_transcoder
3434
environment:
3535
- TZ=Europe/Madrid
@@ -60,6 +60,6 @@ docker run -d \
6060
-v /path/to/flac:/music/flac:ro \
6161
-v /path/to/mp3:/music/mp3 \
6262
--restart unless-stopped \
63-
drumsergio/audio-transcoder:0.6.0
63+
drumsergio/audio-transcoder:0.6.1
6464
```
6565

‎docs/how-it-works.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111

1212
Lossy sources are copied rather than encoded where that keeps quality honest; see [Source formats](configuration.md#source-formats).
1313

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+
1418
## Corrupt sources
1519

1620
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.

‎pyproject.toml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ build-backend = "hatchling.build"
55
[project]
66
name = "audio-transcode-watcher"
77

8-
version = "0.6.0"
8+
version = "0.6.1"
99

1010
description = "Watch a source folder and automatically transcode audio files to multiple formats"
1111
readme = "README.md"
@@ -30,6 +30,9 @@ dependencies = [
3030
"pyyaml>=6.0",
3131
"mutagen>=1.47.0",
3232
"syncedlyrics>=1.0.0",
33+
# Transitive via syncedlyrics/requests; pinned up for GHSA-vxq7-64xx-v4gw,
34+
# GHSA-8988-9cw3-xx77 and GHSA-gh4c-6fx4-qh6g.
35+
"urllib3>=2.8.0",
3336
]
3437

3538
[project.optional-dependencies]
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Audio Transcode Watcher - Automatic audio file transcoding."""
22

3-
__version__ = "0.6.0"
3+
__version__ = "0.6.1"

‎src/audio_transcode_watcher/config.py‎

Lines changed: 49 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -42,45 +42,64 @@
4242
"opus": "128k",
4343
}
4444

45+
# Portable-playback limits applied when an output does not set them.
46+
# AAC goes to phones, AirPods and CarPlay: stereo, at most 48 kHz.
47+
# Any codec can opt in by setting channels / max_sample_rate; 0 = no limit.
48+
DEFAULT_CHANNELS = {"aac": 2}
49+
DEFAULT_MAX_SAMPLE_RATE = {"aac": 48000}
50+
4551

4652
@dataclass
4753
class OutputConfig:
4854
"""Configuration for a single output destination."""
49-
55+
5056
name: str
5157
codec: str
5258
path: str
5359
bitrate: str = ""
5460
include_artwork: bool = True
55-
61+
channels: int | None = None # Downmix above this many channels; 0 = keep
62+
max_sample_rate: int | None = None # Resample above this rate (Hz); 0 = keep
63+
5664
def __post_init__(self) -> None:
5765
"""Validate and set defaults after initialization."""
5866
self.codec = self.codec.lower()
59-
67+
6068
if self.codec not in CODEC_EXTENSIONS:
6169
raise ValueError(
6270
f"Unknown codec '{self.codec}'. "
6371
f"Supported: {', '.join(CODEC_EXTENSIONS.keys())}"
6472
)
65-
73+
6674
# Set default bitrate for lossy codecs
6775
if not self.bitrate and self.codec in DEFAULT_BITRATES:
6876
self.bitrate = DEFAULT_BITRATES[self.codec]
69-
77+
7078
# Artwork not supported for some codecs
7179
if self.include_artwork and self.codec not in ARTWORK_SUPPORTED_CODECS:
7280
self.include_artwork = False
73-
81+
82+
if self.channels is None:
83+
self.channels = DEFAULT_CHANNELS.get(self.codec, 0)
84+
if self.max_sample_rate is None:
85+
self.max_sample_rate = DEFAULT_MAX_SAMPLE_RATE.get(self.codec, 0)
86+
for key in ("channels", "max_sample_rate"):
87+
value = getattr(self, key)
88+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
89+
raise ValueError(
90+
f"Output '{self.name}': {key} must be a whole number, 0 for no limit"
91+
)
92+
7493
@property
7594
def extension(self) -> str:
7695
"""Get the file extension for this codec."""
7796
return CODEC_EXTENSIONS[self.codec]
78-
97+
7998
@property
8099
def is_lossless(self) -> bool:
81100
"""Check if this codec is lossless."""
82101
return self.codec in {"alac", "flac", "wav"}
83-
102+
84103
@classmethod
85104
def from_dict(cls, data: dict[str, Any]) -> OutputConfig:
86105
"""Create OutputConfig from a dictionary."""
@@ -90,13 +109,15 @@ def from_dict(cls, data: dict[str, Any]) -> OutputConfig:
90109
path=data["path"],
91110
bitrate=data.get("bitrate", ""),
92111
include_artwork=data.get("include_artwork", True),
112+
channels=data.get("channels"),
113+
max_sample_rate=data.get("max_sample_rate"),
93114
)
94115

95116

96117
@dataclass
97118
class Config:
98119
"""Main configuration for audio-transcode-watcher."""
99-
120+
100121
source_path: str
101122
outputs: list[OutputConfig] = field(default_factory=list)
102123
force_reencode: bool = False
@@ -106,48 +127,52 @@ class Config:
106127
min_stable_seconds: float = 1.0
107128
fetch_lyrics: bool = True # Auto-fetch .lrc lyrics via syncedlyrics
108129
sync_interval_seconds: int = 300 # Seconds between periodic full syncs
109-
130+
110131
def __post_init__(self) -> None:
111132
"""Validate configuration after initialization."""
112133
if not self.source_path:
113134
raise ValueError("source_path is required")
114135

115136
interval = self.sync_interval_seconds
116-
if isinstance(interval, bool) or not isinstance(interval, (int, float)) or interval <= 0:
137+
if (
138+
isinstance(interval, bool)
139+
or not isinstance(interval, (int, float))
140+
or interval <= 0
141+
):
117142
raise ValueError("sync_interval_seconds must be a number greater than 0")
118-
143+
119144
if not self.outputs:
120145
raise ValueError("At least one output is required")
121-
146+
122147
# Check for duplicate output names
123148
names = [o.name for o in self.outputs]
124149
if len(names) != len(set(names)):
125150
raise ValueError("Duplicate output names detected")
126-
151+
127152
# Check for duplicate output paths
128153
paths = [o.path for o in self.outputs]
129154
if len(paths) != len(set(paths)):
130155
raise ValueError("Duplicate output paths detected")
131-
156+
132157
@property
133158
def output_paths(self) -> list[str]:
134159
"""Get list of all output directory paths."""
135160
return [o.path for o in self.outputs]
136-
161+
137162
def get_output_by_name(self, name: str) -> OutputConfig | None:
138163
"""Get an output configuration by name."""
139164
for output in self.outputs:
140165
if output.name == name:
141166
return output
142167
return None
143-
168+
144169
@classmethod
145170
def from_dict(cls, data: dict[str, Any]) -> Config:
146171
"""Create Config from a dictionary."""
147172
outputs = [OutputConfig.from_dict(o) for o in data.get("outputs", [])]
148173
settings = data.get("settings", {})
149174
_warn_deprecated_settings(settings)
150-
175+
151176
return cls(
152177
source_path=data.get("source", {}).get("path", ""),
153178
outputs=outputs,
@@ -159,14 +184,14 @@ def from_dict(cls, data: dict[str, Any]) -> Config:
159184
fetch_lyrics=settings.get("fetch_lyrics", True),
160185
sync_interval_seconds=settings.get("sync_interval_seconds", 300),
161186
)
162-
187+
163188
@classmethod
164189
def from_yaml_file(cls, path: str) -> Config:
165190
"""Load configuration from a YAML file."""
166191
with open(path, "r", encoding="utf-8") as f:
167192
data = yaml.safe_load(f)
168193
return cls.from_dict(data)
169-
194+
170195
@classmethod
171196
def from_json_string(cls, json_str: str) -> Config:
172197
"""Load configuration from a JSON string."""
@@ -191,11 +216,11 @@ def _warn_deprecated_settings(settings: dict[str, Any]) -> None:
191216
def load_config() -> Config:
192217
"""
193218
Load configuration from environment variables.
194-
219+
195220
Configuration is loaded from one of these sources (in priority order):
196221
1. CONFIG_FILE env var - path to a YAML config file
197222
2. CONFIG_JSON env var - JSON string with full configuration
198-
223+
199224
Raises:
200225
ValueError: If no valid configuration is found
201226
"""
@@ -205,12 +230,12 @@ def load_config() -> Config:
205230
if not Path(config_file).exists():
206231
raise ValueError(f"CONFIG_FILE not found: {config_file}")
207232
return Config.from_yaml_file(config_file)
208-
233+
209234
# Try CONFIG_JSON
210235
config_json = os.getenv("CONFIG_JSON")
211236
if config_json:
212237
return Config.from_json_string(config_json)
213-
238+
214239
# No configuration provided
215240
raise ValueError(
216241
"No configuration found. Set CONFIG_FILE (path to YAML config) "

0 commit comments

Comments
 (0)