Amiga ProTracker (.mod) player for Flipper Zero
Find a file
Isac f147b04d73 Add 6-channel MOD support (6CHN signature)
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
2026-09-02 21:21:13 +02:00
.gitignore Fix garbled playback: sample loops, pitch, mixing, and timing 2026-09-02 17:12:21 +02:00
application.fam Add 6-channel MOD support (6CHN signature) 2026-09-02 21:21:13 +02:00
audio_pwm_dac.c Fix garbled playback: sample loops, pitch, mixing, and timing 2026-09-02 17:12:21 +02:00
audio_pwm_dac.h Fix garbled playback: sample loops, pitch, mixing, and timing 2026-09-02 17:12:21 +02:00
icon.png Add placeholder app icon 2026-09-01 14:15:46 +02:00
mixer.c Add 6-channel MOD support (6CHN signature) 2026-09-02 21:21:13 +02:00
mixer.h Fix garbled playback: sample loops, pitch, mixing, and timing 2026-09-02 17:12:21 +02:00
mod_format.h Add 6-channel MOD support (6CHN signature) 2026-09-02 21:21:13 +02:00
mod_parser.c Add 6-channel MOD support (6CHN signature) 2026-09-02 21:21:13 +02:00
mod_player_app.c Fix sample offsets for modules with unplayed patterns 2026-09-02 17:18:01 +02:00
mod_replay.c Add 6-channel MOD support (6CHN signature) 2026-09-02 21:21:13 +02:00
mod_replay.h Fix garbled playback: sample loops, pitch, mixing, and timing 2026-09-02 17:12:21 +02:00
README.md Add 6-channel MOD support (6CHN signature) 2026-09-02 21:21:13 +02:00

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, 6CHN signatures — 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/mods on the SD card for .mod files 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.