diff --git a/README.md b/README.md index c301b55..2fe38ae 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,8 @@ there is a workround if it's impossible to upgrade. See 4.2 [Constructor](./README.md#42-constructor) 4.3 [Callback methods](./README.md#43-callback-methods) Methods which run in response to events. 4.4 [Method](./README.md#44-method) Optional interface to uasyncio code. - 4.5 [Usage](./README.md#45-usage) Accessing data created in a screen. + 4.5 [Bound variable](./README.md#45-bound-variable) + 4.6 [Usage](./README.md#46-usage) Accessing data created in a screen. 5. [Window class](./README.md#5-window-class) 5.1 [Constructor](./README.md#51-constructor) 5.2 [Class method](./README.md#52-class-method) @@ -130,6 +131,7 @@ there is a workround if it's impossible to upgrade. See      23.3.2 [Class PolarCurve](./README.md#2332-class-polarcurve) 23.4 [Class TSequence](./README.md#234-class-tsequence) Plotting realtime, time sequential data. 24. [Old firmware](./README.md#24-old-firmware) For users of color displays who can't run current firmware. +25. [Realtime applications](./README.md#25-realtime-applications) Accommodating tasks requiring fast RT performance. [Appendix 1 Application design](./README.md#appendix-1-application-design) Tab order, button layout, encoder interface, use of graphics primitives # 1. Basic concepts @@ -866,7 +868,14 @@ base screen are cancelled. For finer control, applications can ignore this method and handle cancellation explicitly in code. -## 4.5 Usage +## 4.5 Bound variable + + * `pause_ms=0` Screen refreshes are performed by a looping task. This + refreshes the screen before pausing. The default of 0ms allows other tasks to + be scheduled and suffices in the vast majority of cases. In some applications + with difficult realtime requirements a longer pause can offer benefits. + +## 4.6 Usage The `Screen.change()` classmethod returns immediately. This has implications where the new, top screen sets up data for use by the underlying screen. One @@ -2657,6 +2666,44 @@ run V1.17 or later it is possible to run under V1.15+. This involves copying to `gui/core/writer.py`. This uses Python code to render text if the firmware or driver are unable to support fast rendering. +# 25. Realtime applications + +Screen refresh is performed in a continuous loop with yields to the scheduler. +In normal applications this works well, however a significant proportion of +processor time is spent performing a blocking refresh. A means of synchronising +refresh to other tasks is provided, enabling the application to control the +screen refresh. This is done by means of two `Event` instances. The refresh +task operates as below (code simplified to illustrate this mechanism). + +```python +class Screen: + rfsh_start = Event() # Refresh pauses until set (set by default). + rfsh_done = Event() # Flag a user task that a refresh was done. + + @classmethod + async def auto_refresh(cls): + cls.rfsh_start.set() + while True: + await cls.rfsh_start.wait() + ssd.show() # Synchronous (blocking) refresh. + # Flag user code. + cls.rfsh_done.set() + await asyncio.sleep_ms(0) # Let user code respond to event +``` +By default the `rfsh_start` event is permanently set, allowing refresh to free +run. User code can clear this event to delay refresh. The `rfsh_done` event can +signal to user code that refresh is complete. As an example of simple usage, +the following, if awaited, pauses until a refresh is complete and prevents +another from occurring. +```python + async def refresh_and_stop(self): + Screen.rfsh_start.set() # Allow refresh + Screen.rfsh_done.clear() # Enable completion flag + await Screen.rfsh_done.wait() # Wait for a refresh to end + Screen.rfsh_start.clear() # Prevent another. +``` +The demo `gui/demos/audio.py` provides example usage. + ###### [Contents](./README.md#0-contents) # Appendix 1 Application design diff --git a/gui/core/ugui.py b/gui/core/ugui.py index e3f4846..a97dfd2 100644 --- a/gui/core/ugui.py +++ b/gui/core/ugui.py @@ -203,6 +203,10 @@ class Display: class Screen: current_screen = None is_shutdown = Event() + # These events enable user code to synchronise display refresh + # to a realtime process. + rfsh_start = Event() # Refresh pauses until set (set by default). + rfsh_done = Event() # Flag a user task that a refresh was done. @classmethod def ctrl_move(cls, _, v): @@ -290,22 +294,27 @@ class Screen: # If the display driver has an async refresh method, determine the split # value which must be a factor of the height. In the unlikely event of # no factor, do_refresh confers no benefit, so use synchronous code. - @staticmethod - async def auto_refresh(): - arfsh = hasattr(ssd, 'do_refresh') # Refresh can be asynchronous + @classmethod + async def auto_refresh(cls): + arfsh = hasattr(ssd, 'do_refresh') # Refresh can be asynchronous. + # By default rfsh_start is permanently set. User code can clear this. + cls.rfsh_start.set() if arfsh: h = ssd.height split = max(y for y in (1,2,3,5,7) if not h % y) if split == 1: arfsh = False while True: + await cls.rfsh_start.wait() Screen.show(False) # Update stale controls. No physical refresh. # Now perform physical refresh. if arfsh: await ssd.do_refresh(split) else: ssd.show() # Synchronous (blocking) refresh. - await asyncio.sleep_ms(0) + # Flag user code. + cls.rfsh_done.set() + await asyncio.sleep_ms(0) # Let user code respond to event @classmethod def back(cls): diff --git a/gui/demos/audio.py b/gui/demos/audio.py index 9f9d38f..73044cd 100644 --- a/gui/demos/audio.py +++ b/gui/demos/audio.py @@ -9,8 +9,11 @@ from machine import I2S from machine import Pin import pyb +# *************** + # Do allocations early -BUFSIZE = 1024*20 # 5.8ms/KiB +BUFSIZE = 1024*20 # 5.8ms/KiB 8KiB occasional dropouts +WAVSIZE = 1024*2 root = "/sd/music" # Location of directories containing albums @@ -20,7 +23,7 @@ pyb.Pin("EN_3V3").on() # provide 3.3V on 3V3 output pin I2S_ID = 1 # allocate sample array once -wav_samples = bytearray(BUFSIZE) +wav_samples = bytearray(WAVSIZE) # The proper way is to parse the WAV file as per # https://github.com/miketeachman/micropython-i2s-examples/blob/master/examples/wavplayer.py @@ -128,6 +131,7 @@ class BaseScreen(Screen): CloseButton(wri) # Quit the application # self.reg_task(asyncio.create_task(self.report())) + async def report(self): while True: gc.collect() @@ -184,13 +188,29 @@ class BaseScreen(Screen): self.show_song() #self.play_album() - def show_song(self): + def show_song(self): # 13ms song = self.songs[self.song_idx] ns = song.find(SelectScreen.album) ne = song[ns:].find('/') + 1 end = song[ns + ne:].find(".wav") self.lblsong.value(song[ns + ne: ns + ne + end]) + async def refresh_and_stop(self): + Screen.rfsh_start.set() # Allow refresh + Screen.rfsh_done.clear() + await Screen.rfsh_done.wait() # Wait for a refresh to end + Screen.rfsh_start.clear() # Prevent another. + + + async def refresh_ctrl(self): # Enter with refresh paused + await asyncio.sleep_ms(100) # Time for initial buffer fill + try: + while True: + await self.refresh_and_stop() # Allow one screen refresh + await asyncio.sleep_ms(20) # Time for buffer top-up + finally: # Allow refresh to free-run + Screen.rfsh_start.set() + async def album_task(self): self.playing = True # Prevent other instances self.stop_play = False @@ -198,7 +218,12 @@ class BaseScreen(Screen): songs = self.songs[self.song_idx :] # Start from current index for song in songs: self.show_song() + await self.refresh_and_stop() # Pause until refresh is stopped. + # Delay refresh to ensure buffer is filled + rc = asyncio.create_task(self.refresh_ctrl()) + await asyncio.sleep_ms(0) await self.play_song(song) + rc.cancel() # Restore normal display refresh if self.stop_play: break # A callback has stopped playback self.song_idx += 1 @@ -206,6 +231,7 @@ class BaseScreen(Screen): self.song_idx = 0 # Played to completion. self.show_song() self.playing = False + rc.cancel() # Restore normal display refresh # Open and play a binary wav file async def play_song(self, song): @@ -223,6 +249,7 @@ class BaseScreen(Screen): # HACK awaiting https://github.com/micropython/micropython/pull/7868 swriter.out_buf = wav_samples_mv[:num_read] await swriter.drain() + # wav_samples is now empty self.offset += size def test(): diff --git a/gui/demos/audio_monitored.py b/gui/demos/audio_monitored.py new file mode 100644 index 0000000..3dd1d9e --- /dev/null +++ b/gui/demos/audio_monitored.py @@ -0,0 +1,279 @@ +# audio.py + +# Released under the MIT License (MIT). See LICENSE. +# Copyright (c) 2021 Peter Hinch + +import hardware_setup # Create a display instance +from gui.core.ugui import Screen, ssd +from machine import I2S +from machine import Pin +import pyb + +# *************** + +# Do allocations early +BUFSIZE = 1024*20 # 5.8ms/KiB 8KiB occasional dropouts +WAVSIZE = 1024*2 + +root = "/sd/music" # Location of directories containing albums + +pyb.Pin("EN_3V3").on() # provide 3.3V on 3V3 output pin + +# ======= I2S CONFIGURATION ======= + +I2S_ID = 1 +# allocate sample array once +wav_samples = bytearray(WAVSIZE) + +# The proper way is to parse the WAV file as per +# https://github.com/miketeachman/micropython-i2s-examples/blob/master/examples/wavplayer.py +# Here for simplicity we assume stereo files ripped from CD's. +config = { + 'sck' : Pin('W29'), + 'ws' : Pin('W16'), + 'sd' : Pin('Y4'), + 'mode' : I2S.TX, + 'bits' : 16, # Sample size in bits/channel + 'format' : I2S.STEREO, + 'rate' : 44100, # Sample rate in Hz + 'ibuf' : BUFSIZE, # Buffer size + } + +audio_out = I2S(I2S_ID, **config) + +# *** MONITOR *** +from machine import UART +import monitor +# Define interface to use +monitor.set_device(UART(2, 1_000_000)) # UART must be 1MHz +trig2 = monitor.trigger(2) +# trig4 = monitor.trigger(4) + +# ======= GUI ======= + +from gui.widgets.label import Label +from gui.widgets.buttons import Button, CloseButton, CIRCLE +from gui.widgets.sliders import HorizSlider +from gui.widgets.listbox import Listbox +from gui.core.writer import CWriter + +# Font for CWriter +import gui.fonts.arial10 as arial10 +import gui.fonts.icons as icons +from gui.core.colors import * + +import os +import gc +import uasyncio as asyncio +import sys + +# Initial check on ilesystem +try: + subdirs = [x[0] for x in os.ilistdir(root) if x[1] == 0x4000] + if len(subdirs): + subdirs.sort() + else: + print("No albums found in ", root) + sys.exit(1) +except OSError: + print(f"Expected {root} directory not found.") + sys.exit(1) + +class SelectScreen(Screen): + songs = [] + album = "" + def __init__(self, wri): + super().__init__() + Listbox(wri, 2, 2, elements = subdirs, dlines = 8, width=100, callback = self.lbcb) + + def lbcb(self, lb): # sort + directory = ''.join((root, '/', lb.textvalue())) + songs = [x[0] for x in os.ilistdir(directory) if x[1] != 0x4000] + songs.sort() + SelectScreen.songs = [''.join((directory, '/', x)) for x in songs] + SelectScreen.album = lb.textvalue() + Screen.back() + + +class BaseScreen(Screen): + + def __init__(self): + self.swriter = asyncio.StreamWriter(audio_out) + + args = { + 'bdcolor' : RED, + 'slotcolor' : BLUE, + 'legends' : ('-48dB', '-24dB', '0dB'), + 'value' : 0.5, + 'height' : 15, + } + buttons = { + 'shape' : CIRCLE, + 'fgcolor' : GREEN, + } + super().__init__() + # Audio status + self.playing = False # Track is playing + self.stop_play = False # Command + self.paused = False + self.songs = [] # Paths to songs in album + self.song_idx = 0 # Current index into .songs + self.offset = 0 # Offset into file + self.volume = -3 + + wri = CWriter(ssd, arial10, GREEN, BLACK, False) + wri_icons = CWriter(ssd, icons, WHITE, BLACK, False) + Button(wri_icons, 2, 2, text='E', callback=self.new, args=(wri,), **buttons) # New + Button(wri_icons, row := 30, col := 2, text='D', callback=self.replay, **buttons) # Replay + Button(wri_icons, row, col := col + 25, text='F', callback=self.play_cb, **buttons) # Play + Button(wri_icons, row, col := col + 25, text='B', callback=self.pause, **buttons) # Pause + Button(wri_icons, row, col := col + 25, text='A', callback=self.stop, **buttons) # Stop + Button(wri_icons, row, col + 25, text='C', callback=self.skip, **buttons) # Skip + row = 60 + col = 2 + self.lbl = Label(wri, row, col, 120) + self.lblsong = Label(wri, self.lbl.mrow + 2, col, 120) + row = 110 + col = 14 + HorizSlider(wri, row, col, callback=self.slider_cb, **args) + CloseButton(wri) # Quit the application + # self.reg_task(asyncio.create_task(self.report())) + # *** MONITOR *** + monitor.init() + asyncio.create_task(monitor.hog_detect()) + + + async def report(self): + while True: + gc.collect() + print(gc.mem_free()) + await asyncio.sleep(20) + + def slider_cb(self, s): + self.volume = round(8 * (s.value() - 1)) + + def play_cb(self, _): + self.play_album() + + def pause(self, _): + self.stop_play = True + self.paused = True + self.show_song() + + def stop(self, _): # Abandon album + self.stop_play = True + self.paused = False + self.song_idx = 0 + self.show_song() + + def replay(self, _): + if self.stop_play: + self.song_idx = max(0, self.song_idx - 1) + else: + self.stop_play = True # Replay from start + self.paused = False + self.show_song() + #self.play_album() + + def skip(self, _): + self.stop_play = True + self.paused = False + self.song_idx = min(self.song_idx + 1, len(self.songs) -1) + self.show_song() + #self.play_album() + + def new(self, _, wri): + self.stop_play = True + self.paused = False + Screen.change(SelectScreen, args=[wri,]) + + def play_album(self): + if not self.playing: + self.reg_task(asyncio.create_task(self.album_task())) + + def after_open(self): + self.songs = SelectScreen.songs + self.lbl.value(SelectScreen.album) + if self.songs: + self.song_idx = 0 # Start on track 0 + self.show_song() + #self.play_album() + + @monitor.sync(5) + def show_song(self): # 13ms + song = self.songs[self.song_idx] + ns = song.find(SelectScreen.album) + ne = song[ns:].find('/') + 1 + end = song[ns + ne:].find(".wav") + self.lblsong.value(song[ns + ne: ns + ne + end]) + + @monitor.asyn(4) + async def refresh_and_stop(self): + Screen.rfsh_start.set() # Allow refresh + Screen.rfsh_done.clear() + await Screen.rfsh_done.wait() # Wait for a refresh to end + Screen.rfsh_start.clear() # Prevent another. + + + async def refresh_ctrl(self): # Enter with refresh paused + await asyncio.sleep_ms(100) # Time for initial buffer fill + try: + while True: + await self.refresh_and_stop() # Allow one screen refresh + await asyncio.sleep_ms(20) # Time for buffer top-up + finally: # Allow refresh to free-run + Screen.rfsh_start.set() + + async def album_task(self): + self.playing = True # Prevent other instances + self.stop_play = False + # Leave paused status unchanged + songs = self.songs[self.song_idx :] # Start from current index + for song in songs: + self.show_song() + await self.refresh_and_stop() # Pause until refresh is stopped. + # Delay refresh to ensure buffer is filled + rc = asyncio.create_task(self.refresh_ctrl()) + await asyncio.sleep_ms(0) + await self.play_song(song) + rc.cancel() # Restore normal display refresh + if self.stop_play: + break # A callback has stopped playback + self.song_idx += 1 + else: + self.song_idx = 0 # Played to completion. + self.show_song() + self.playing = False + rc.cancel() # Restore normal display refresh + + # Open and play a binary wav file + @monitor.asyn(1) + async def play_song(self, song): + wav_samples_mv = memoryview(wav_samples) + size = len(wav_samples) + if not self.paused: + # advance to first byte of Data section in WAV file. This is not + # correct for all WAV files. See link above. + self.offset = 44 + swriter = self.swriter + with open(song, "rb") as wav: + _ = wav.seek(self.offset) + while (num_read := wav.readinto(wav_samples_mv)) and not self.stop_play: + I2S.shift(buf=wav_samples_mv[:num_read], bits=16, shift=self.volume) + # HACK awaiting https://github.com/micropython/micropython/pull/7868 + swriter.out_buf = wav_samples_mv[:num_read] + trig2(False) + await swriter.drain() + # wav_samples is now empty + trig2(True) # Ready for data + self.offset += size + +def test(): + print('Audio demo.') + try: + Screen.change(BaseScreen) # A class is passed here, not an instance. + finally: + audio_out.deinit() + print("========== CLOSE AUDIO ==========") + +test()