- Python 100%
| assets/fonts | ||
| tests | ||
| vendor | ||
| .gitignore | ||
| dactest.py | ||
| ipod-player@.service | ||
| ipodctl.py | ||
| main.py | ||
| README.md | ||
| requirements.txt | ||
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.pydactest.pyipodctl.pyvendor/adafruit_tlv320.pyvendor/LICENSE.adafruit-tlv320assets/fonts/LiberationMono-Regular.ttfassets/fonts/LICENSE.Liberationrequirements.txtipod-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.pyconfiguration 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.pyremains the baseline configuration before saved controls are applied.
Volume and gain API:
volumeaccepts integer0–100, mapped linearly from-30 dBto0 dBusing onlydac.dac_volume.gainacceptslow,med, orhigh, mapped linearly to headphone left/right gain3,6, or9 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.jsonand 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()mask0x03, producing BCLK/PLL clock muxP0/R4 = 0x07. - 44.1 and 48 kHz BCLK configurations use
J=32,NDAC=8,MDAC=2, andDOSR=128. - HPL/HPR PGA writes force TI-required reserved D1 high, producing
0x06at 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.