No description
Find a file
2026-08-10 20:08:57 +01:00
assets/fonts Use lighter battery percentage font 2026-08-10 19:44:06 +01:00
tests Raise linear gain presets to nine dB 2026-08-10 20:08:57 +01:00
vendor Vendor patched TLV320 driver 2026-08-10 19:08:54 +01:00
.gitignore Add persistent volume and gain controls 2026-08-10 18:54:25 +01:00
dactest.py Select safe playback sample-rate family 2026-08-10 19:29:17 +01:00
ipod-player@.service Avoid blocking on I2C device unit 2026-08-10 18:24:35 +01:00
ipodctl.py Show playback format in status 2026-08-10 19:32:32 +01:00
main.py Raise linear gain presets to nine dB 2026-08-10 20:08:57 +01:00
README.md Raise linear gain presets to nine dB 2026-08-10 20:08:57 +01:00
requirements.txt Vendor patched TLV320 driver 2026-08-10 19:08:54 +01:00

iPod player daemon

Persistent Raspberry Pi SH1106/MAX17048 now-playing UI with fail-closed TLV320DAC3100 headphone configuration, SoX playback, and a local ipodctl command.

Files to copy

Copy these files into /home/YOUR_USER/ipod-player on the player:

  • main.py
  • dactest.py
  • ipodctl.py
  • vendor/adafruit_tlv320.py
  • vendor/LICENSE.adafruit-tlv320
  • assets/fonts/LiberationMono-Regular.ttf
  • assets/fonts/LICENSE.Liberation
  • requirements.txt
  • ipod-player@.service

Service template assumes normal Raspberry Pi home path /home/YOUR_USER. Edit paths in service file first if player uses a different home directory.

1. Copy from development machine

Replace PLAYER and YOUR_USER:

ssh YOUR_USER@PLAYER 'mkdir -p ~/ipod-player'
scp main.py dactest.py ipodctl.py requirements.txt ipod-player@.service YOUR_USER@PLAYER:~/ipod-player/
scp -r vendor assets YOUR_USER@PLAYER:~/ipod-player/

2. Prepare Raspberry Pi

Use Raspberry Pi OS Bookworm or newer with Python 3.10+. Pinned Pillow build does not support Bullseye's Python 3.9.

Run on player:

cd ~/ipod-player
sudo raspi-config

Enable Interface Options → I2C, then install OS dependencies:

sudo apt update
sudo apt install -y python3-venv python3-pip sox libsox-fmt-all i2c-tools
sudo usermod -aG audio,i2c,gpio "$USER"
sudo reboot

Reconnecting after reboot applies new group membership.

3. Create Python environment

Run on player:

cd ~/ipod-player
python3 -m venv .venv
.venv/bin/python -m pip install --upgrade pip
.venv/bin/python -m pip install -r requirements.txt
chmod +x main.py dactest.py ipodctl.py

4. Optional hardware checks

Stop service before probing I²C after installation.

ls -l /dev/i2c-1 /dev/snd
id
sox --version
i2cdetect -y 1
aplay -l

Expected I²C addresses normally include TLV320DAC3100 at 0x18, SH1106 at 0x3c, and MAX17048 at 0x36. ALSA output in main.py is fixed to hw:0,0.

Playback output is always signed 16-bit stereo. Source sample rate is read with soxi; 44.1 kHz family multiples such as 88.2/176.4 kHz output at 44.1 kHz, while 48 kHz family multiples such as 96/192 kHz output at 48 kHz. Common lower rates use their family, including 32 kHz → 48 kHz. Other rates use the numerically nearest supported output. DAC clocks are configured to the selected output rate before SoX starts.

Run authoritative DAC configuration by itself before first service test:

cd ~/ipod-player
.venv/bin/python dactest.py

Expected first line is Speaker enabled: False. Do not continue if script fails or reports speaker enabled.

Confirm application resolves bundled patched driver, not a site-packages copy:

.venv/bin/python -c 'from vendor import adafruit_tlv320 as d; print(d.__version__, d.__file__)'

Expected version is 1.3.2+ipod.1 and path ends in ipod-player/vendor/adafruit_tlv320.py.

5. Test manually

Terminal 1 on player:

cd ~/ipod-player
IPOD_PLAYER_SOCKET=/tmp/ipod-player.sock .venv/bin/python main.py --socket /tmp/ipod-player.sock

Terminal 2:

cd ~/ipod-player
IPOD_PLAYER_SOCKET=/tmp/ipod-player.sock ./ipodctl.py status
IPOD_PLAYER_SOCKET=/tmp/ipod-player.sock ./ipodctl.py play "/absolute/path/song.flac"
IPOD_PLAYER_SOCKET=/tmp/ipod-player.sock ./ipodctl.py stop

Press Ctrl+C in terminal 1 after testing. Do not continue until OLED, battery reading, and audio work manually.

6. Enable boot service

Unit is an instance template. Instance name is player username.

cd ~/ipod-player
sudo cp ipod-player@.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now "ipod-player@${USER}.service"

Check service:

systemctl status "ipod-player@${USER}.service"
journalctl -u "ipod-player@${USER}.service" -f

At boot, daemon creates /run/ipod-player/control.sock with mode 0600 and displays idle Not playing UI.

7. Install terminal command

sudo ln -sf "/home/${USER}/ipod-player/ipodctl.py" /usr/local/bin/ipodctl

Usage:

ipodctl status
ipodctl play "/absolute/path/song.flac"
ipodctl pause     # toggle pause/resume
ipodctl resume    # explicit resume
ipodctl stop
ipodctl volume
ipodctl volume 75
ipodctl gain
ipodctl gain med

While playing or paused, ipodctl status includes current selected output format, for example 48 kHz / 16-bit.

New play command stops current SoX process before starting requested file. Natural completion returns display to idle UI. Pause sends SIGSTOP to SoX; resume sends SIGCONT. OLED metadata remains visible, progress freezes, and a pause icon overlays the progress bar.

DAC safety behavior:

  • Full dactest.py configuration runs when daemon starts.
  • Full configuration runs again before every SoX launch.
  • Speaker amplifier is explicitly disabled and read back before playback.
  • Any enabled or unverifiable speaker state prevents playback from starting.
  • dactest.py remains the baseline configuration before saved controls are applied.

Volume and gain API:

  • volume accepts integer 0–100, mapped linearly from -30 dB to 0 dB using only dac.dac_volume.
  • gain accepts low, med, or high, mapped linearly to headphone left/right gain 3, 6, or 9 dB.
  • Reads use cached daemon state and do not access I²C.
  • Updates write DAC properties immediately without restarting SoX.
  • Settings persist atomically in audio-state.json and are reapplied after every DAC reset and player restart.

Raw newline-delimited JSON requests use the same Unix socket:

{"command":"pause"}
{"command":"resume"}
{"command":"volume"}
{"command":"volume","value":75}
{"command":"gain"}
{"command":"gain","value":"med"}

Updating

Copy changed files into ~/ipod-player, refresh dependencies if requirements.txt changed, then restart:

cd ~/ipod-player
.venv/bin/python -m pip install -r requirements.txt
sudo systemctl restart "ipod-player@${USER}.service"

Removal

sudo systemctl disable --now "ipod-player@${USER}.service"
sudo rm -f /etc/systemd/system/ipod-player@.service /usr/local/bin/ipodctl
sudo systemctl daemon-reload

Project directory and virtual environment remain until manually deleted.

Troubleshooting

ipodctl: [Errno 2] No such file or directory

Daemon is not running or socket was not created:

systemctl status "ipod-player@${USER}.service"
journalctl -u "ipod-player@${USER}.service" -n 100 --no-pager

Permission denied

Run ipodctl as same user named in service instance. Confirm user belongs to audio, i2c, and gpio groups:

id
ls -l /run/ipod-player/control.sock

ALSA device busy or missing

aplay -l
fuser -v /dev/snd/*

Confirm target output is card 0/device 0 or change ALSA_DEVICE in main.py.

Service restart loop

Stop loop before debugging manually:

sudo systemctl stop "ipod-player@${USER}.service"
journalctl -u "ipod-player@${USER}.service" -n 100 --no-pager

Metadata marquee

Titles, artists, and albums that fit remain stationary. Overflowing text holds at its beginning for 1.5 seconds, scrolls left at 15 pixels/second until its end is visible, then holds for 1 second before repeating or rotating. Artist and album each remain selected long enough to reveal their full text; short artist/album values retain the 3-second rotation.

Bundled bottom-row font

Battery percentage uses bundled Liberation Mono Regular at 12 px for clearer 6, 8, and 9 shapes. Timestamps use the same face at 11 px with -1 px tracking so character spacing visually matches the battery percentage while retaining spaces around /. Remaining OLED typography is unchanged. Font is distributed under included SIL Open Font License 1.1.

Bundled TLV320 driver

Application imports vendor.adafruit_tlv320 explicitly. An installed upstream adafruit_tlv320 module cannot be selected accidentally. Bundled source is based on Adafruit 1.3.2 under its included MIT license.

Bundled 1.3.2+ipod.1 patches:

  • PLL source field uses unshifted _set_bits() mask 0x03, producing BCLK/PLL clock mux P0/R4 = 0x07.
  • 44.1 and 48 kHz BCLK configurations use J=32, NDAC=8, MDAC=2, and DOSR=128.
  • HPL/HPR PGA writes force TI-required reserved D1 high, producing 0x06 at 0 dB unmuted.

requirements.txt installs direct Blinka and BusDevice dependencies, not upstream TLV320 package. A stale upstream installation is harmless because imports are explicit; it may be removed with:

.venv/bin/python -m pip uninstall adafruit-circuitpython-tlv320

Unit tests

Tests use only mocks and never access GPIO, I²C, ALSA, or physical DAC hardware:

.venv/bin/python -m unittest discover -s tests -v

Validation boundary

Development-host checks cover syntax, JSON protocol, DAC configuration ordering with mocks, CLI behavior, subprocess transitions, and shutdown cleanup. OLED, I²C, MAX17048, TLV320DAC3100 readback, speaker disablement, ALSA, SoX output, and boot behavior must be verified on player using steps above.