Building a portable GIF player
8.1.2022I think we're all could confirm, gifs are great! People are crazy about it. Although Gifs aren't intended for animations1 in the first place, they started a sub-genre for short moving pictures. Now platforms like Giphy and Tenor are full of these little eye candies. We already have MP3 players for music, why we don't have GIF players... So I decided to build such a thing, which is able to store two short GIFs and loop them on a small display. The GIFs should be uploadable from Giphy using an Android app.
Hardware
I used a Raspberry PI Pico as the platform for my build. They're affordable, more powerful than Arduinos and very well documented (!).
For the display, I found this ILI9341 based 2.4inch LCD display shield It has 320 by 240 pixels. Each of them is able to display 262K colours. (Definitely not HDR, but good enough for GIFs.) I connected this shield using the 8-bit parallel interface with the Pico, on-top of these eight connections, four additional wires are needed for the signalling of the display. The button, included on this shield, which I used for cycling through these two GIFs, gets also a connection to the Pico.
Software
Embedded
Let's start with the embedded part first. We have to do two things on the Pico. First, we have to handle the USB communication with the android app and of cause second we need to display the frames of the selected GIF on the display. It's pretty handy that the Pico has two CPU cores, so we can run each task on a separate core.
The first task is responsible for rendering the GIF from the flash memory to the LCD display. The off-the-shelf Pico includes 2 MB of flash memory. 1 MB is reserved for the firmware, the other 1 MB is split into two 512 KB slots for the GIF files. Because I hadn't the intention of developing my own GIF decoder, I used a very simple C implementation and modified it at two small details. First, now the GIF isn't read from a file anymore, instead, it is consumed directly from the memory-mapped flash storage. And second I reduced the memory footprint by using 16 bits for each pixel, which are 8-bit less than the original implementation. This loss in quality is negligible because the display isn't capable of displaying the whole 24-bit colour range. Because of the memory constraints, I also decided to enforce a gif size of 240 by 240 pixels. The top and bottom 40 pixels are just mirrored.
As mentioned in the previous section, the display content is sent to the LCD module via an 8-bit parallel port. For this, I put the PIO of the Pico into operation. PIO is an abbreviation for "Programmable input and output" and that tells exactly what it does; it is possible to write a program that runs directly on the IO controller. The domain-specific language used is very minimalistic, but it is sufficient to implement several bus protocols. I need the PIO for a simple job only: writing 8 bits in parallel from the PIO's internal buffer and toggling the clock pin on the way. The signal frequency is around 20 Mhz. The following listing shows the used PIO program in combination with the driver written in C.
.program ili9341_lcd
.side_set 1
.wrap_target
out pins, 8 side 0 ; output 8-bits from the internal buffer, clock low
nop side 1 ; clock high
.wrap
% c-sdk {
static inline void ili9341_lcd_program_init(PIO pio, uint sm, uint offset,
uint data_pin, uint clk_pin, float clk_div) {
// ...
}
static inline void ili9341_lcd_put(PIO pio, uint sm, uint8_t x) {
while (pio_sm_is_tx_fifo_full(pio, sm));
*(volatile uint8_t*)&pio->txf[sm] = x;
}
static inline void ili9341_lcd_wait_idle(PIO pio, uint sm) {
uint32_t sm_stall_mask = 1u << (sm + PIO_FDEBUG_TXSTALL_LSB);
pio->fdebug = sm_stall_mask;
while (!(pio->fdebug & sm_stall_mask));
}
%}
Before the LCD show a picture, it has to be initialized with a few commands waking it up, resetting it and setting the viewport. Afterwards, the selected GIF is decoded into a memory buffer and is sent to LCD pixel by pixel, frame by frame.
The second task, handling the USB communication is more complicated, due to the fact that USB is a complex protocol. For me, the first step was to design the USB communication between App and the Pico. There were two use cases to consider, reading a GIF from the Pico to the App and writing a GIF from the App to the Pico. And this for both GIF slots. The App is able to initiate one of these use cases by sending a control request to the Pico, containing the command.
bRequest |
Command |
---|---|
0x01 |
Read GIF from slot 1 |
0x02 |
Read GIF from slot 2 |
0x03 |
Write GIF to slot 1 |
0x04 |
Write GIF to slot 2 |
On a download command, the Pico writes the GIF from the flash to the BULK IN endpoint, which is read by the app (more specifically, the Android phone). On an upload command, the app writes to the BULK OUT endpoint of the pico, which then locks the flash memory and writes the content to it. I implemented this Interface with help of TinyUSB Library, so I hadn't to write any own low-level USB functions. With TinyUSB it's relatively easy to develop this Interface, first I defined the descriptors of the Pico. They contain basic information like manufacture name, device type, exposed endpoints, ... Afterwards, I wrote this callback which handles all of the incoming command requests:
bool tud_vendor_control_xfer_cb(uint8_t rhport, uint8_t stage, tusb_control_request_t const *request)
{
switch (request->bmRequestType_bit.type)
{
case TUSB_REQ_TYPE_VENDOR:
switch (request->bRequest)
{
case REQ_READ_1:
// Handle download command
// ...
And at the end I developed the logic, that does all the copying from the USB endpoint to the flash memory and vice versa. TinyUSB handles the USB communication in the background, by using interrupts, all intermediate traffic is written and read from an internal buffer. TinyUSB offers therefore the functions tud_vendor_write
and tud_vendor_read
to access these endpoint buffers.
I wrote the following script for testing purposes:
import struct
import usb1 # pip install libusb1
WRITE_SLOT_1 = 0x03
with open("nyan.gif", "rb") as f:
gif_content = f.read()
with usb1.USBContext() as context:
handle = context.openByVendorIDAndProductID(
0xCafe,
0x0420,
skip_on_error=True,
)
handle.claimInterface(0)
handle.controlWrite(2<<5 | 1, WRITE_SLOT_1, 0, 0, bytes())
out_buffer = struct.pack("<I", len(gif_content)) + gif_content
CHUNK_SIZE = 256
for i in range(0, len(out_buffer), CHUNK_SIZE):
print(handle.bulkWrite(1, out_buffer[i: i + CHUNK_SIZE]))
App
I wanted a portable solution to upload GIFs to the Player. So I wrote myself an Android App, which lets you browse the trending GIFs from Giphy and upload them to the Player over USB.
Displaying the trending GIFs is pretty straightforward. I don't go into detail about that.
Because the Screen is only 240 by 240 pixels wide and isn't able to show full depth 24Bit colours, but more important the flash memory is limited, it would be not practical to transfer GIFs directly from Giphy to the Player. Most of the GIFs would not even fit in this 512KB slot. Therefore I embedded the Library Gifsicle into the App. Gifsicle is useful for creating, editing, and getting information about GIF images and animations. I incorporated Gifsicle for scaling the GIF down and reducing the count of colours used after it is loaded from Giphy.
Uploading the GIFs over USB is on Android analogue to the python code I showed earlier. Besides that, Android implements a safety measure; the app has to ask the user for permission first, before accessing the USB Gadget.
On iPhones establishing a USB connection to an external accessory I way more complicated. As it requires the manufacturer to be part of the MFI program and needs an additional IC on the Gadget. (Maybe I write an article about this soon.)
In the end, everything worked out pretty well. I wasn't able to upload every GIF, because some are even too large after scaling them down, but that's okay.
The whole source code of the project is available on GitHub.
Prospects
What I found later, is that Pimoroni already offers a display pack for the Pico. Using this would result in a more compact design.
They also offer a Pico with 16 MB Flash and a LiPo charger. With this more GIFs could be stored on the Pico and the whole build could be driven by a battery.
I have not tried this, but you could ;)
Embedded One Day Builds