Adafruit_Learning_System_Gu.../CircuitPython_JEplayer_mp3/code.py
Jeff Epler 1c059d2cd8
Bring project up to date
Tested on 8.0.0-beta.4

Gets rid of gamepadshift, adjusts for other incompatible changes
2022-11-01 09:57:54 -05:00

531 lines
18 KiB
Python

# SPDX-FileCopyrightText: 2020 Jeff Epler for Adafruit Industries
#
# SPDX-License-Identifier: MIT
# The MIT License (MIT)
#
# Copyright (c) 2020 Jeff Epler for Adafruit Industries LLC
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
"""
jeplayer - main file
This is an MP3 player for the PyGamer with CircuitPython.
See README.md for more information.
"""
import gc
import os
import random
import time
import adafruit_bitmap_font.bitmap_font
import adafruit_display_text.label
from adafruit_progressbar import ProgressBar
import sdcardio
import analogjoy
import audioio
import audiomp3
import board
import busio
import digitalio
import displayio
import terminalio
from keypad import ShiftRegisterKeys
import icons
import neopixel
import repeat
import storage
from micropython import const
def clear_display():
"""Display nothing"""
board.DISPLAY.show(displayio.Group())
clear_display()
# pylint: disable=invalid-name
def px(x, y):
"""Convert a raw value (x/y) to a pixel value, clamping negative values"""
return 0 if x <= 0 else round(x / y)
# pylint: enable=invalid-name
(ICON_PLAY, ICON_PAUSE, ICON_STOP, ICON_PREV, ICON_NEXT, ICON_REPEAT,
ICON_SHUFFLE, ICON_FOLDERNEXT) = range(8)
class PlaybackDisplay:
"""Manage display during playback"""
def __init__(self):
self.group = displayio.Group()
self.glyph_width, self.glyph_height = font.get_bounding_box()[:2]
self.pbar = ProgressBar(0, 0, board.DISPLAY.width,
self.glyph_height*2, bar_color=0x0000ff,
outline_color=0x333333, stroke=1)
self.iconbar = icons.IconBar()
self.iconbar.group.y = 1000
for i in range(5, 8):
self.iconbar.icons[i].x += 32
self.label = adafruit_display_text.label.Label(font, line_spacing=1.0)
self.label.y = 4
self._bitmap_filename = None
self._fallback_bitmap = ["/rsrc/background.bmp"]
self._rms = 0.
self._text = ""
self.set_bitmap([]) # Must be first!
self.group.append(self.pbar)
self.group.append(self.label)
self.group.append(self.iconbar.group)
self.pixels = neopixel.NeoPixel(board.NEOPIXEL, 5)
self.pixels.auto_write = False
self.pixels.fill(0)
self.pixels.show()
self.paused = False
self.next_choice = 0
self.tile_grid = None
@property
def text(self):
"""The text shown at the top of the display. Usually 2 lines."""
return self._text
@text.setter
def text(self, text):
if len(text) > 256:
text = text[:256]
self._text = text
self.label.text = text
@property
def progress(self):
"""The fraction of progress through the current track"""
return self.pbar.progress
@progress.setter
def progress(self, frac):
self.pbar.progress = frac
def set_bitmap(self, candidates):
"""Find and use a background from among candidates, or else the fallback bitmap"""
for i in candidates + self._fallback_bitmap:
if i == self._bitmap_filename:
return # Already loaded
# CircuitPython 6 & 7 compatible
try:
bitmap_file = open(i, 'rb')
except OSError:
continue
bitmap = displayio.OnDiskBitmap(bitmap_file)
self._bitmap_filename = i
# Create a TileGrid to hold the bitmap
self.tile_grid = displayio.TileGrid(
bitmap,
pixel_shader=getattr(
bitmap, "pixel_shader", displayio.ColorConverter()
),
)
# # CircuitPython 7+ compatible
# try:
# bitmap = displayio.OnDiskBitmap(i)
# except OSError:
# continue
# self._bitmap_filename = i
# # Create a TileGrid to hold the bitmap
# self.tile_grid = displayio.TileGrid(
# bitmap, pixel_shader=bitmap.pixel_shader
# )
# Add the TileGrid to the Group
if len(self.group) == 0:
self.group.append(self.tile_grid)
else:
self.group[0] = self.tile_grid
self.tile_grid.x = (160 - bitmap.width) // 2
self.tile_grid.y = self.glyph_height*2 + max(0, (96 - bitmap.height) // 2)
break
@property
def rms(self):
"""The RMS audio level, used to control the neopixel vu meter"""
return self._rms
@rms.setter
def rms(self, value):
self._rms = value
self.pixels[0] = (20, 0, 0) if value > 20 else (px(value, 1), 0, 0)
self.pixels[1] = (20, 0, 0) if value > 40 else (px(value - 20, 1), 0, 0)
self.pixels[2] = (20, 0, 0) if value > 80 else (px(value - 40, 2), 0, 0)
self.pixels[3] = (20, 0, 0) if value > 160 else (px(value - 80, 4), 0, 0)
self.pixels[4] = (20, 0, 0) if value > 320 else (px(value - 160, 8), 0, 0)
self.pixels.show()
# pylint: disable=too-many-branches
def press(self, idx):
"""Do the action for the current icon"""
selected = self.iconbar.selected
if selected in (ICON_PLAY, ICON_PAUSE): # Play/Pause
if self.paused:
self.resume()
else:
self.pause()
self.iconbar.select(not self.paused)
elif selected == ICON_STOP:
self.iconbar.deactivate(ICON_FOLDERNEXT)
return (-1,)
elif selected == ICON_PREV:
if self.shuffle:
return (None,)
return (idx-1,)
elif selected == ICON_NEXT:
if self.shuffle:
return (None,)
return (idx+1,)
elif selected == ICON_SHUFFLE:
self.iconbar.toggle(selected)
if self.iconbar.active[ICON_SHUFFLE]:
self.iconbar.deactivate(ICON_REPEAT)
self.iconbar.deactivate(ICON_FOLDERNEXT)
elif selected == ICON_REPEAT:
self.iconbar.toggle(selected)
if self.iconbar.active[ICON_REPEAT]:
self.iconbar.deactivate(ICON_SHUFFLE)
self.iconbar.deactivate(ICON_FOLDERNEXT)
elif selected == ICON_FOLDERNEXT:
self.iconbar.toggle(selected)
if self.iconbar.active[ICON_FOLDERNEXT]:
self.iconbar.deactivate(ICON_REPEAT)
self.iconbar.deactivate(ICON_SHUFFLE)
return None
def move(self, direction):
"""Switch the current icon in the given direction"""
self.iconbar.select((self.iconbar.selected + direction) % 8)
def play(self, stream):
"""Starting playing a stream on the speaker"""
speaker.play(stream)
self.paused = False
self.iconbar.set_active(0, not self.paused)
self.iconbar.set_active(1, self.paused)
def pause(self):
"""Pause the stream"""
speaker.pause()
self.paused = True
self.iconbar.set_active(0, not self.paused)
self.iconbar.set_active(1, self.paused)
def resume(self):
"""Resume the stream"""
speaker.resume()
self.paused = False
self.iconbar.set_active(0, not self.paused)
self.iconbar.set_active(1, self.paused)
@property
def shuffle(self):
"""Whether to shuffle the playlist"""
return self.iconbar.active[ICON_SHUFFLE]
@property
def repeat(self):
"""Whether to repeat the playlist"""
return self.iconbar.active[ICON_REPEAT]
@property
def auto_next(self):
"""Whether to play all folders"""
return self.iconbar.active[ICON_FOLDERNEXT]
@staticmethod
def has_any_mp3s(folder):
"""True if the folder contains at least one item ending in .mp3"""
return any(not fn.startswith(".") and fn.lower().endswith(".mp3")
for fn in os.listdir(folder))
def choose_folder(self, base='/sd'):
"""Let the user choose a folder within a base directory"""
all_folders = (m for m in os.listdir(base)
if not m.startswith('.') and isdir(join(base, m)))
all_folders = sorted(f for f in all_folders if self.has_any_mp3s(join(base, f)))
choices = ['Surprise Me'] + all_folders
if playback_display.auto_next:
idx = self.next_choice
else:
idx = menu_choice(choices,
sel_idx=self.next_choice,
text_font=terminalio.FONT)
clear_display()
self.next_choice = idx
if idx >= 1:
result = all_folders[idx-1]
self.next_choice = idx+1
if self.next_choice == len(choices):
self.next_choice = 1 # Go to first folder, not "surprise me"
else:
result = random.choice(all_folders)
return join(base, result)
# pylint: disable=invalid-name
enable = digitalio.DigitalInOut(board.SPEAKER_ENABLE)
enable.direction = digitalio.Direction.OUTPUT
enable.value = True
speaker = audioio.AudioOut(board.SPEAKER, right_channel=board.A1)
mp3stream = audiomp3.MP3Decoder(open("/rsrc/splash.mp3", "rb"))
speaker.play(mp3stream)
font = adafruit_bitmap_font.bitmap_font.load_font("rsrc/5x8.pcf")
playback_display = PlaybackDisplay()
board.DISPLAY.show(playback_display.group)
font.load_glyphs(range(32, 128))
joystick = analogjoy.AnalogJoystick()
up_key = repeat.KeyRepeat(lambda: joystick.up, rate=0.2)
down_key = repeat.KeyRepeat(lambda: joystick.down, rate=0.2)
left_key = repeat.KeyRepeat(lambda: joystick.left, rate=0.2)
right_key = repeat.KeyRepeat(lambda: joystick.right, rate=0.2)
buttons = ShiftRegisterKeys(clock=board.BUTTON_CLOCK,
data=board.BUTTON_OUT,
latch=board.BUTTON_LATCH, key_count=4, value_when_pressed=True)
# pylint: enable=invalid-name
def mount_sd():
"""Mount the SD card"""
spi = busio.SPI(board.SCK, MOSI=board.MOSI, MISO=board.MISO)
sdcard = sdcardio.SDCard(spi, board.SD_CS)
vfs = storage.VfsFat(sdcard)
storage.mount(vfs, "/sd")
def join(*args):
"""Like posixpath.join"""
return "/".join(args)
def shuffle(seq):
"""Shuffle a sequence using the Fisher-Yates shuffle algorithm (like random.shuffle)"""
for i in range(len(seq)-2):
j = random.randint(i, len(seq)-1)
seq[i], seq[j] = seq[j], seq[i]
# pylint: disable=too-many-locals,too-many-statements
def menu_choice(seq, *, sel_idx=0, text_font=font):
"""Display a menu and allow a choice from it"""
gc.collect()
board.DISPLAY.auto_refresh = True
scroll_idx = 0
glyph_width, glyph_height = text_font.get_bounding_box()[:2]
num_rows = min(len(seq), board.DISPLAY.height // glyph_height)
max_glyphs = board.DISPLAY.width // glyph_width
palette = displayio.Palette(2)
palette[0] = 0
palette[1] = 0xffffff
labels = [displayio.TileGrid(text_font.bitmap, pixel_shader=palette,
width=max_glyphs+1, height=1,
tile_width=glyph_width,
tile_height=glyph_height)
for i in range(num_rows)]
terminals = [terminalio.Terminal(li, text_font) for li in labels]
cursor = adafruit_display_text.label.Label(text_font, color=0xddddff)
base_y = 0
caret_offset = glyph_height//2-1
scene = displayio.Group()
for i, label in enumerate(labels):
label.x = round(glyph_width * 1.5)
label.y = base_y + glyph_height * i
scene.append(label)
cursor.x = 0
cursor.y = caret_offset
cursor.text = ">"
scene.append(cursor)
last_scroll_idx = max(0, len(seq) - num_rows)
board.DISPLAY.show(scene)
buttons.events.clear()
i = 0
old_scroll_idx = None
while True:
enable.value = speaker.playing
event = buttons.events.get()
if event and event.pressed:
return sel_idx
joystick.poll()
if up_key.value:
sel_idx -= 1
if down_key.value:
sel_idx += 1
sel_idx = min(len(seq)-1, max(0, sel_idx))
if scroll_idx > sel_idx or scroll_idx + num_rows <= sel_idx:
scroll_idx = sel_idx - num_rows // 2
scroll_idx = min(last_scroll_idx, max(0, scroll_idx))
board.DISPLAY.auto_refresh = False
if old_scroll_idx != scroll_idx:
for i in range(scroll_idx, scroll_idx + num_rows):
j = i - scroll_idx
new_text = ''
if i < len(seq):
new_text = seq[i][:max_glyphs]
terminals[j].write('\r\033[K')
terminals[j].write(new_text)
cursor.y = caret_offset + base_y + glyph_height * (sel_idx - scroll_idx)
board.DISPLAY.auto_refresh = True
old_scroll_idx = scroll_idx
time.sleep(1/20)
# pylint: enable=too-many-locals
S_IFDIR = const(16384)
def isdir(x):
"""Return True if 'x' is a directory"""
return os.stat(x)[0] & S_IFDIR
def change_stream(filename):
"""Change the global MP3Decoder object to play a new file"""
old_stream = mp3stream.file
mp3stream.file = open(filename, "rb")
old_stream.close()
return mp3stream.file
def play_one_file(idx, filename, folder, title, playlist_size):
"""Play one file, reacting to user input"""
board.DISPLAY.auto_refresh = False
playback_display.set_bitmap([
filename.rsplit('.', 1)[0] + ".bmp",
filename.rsplit('/', 1)[0] + ".bmp",
filename.rsplit('/', 1)[0] + "/cover.bmp",
])
playback_display.text = "%s\n%s" % (folder, title)
board.DISPLAY.refresh()
result = None
file_size = os.stat(filename)[6]
mp3file = change_stream(filename)
playback_display.play(mp3stream)
board.DISPLAY.auto_refresh = True
while speaker.playing:
# pylint: disable=no-member
if gc.mem_free() < 4096:
gc.collect()
playback_display.rms = mp3stream.rms_level
playback_display.progress = mp3file.tell() / file_size
joystick.poll()
if left_key.value:
playback_display.move(-1)
if right_key.value:
playback_display.move(1)
event = buttons.events.get()
if event and event.pressed:
return_now = playback_display.press(idx)
if return_now:
result = return_now[0]
break
if result is None:
if playback_display.shuffle:
if playback_display.shuffle:
# Choose a random integer .. except for this one
result = random.randrange(playlist_size-1)
if result >= idx:
result += 1
else:
result = (idx + 1)
speaker.stop()
playback_display.rms = 0
gc.collect()
return result
def play_all(playlist, *, folder='', trim=0, location='/sd'):
"""Play everything in 'playlist', which is relative to 'location'.
'folder' is a display name for the user."""
i = 0
board.DISPLAY.show(playback_display.group)
playback_display.iconbar.group.y = 112
while 0 <= i < len(playlist):
filename = playlist[i]
i = play_one_file(i, join(location, filename), folder, filename[trim:-4], len(playlist))
if i == -1:
break
if playback_display.repeat and i == len(playlist):
i = 0
speaker.stop()
clear_display()
def longest_common_prefix(seq):
"""Find the longest common prefix between all items in sequence"""
seq0 = seq[0]
for i, seq0i in enumerate(seq0):
for j in seq:
if len(j) < i or j[i] != seq0i:
return i
return len(seq0)
def play_folder(location):
"""Play everything within a given folder"""
playlist = [d for d in os.listdir(location)
if not d.startswith('.') and d.lower().endswith('.mp3')]
if not playlist:
# hmm, no mp3s in a folder? Well, don't crash okay?
del playlist
gc.collect()
return
playlist.sort()
trim = longest_common_prefix(playlist)
enable.value = True
play_all(playlist, folder=location.split('/')[-1], trim=trim, location=location)
enable.value = False
def main():
"""The main function of the player"""
try:
mount_sd()
except OSError as detail:
text = "%s\n\nInsert or re-seat\nSD card\nthen press reset" % detail.args[0]
error_text = adafruit_display_text.label.Label(font, text=text)
error_text.x = 8
error_text.y = board.DISPLAY.height // 2
g = displayio.Group()
g.append(error_text)
board.DISPLAY.show(g)
while True:
time.sleep(1)
while True:
folder = playback_display.choose_folder()
play_folder(folder)
main()