- C 100%
Channel count is now read from the file's format tag at load time instead of being a fixed 4-channel assumption: the parser, sequencer, and mixer all iterate the module's actual channel count. Mix levels are scaled down above 4 channels so 6-channel sums don't just clip harder than before. Verified on hardware with bud.mod (6CHN). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UK75xG8ktPzXgvcZcLKK8h |
||
|---|---|---|
| .gitignore | ||
| application.fam | ||
| audio_pwm_dac.c | ||
| audio_pwm_dac.h | ||
| icon.png | ||
| mixer.c | ||
| mixer.h | ||
| mod_format.h | ||
| mod_parser.c | ||
| mod_player_app.c | ||
| mod_replay.c | ||
| mod_replay.h | ||
| README.md | ||
flipper-mod-player
Amiga ProTracker .mod file player for the Flipper Zero, using the
built-in piezo speaker.
What it does
- Parses standard 4- and 6-channel ProTracker MOD files (
M.K.,M!K!,FLT4,4CHN,6CHNsignatures — 31-instrument format). - Software mixes up to 6 channels of 8-bit PCM at 32kHz into a signed 16-bit stream, scaled so simultaneous full-volume channels use the full output range without clipping.
- Streams sample data from the SD card rather than holding it in RAM (a 4KB read-ahead cache per channel), since real MOD files routinely exceed the Flipper's free heap with sample data alone.
- Runs the sequencer off the audio clock (the mixer advances ticks and rows as it renders), so tempo is sample-accurate and a mid-song speed/tempo change takes effect immediately.
- Implements the common effect subset: arpeggio, portamento (up/down/tone), vibrato, volume slide, set volume, set sample offset, position jump, pattern break, set speed/tempo, and the E1/E2/EA/EB/EC fine-slide/note-cut sub-effects. Not implemented: retrigger (E9), note delay (ED), pattern loop (E6), and the filter/funk-repeat sub-effects — cells using them are otherwise still played (note/volume/etc. still trigger), just without that specific effect.
- Browses
/ext/modson the SD card for.modfiles via the standard file picker. - Output volume control: left/right (hold to ramp) across 9 steps of 0.5x, from mute to 4x, defaulting to 2x.
Files
| File | Purpose |
|---|---|
mod_format.h / mod_parser.c |
MOD file struct definitions and loader |
(note: pattern count for locating sample data comes from the whole 128-entry order table, not just the played range — see the comment in mod_parser.c) |
|
mod_replay.h / mod_replay.c |
Tracker sequencer (row/tick/effects state machine) |
mixer.h / mixer.c |
4/6-channel software PCM mixer, sample streaming, and the audio-clock tick driver |
audio_pwm_dac.h / audio_pwm_dac.c |
Low-level PWM+DMA audio output driver |
mod_player_app.c |
GUI, input handling, app entry point |
Audio output: how it actually makes sound
The Flipper's official furi_hal_speaker API only plays a single fixed
tone (frequency + volume) — there's no public API for streaming arbitrary
PCM. To get real sample playback, audio_pwm_dac.c bypasses it and drives
the speaker pin (PB8, TIM16 channel 1) directly: TIM16 runs as a PWM
carrier at 32kHz, and DMA rewrites its compare register once per carrier
period from a circular buffer that the mixer keeps filled. The duty cycle
traces out the waveform; the piezo's own limited frequency response acts
as a (crude) reconstruction filter. There's no dedicated DAC or
anti-aliasing hardware in this signal path, so expect a background
PWM whine under the music — this is a known, inherent limitation of
Flipper Zero's audio hardware, not a bug in this player.
audio_pwm_dac.c has been built and hardware-tested against firmware
1.4.3 (commit 8622f1a2): TIM16_CLOCK_HZ (64MHz) matches that firmware's
actual APB2 timer clock, FuriHalInterruptIdDma1Ch1 and
LL_DMAMUX_REQ_TIM16_UP both exist in its SDK, and DMA1 channel 1 isn't
claimed by anything else in that firmware. One real bug turned up in
testing and is now fixed: furi_hal_speaker_acquire() (called at the top
of audio_pwm_dac_start()) already enables TIM16's bus and configures
PB8 for TIM16 AF, so this file must not call
furi_hal_bus_enable(FuriHalBusTIM16) itself — doing so hit a bare
furi_check (it asserts the peripheral bus is currently disabled before
enabling it), crashing the app on launch with no message beyond
"furi_check failed".
That bus enable is now guarded by furi_hal_bus_is_enabled() rather than
removed outright, so it also works on firmware whose
furi_hal_speaker_acquire() does not enable TIM16 — and only the
peripheral this driver actually enabled gets disabled again on stop. The
PWM autoreload is derived from SystemCoreClock rather than a hardcoded
64MHz, for the same reason.
If you're on a different firmware version, it's still worth re-checking the above against your SDK's headers, since none of it is guaranteed stable across firmware releases.
The driver counts DMA refills it missed. That is the number to check
first if playback ever stutters, since it distinguishes "the mixer can't
keep up with SD reads" from every other cause. It is logged on stop
(AudioPwmDac: N buffer underruns during playback) and, if it is ever
non-zero, shown on screen as UR n.
Building
Requires ufbt:
pip install ufbt
cd flipper-mod-player
ufbt # builds dist/mod_player.fap
ufbt launch # builds, installs over USB, and launches on a connected Flipper
You'll need to add a 10x10, 1-bit-depth icon.png to this directory
before building — ufbt requires one for any app with fap_icon set in
application.fam and none is included here (no image tooling available
when this project was generated).
Volume
MOD material is dynamically quiet — a typical track peaks a few dB below full scale and sits around −20 dBFS RMS, so at unity gain the piezo is quiet even though nothing is wrong. Left/right apply an output gain in the PWM driver's requantisation loop; above the available headroom it hard-clips, which is a deliberate loudness-for-distortion trade. Measured over two minutes of a representative module:
| level | gain | RMS | clipped samples |
|---|---|---|---|
| 2 | 1.0x | −20 dBFS | 0.000% |
| 3 | 1.5x | −16 dBFS | 0.000% |
| 4 (default) | 2.0x | −14 dBFS | 0.010% |
| 6 | 3.0x | −11 dBFS | 0.482% |
| 8 | 4.0x | −8 dBFS | 2.328% |
Level 0 pins the PWM duty at 50%, which is true silence. If even 4x is not loud enough, the next step would be a limiter rather than more gain: at −20 dBFS RMS there is a lot of room to raise average level without touching the peaks.
Known limitations
- 4- and 6-channel MODs only. Anything else (
8CHN,28CH, XM/S3M/IT) is rejected at load with the reason shown on screen, e.g.Not 4/6-channel (8CHN). - No sample interpolation (nearest-neighbor playback) — adds a bit of extra aliasing noise on top of the PWM carrier's own.
- Arpeggio looks up its semitone neighbours in the finetune-0 period table. That is the correct table to search (pattern periods are stored finetune-0, and finetune is applied afterwards when computing the playback step), but the lookup snaps to the nearest table entry, so a period reached mid-portamento arpeggiates from the nearest note.
- Sample streaming trades RAM for SD reads. Measured on hardware (firmware 1.4.3) this is not a problem: a representative module needs ~18 refills/sec, averaging under 2ms and peaking at 15ms, against the 64ms each half-buffer allows — and real playback logged zero underruns. Very high notes on all channels at once read fastest, so the margin narrows there — more so on 6-channel modules than 4-channel ones.