Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found
Select Git revision
  • 89-apps-should-be-able-to-specify-if-they-want-wifi-to-be-disabled-when-entering-them
  • 9Rmain
  • allow-reloading-sunmenu
  • always-have-a-wifi-instance
  • anon/gpndemo
  • anon/update-sim
  • anon/webflasher
  • app_text_viewer
  • audio_input
  • audio_io
  • blm_dev_chan
  • ch3/bl00mbox_docs
  • ci-1690580595
  • dev_p4
  • dev_p4-iggy
  • dev_p4-iggy-rebased
  • dx/dldldld
  • dx/fb-save-restore
  • dx/hint-hint
  • dx/jacksense-headset-mic-only
  • events
  • fil3s-limit-filesize
  • fil3s-media
  • fpletz/flake
  • gr33nhouse-improvements
  • history-rewrite
  • icon-flower
  • iggy/stemming
  • iggy/stemming_merge
  • led_fix_fix
  • main
  • main+schneider
  • media_has_video_has_audio
  • micropython_api
  • mixer2
  • moon2_demo_temp
  • moon2_migrate_apps
  • more-accurate-battery
  • pippin/ctx_sprite_sheet_support
  • pippin/display-python-errors-on-display
  • pippin/make_empty_drawlists_skip_render_and_blit
  • pippin/more-accurate-battery
  • pippin/tcp_redirect_hack
  • pippin/tune_ctx_config_update_from_upstream
  • pippin/uhm_flash_access_bust
  • pressable_bugfix
  • py_only_update_fps_overlay_when_changing
  • q3k/doom-poc
  • q3k/render-to-texture
  • rahix/flow3rseeds
  • raw_captouch_new
  • raw_captouch_old
  • release/1.0.0
  • release/1.1.0
  • release/1.1.1
  • release/1.2.0
  • release/1.3.0
  • release/1.4.0
  • restore_blit
  • return_of_melodic_demo
  • rev4_micropython
  • schneider/application-remove-name
  • schneider/bhi581
  • schneider/factory_test
  • schneider/recovery
  • scope_hack
  • sdkconfig-spiram-tinyusb
  • sec/auto-nick
  • sec/blinky
  • sector_size_512
  • shoegaze-fps
  • smaller_gradient_lut
  • store_delta_ms_and_ins_as_class_members
  • task_cleanup
  • uctx-wip
  • w1f1-in-sim
  • widgets_draw
  • wifi-json-error-handling
  • wip-docs
  • wip-tinyusb
  • v1.0.0
  • v1.0.0+rc1
  • v1.0.0+rc2
  • v1.0.0+rc3
  • v1.0.0+rc4
  • v1.0.0+rc5
  • v1.0.0+rc6
  • v1.1.0
  • v1.1.0+rc1
  • v1.1.1
  • v1.2.0
  • v1.2.0+rc1
  • v1.3.0
  • v1.4.0
94 results

Target

Select target project
  • flow3r/flow3r-firmware
  • Vespasian/flow3r-firmware
  • alxndr42/flow3r-firmware
  • pl/flow3r-firmware
  • Kari/flow3r-firmware
  • raimue/flow3r-firmware
  • grandchild/flow3r-firmware
  • mu5tach3/flow3r-firmware
  • Nervengift/flow3r-firmware
  • arachnist/flow3r-firmware
  • TheNewCivilian/flow3r-firmware
  • alibi/flow3r-firmware
  • manuel_v/flow3r-firmware
  • xeniter/flow3r-firmware
  • maxbachmann/flow3r-firmware
  • yGifoom/flow3r-firmware
  • istobic/flow3r-firmware
  • EiNSTeiN_/flow3r-firmware
  • gnudalf/flow3r-firmware
  • 999eagle/flow3r-firmware
  • toerb/flow3r-firmware
  • pandark/flow3r-firmware
  • teal/flow3r-firmware
  • x42/flow3r-firmware
  • alufers/flow3r-firmware
  • dos/flow3r-firmware
  • yrlf/flow3r-firmware
  • LuKaRo/flow3r-firmware
  • ThomasElRubio/flow3r-firmware
  • ai/flow3r-firmware
  • T_X/flow3r-firmware
  • highTower/flow3r-firmware
  • beanieboi/flow3r-firmware
  • Woazboat/flow3r-firmware
  • gooniesbro/flow3r-firmware
  • marvino/flow3r-firmware
  • kressnerd/flow3r-firmware
  • quazgar/flow3r-firmware
  • aoid/flow3r-firmware
  • jkj/flow3r-firmware
  • naomi/flow3r-firmware
41 results
Select Git revision
  • 9Rmain
  • anon/gpndemo
  • anon/update-sim
  • anon/webflasher
  • audio_input
  • audio_io
  • bl00mbox
  • bl00mbox_old
  • captouch-threshold
  • ch3/bl00mbox_docs
  • ci-1690580595
  • compressor
  • dev_p4
  • dev_p4-iggy
  • dev_p4-iggy-rebased
  • dos
  • dos-main-patch-50543
  • events
  • fm_fix
  • fm_fix2
  • fpletz/flake
  • history-rewrite
  • icon-flower
  • iggy/stemming
  • iggy/stemming_merge
  • json-error
  • main
  • main+schneider
  • media-buf
  • micropython_api
  • moon2_applications
  • moon2_demo_temp
  • moon2_gay_drums
  • passthrough
  • phhw
  • pippin/display-python-errors-on-display
  • pippin/make_empty_drawlists_skip_render_and_blit
  • pippin/media_framework
  • pippin/uhm_flash_access_bust
  • pressable_bugfix
  • q3k/doom-poc
  • rahix/big-flow3r
  • rahix/flow3rseeds
  • raw_captouch_new
  • raw_captouch_old
  • release/1.0.0
  • release/1.1.0
  • release/1.1.1
  • rev4_micropython
  • schneider/application-remove-name
  • schneider/bhi581
  • schneider/factory_test
  • schneider/recovery
  • scope
  • scope_hack
  • sdkconfig-spiram-tinyusb
  • sec/auto-nick
  • sec/blinky
  • simtest
  • slewtest
  • t
  • test
  • test2
  • uctx-wip
  • view-think
  • vm-pending
  • vsync
  • wave
  • wip-docs
  • wip-tinyusb
  • v1.0.0
  • v1.0.0+rc1
  • v1.0.0+rc2
  • v1.0.0+rc3
  • v1.0.0+rc4
  • v1.0.0+rc5
  • v1.0.0+rc6
  • v1.1.0
  • v1.1.0+rc1
  • v1.1.1
  • v1.2.0
  • v1.2.0+rc1
  • v1.3.0
83 results
Show changes
Showing
with 3609 additions and 216 deletions
from st3m.application import Application
import sys_display, math, random
from ctx import Context
class App(Application):
def __init__(self, app_ctx):
super().__init__(app_ctx)
def on_enter(self, vm):
super().on_enter(vm)
self.y = 0
self.xa = -1.5
self.xb = 1.5
self.ya = -2.0
self.yb = 1.0
def on_enter_done(self):
sys_display.set_mode(sys_display.cool | sys_display.x2)
def draw(self, ctx: Context):
if self.vm.transitioning:
ctx.gray(0).rectangle(-120, -120, 240, 240).fill()
return
fb_info = sys_display.fb(sys_display.cool)
fb = fb_info[0]
max_iterations = 30
width = fb_info[1]
height = fb_info[2]
stride = fb_info[3]
for chunk in range(3): # do 3 scanlines at a time
y = self.y
if y < height:
zy = y * (self.yb - self.ya) / (height - 1) + self.ya
inners = 0
for x in range(width // 2):
zx = x * (self.xb - self.xa) / (width - 1) + self.xa
z = zy + zx * 1j
c = z
reached = 0
if inners > 10 and y < height * 0.7:
reached = max_iterations - 1
else:
for i in range(max_iterations):
if abs(z) > 2.0:
break
z = z * z + c
reached = i
if reached == max_iterations - 1:
inners += 1
val = reached * 255 / max_iterations
val = math.sqrt(val / 255) * 255
fb[y * stride + x] = int(val)
fb[y * stride + width - 1 - x] = int(val)
self.y += 1
def think(self, ins, delta_ms):
super().think(ins, delta_ms)
if self.input.buttons.app.right.pressed:
pal = bytearray(256 * 3)
modr = random.getrandbits(7) + 1
modg = random.getrandbits(7) + 1
modb = random.getrandbits(7) + 1
for i in range(256):
pal[i * 3] = int((i % modr) * (255 / modr))
pal[i * 3 + 1] = int((i % modg) * (255 / modg))
pal[i * 3 + 2] = int((i % modb) * (255 / modb))
sys_display.set_palette(pal)
if self.input.buttons.app.left.pressed:
pal = bytearray(256 * 3)
for i in range(256):
pal[i * 3] = i
pal[i * 3 + 1] = i
pal[i * 3 + 2] = i
sys_display.set_palette(pal)
if __name__ == "__main__":
import st3m.run
st3m.run.run_app(App)
[app]
name = "Mandelbrot"
category = "Apps"
[metadata]
author = "Flow3r Badge Authors"
license = "LGPL-3.0-only"
url = "https://git.flow3r.garden/flow3r/flow3r-firmware"
from st3m.application import Application
from st3m import utils
import json
import media
import os, uos
import math, cmath, random
import leds
from st3m.ui import led_patterns
COLOR_BG = (0, 0, 0)
COLOR_FG = (0, 1, 1)
COLOR_CURSOR = (1, 1, 0)
COLOR_PLAYING = (1, 0, 1)
PETAL_A = 1
PETAL_B = 3
PETAL_C = 5
PETAL_D = 7
PETAL_E = 9
PLAYLIST_MAX_LEN = 1000
# taken from https://github.com/python/cpython/blob/main/Lib/bisect.py
# Copyright (c) 2001-2024 Python Software Foundation; All Rights Reserved
# (modified)
def insort(a, x, lo=0, hi=None, *, key=None, unique=False):
if key is None:
lo = bisect(a, x, lo, hi)
else:
lo = bisect(a, key(x), lo, hi, key=key)
if not unique or not lo or a[lo - 1] != x:
a.insert(lo, x)
return lo
return None
# taken from https://github.com/python/cpython/blob/main/Lib/bisect.py
# Copyright (c) 2001-2024 Python Software Foundation; All Rights Reserved
def bisect(a, x, lo=0, hi=None, *, key=None):
if lo < 0:
raise ValueError("lo must be non-negative")
if hi is None:
hi = len(a)
if key is None:
while lo < hi:
mid = (lo + hi) // 2
if x < a[mid]:
hi = mid
else:
lo = mid + 1
else:
while lo < hi:
mid = (lo + hi) // 2
if x < key(a[mid]):
hi = mid
else:
lo = mid + 1
return lo
class Marquee:
def __init__(self):
self.speed = 0.015
self.gap = 40
self.x = 0
self.ypos = None
self.set_width(0)
self.text = None
self.set_text("")
self._needs_redraw = True
def set_text(self, text):
if self.text == text:
return
self.text = text
self.text_width = None
self.x = 0
self.full_redraw = True
def set_width(self, ypos):
ypos = int(ypos)
if self.ypos == ypos:
return
radius = 10
if ypos < -90 + 14 + radius:
self.frame_width = 65 - radius
elif ypos > 34 - radius and ypos < 34 + 14 + radius:
self.frame_width = 106 - radius
else:
y = max(ypos + 12, -ypos - 2) / 120
self.frame_width = 120 * math.sqrt(1 - y * y)
self.frame_width = max(0, self.frame_width - 5)
self.ypos = ypos
self.full_redraw = True
def think(self, delta_ms):
if self.text_width is None:
return
if self.text_width / 2 < self.frame_width:
return
self.x += delta_ms * self.speed
self.x %= self.text_width + self.gap
self.full_redraw = True
def draw(self, ctx):
if self.text_width is None:
self.text_width = ctx.text_width(self.text)
if not self.full_redraw:
return
ctx.move_to(0, 0)
ctx.save()
ctx.rgb(*COLOR_BG)
ctx.rectangle(-120, -12, 240, 14).fill()
ctx.restore()
ctx.save()
if self.text_width / 2 < self.frame_width:
ctx.text_align = ctx.CENTER
ctx.text(self.text)
else:
ctx.text_align = ctx.LEFT
ctx.rel_move_to(-self.x - self.frame_width, 0)
ctx.text(self.text)
ctx.rel_move_to(self.gap, 0)
ctx.text(self.text)
ctx.restore()
self.full_redraw = False
def minutify(seconds):
try:
seconds = int(seconds)
except ValueError:
return ""
minutes = seconds // 60
seconds = seconds % 60
return f"{minutes:02.}:{seconds:02.}"
def slugify(path):
# strip directory
if path.startswith("/sd/music/"):
path = path[len("/sd/music/") :]
# take care of non-ascii characters, bad hack
path = repr(path)[1:-1]
return path
class ListPageItem:
def __init__(self, slug):
self.ypos = None
self.highlight = None
self.already_drawn = 0
self.slug = slug
def undraw(self, ctx, ypos, highlight):
if ypos is not None:
ypos = int(ypos)
if ypos < -95 or ypos > 67 + ctx.font_size:
ypos = None
if highlight == self.highlight and ypos == self.ypos:
self.already_drawn += 1
self.already_drawn = min(self.already_drawn, 3)
else:
self.already_drawn = 0
if self.already_drawn > 1:
return False
if self.ypos is not None:
ctx.rgb(*COLOR_BG)
ctx.rectangle(-120, self.ypos - ctx.font_size, 240, ctx.font_size).fill()
self.ypos = None
if ypos is None:
return False
self.area = 0
ctx.rgb(*COLOR_BG)
ctx.rectangle(-120, ypos - ctx.font_size, 240, ctx.font_size).fill()
self.highlight = highlight
self.ypos = ypos
return True
def draw(self, ctx, draw_slug=True):
if self.highlight > 1:
return
ctx.move_to(0, self.ypos - 2) # idk why the offset
if self.highlight == 0:
ctx.rgb(*COLOR_FG)
elif self.highlight == 1:
ctx.rgb(*COLOR_PLAYING)
else:
ctx.rgb(*COLOR_CURSOR)
if draw_slug:
self.draw_slug(ctx)
def draw_slug(self, ctx):
ctx.text(self.slug)
class Artist(ListPageItem):
def __init__(self, artist):
slug = artist if artist else "<unknown artist>"
super().__init__(slug)
self.artist = artist
def __eq__(self, other):
if isinstance(other, Artist):
return self.artist == other.artist
return False
class Song(ListPageItem):
def __init__(self, path, slug=None):
super().__init__("dummy")
if slug is not None:
self.slug = slug
self.path = path
return
path = path.strip("\n")
self.path = utils.simplify_path(path)
self.tags = media.get_tags(self.path)
if self.tags is None:
self.tags = {}
self.title = self.tags.get("title")
self.artist = self.tags.get("artist")
self.album = self.tags.get("album")
self.track_number = self.tags.get("track_number", 10000)
self.year = self.tags.get("year", 10000)
if self.title:
if self.artist:
self.slug = self.artist + " - " + self.title
else:
self.slug = self.title
self.slug = repr(self.slug)[1:-1]
else:
if path.startswith("/sd/music/"):
path = path[len("/sd/music/") :]
self.slug = repr(path)[1:-1]
def __eq__(self, other):
if isinstance(other, Song):
return self.path == other.path
return False
def copy(self):
ret = Song(self.path, self.slug)
ret.artist = self.artist
ret.title = self.title
return ret
class ListPage:
name = "dummy"
def __init__(self, app, itemlist):
self.ypos = -90
self.highlight = 0
self.highlight_alt = None
self.highlight_is_playing = False
self.app = app
self.input = app.input
self.itemlist = itemlist
self.undraws = []
self.drawn_items = []
self.step_size = 0
self.alt_scroll = False
self.label_state = [0] * 10
self.highlight_ypos_limits = [-90 + 14, 67]
self.marquee = Marquee()
@property
def highlighted_item(self):
if len(self.itemlist) > self.highlight:
return self.itemlist[self.highlight]
return None
def draw(self, ctx):
incr = min(self.app.draw_ms, 50) / 250
for x in [PETAL_B, PETAL_C, PETAL_D]: # add more if needed
if self.label_state[x] is not None and self.label_state[x] < 0:
self.label_state[x] = min(self.label_state[x] + incr, 0)
self.step_size = ctx.font_size
if self.app.full_redraw:
for song in self.itemlist:
song.already_drawn = 0
highlight_ypos = self.highlight * self.step_size + int(self.ypos)
if self.highlight_ypos_limits[0] > highlight_ypos:
self.ypos += self.highlight_ypos_limits[0] - highlight_ypos
elif highlight_ypos > self.highlight_ypos_limits[1]:
self.ypos -= highlight_ypos - self.highlight_ypos_limits[1]
highlight_range = self.highlight_ypos_limits[1] - self.highlight_ypos_limits[0]
highlight_bias = (
self.highlight_ypos_limits[1] + self.highlight_ypos_limits[0]
) / 2
max_items = highlight_range / self.step_size
len_itemlist = len(self.itemlist)
if len_itemlist > max_items:
highlight_range /= 2
highlight_range -= self.step_size # * 1
highlight_ypos -= highlight_bias
off_by = max(abs(highlight_ypos) - highlight_range + self.step_size, 0)
off_by = off_by if highlight_ypos > 0 else -off_by
if off_by:
self.ypos -= highlight_ypos * min(self.app.draw_ms / 300, 1) * 0.2
min_ypos = -self.ypos - highlight_range
max_ypos = (
-self.ypos + highlight_range - (len_itemlist - 1) * self.step_size
)
if min_ypos < 0:
self.ypos += min_ypos
elif max_ypos > 0:
self.ypos += max_ypos
else:
self.ypos = -0.5 * (len_itemlist - 1) * self.step_size
ypos = int(self.ypos)
ctx.text_align = ctx.CENTER
index_min = (self.highlight_ypos_limits[0] - self.ypos) / self.step_size
index_max = (self.highlight_ypos_limits[1] - self.ypos) / self.step_size
index_min = max(0, int(index_min) - 1)
index_max = min(len_itemlist, int(index_max) + 4)
candidates = self.itemlist[index_min:index_max]
self.undraws += [item for item in self.drawn_items if item not in candidates]
while self.undraws:
self.undraws.pop().undraw(ctx, None, 0)
drawables = []
self.drawn_items = []
highlight_on_screen = False
for index_offset, item in enumerate(candidates):
index = index_offset + index_min
ypos = int(self.ypos) + self.step_size * index
highlight = 2 * (index == self.highlight) + (index == self.highlight_alt)
needs_fresh_draw = item.undraw(ctx, ypos, highlight)
if item.ypos is not None: # hacky: if the thing is on screen,
self.drawn_items.append(item)
if needs_fresh_draw:
drawables.append(item)
if highlight > 1:
if needs_fresh_draw:
self.marquee.full_redraw = True
highlight_on_screen = True
self.marquee.set_text(item.slug)
self.marquee.set_width(ypos)
self.marquee.think(self.app.draw_ms)
for item in drawables:
item.draw(ctx)
if highlight_on_screen:
ctx.translate(0, self.marquee.ypos - 2)
ctx.rgb(*COLOR_CURSOR)
self.marquee.draw(ctx)
def scroll(self, cw_dir, overflow):
if not self.itemlist:
return
self.highlight += cw_dir
if overflow:
self.highlight %= len(self.itemlist)
else:
self.highlight = max(0, min(len(self.itemlist) - 1, self.highlight))
def think(self, ins, delta_ms):
if self.app.active_songlist == self.itemlist:
self.highlight_alt = self.app.active_songlist_index
self.highlight_is_playing = self.highlight_alt == self.highlight
else:
self.highlight_alt = None
self.highlight_is_playing = False
def draw_label(self, ctx, petal):
if petal == PETAL_D:
ctx.move_to(-3, -3)
ctx.rel_line_to(6, 0).stroke()
ctx.move_to(-3, 0)
ctx.rel_line_to(6, 0).stroke()
ctx.move_to(-3, 3)
ctx.rel_line_to(6, 0).stroke()
def insort(self, item, key=None, unique=True):
pos = insort(self.itemlist, item, key=key, unique=unique)
if pos is not None and pos <= self.highlight:
self.highlight = min(self.highlight + 1, len(self.itemlist) - 1)
self.ypos -= self.step_size
return pos
class NotPlaylistPage(ListPage):
def draw_label(self, ctx, petal):
if petal == PETAL_B:
ctx.move_to(0, -4)
ctx.rel_line_to(0, 8).stroke()
ctx.move_to(-4, 0)
ctx.rel_line_to(8, 0).stroke()
ctx.stroke()
elif petal == PETAL_A:
ctx.arc(0, -1, 2, 0, math.tau, 0).stroke()
ctx.arc(0, 4, 3, -math.tau / 2, 0, 0).stroke()
else:
super().draw_label(ctx, petal)
class ArtistsPage(NotPlaylistPage):
name = "artists"
def think(self, ins, delta_ms):
super().think(ins, delta_ms)
self.label_state[PETAL_A] = 1
if self.app.captouch_locked:
return
if self.input.captouch.petals[PETAL_B].whole.pressed:
artist = self.highlighted_item.artist if self.highlighted_item else None
if artist is not None: # we don't accept unknown artist here bc spam risk
artist_found = False
items_added = False
items_skipped = False
for song in self.app.mp3_files:
if song.artist == artist:
artist_found = True
if len(self.app.playlist) <= PLAYLIST_MAX_LEN:
self.app.playlist.append(song.copy())
items_added = True
else:
items_skipped = True
elif artist_found:
break # list should be sorted so that should be all
if items_added:
self.app.save_request = True
self.label_state[PETAL_B] = None
if items_skipped:
# TODO: send toast once they exist
pass
class MediaPage(NotPlaylistPage):
name = "media"
def think(self, ins, delta_ms):
super().think(ins, delta_ms)
if self.app.captouch_locked:
return
if (
self.input.captouch.petals[PETAL_B].whole.pressed
and self.highlighted_item is not None
):
if len(self.app.playlist) <= PLAYLIST_MAX_LEN:
self.app.playlist.append(self.highlighted_item.copy())
self.app.save_request = True
self.label_state[PETAL_B] = None
else:
# TODO: send toast once they exist
pass
class PlaylistPage(ListPage):
name = "playlist"
def think(self, ins, delta_ms):
super().think(ins, delta_ms)
if self.app.captouch_locked:
self.alt_scroll = False
self.label_state[PETAL_A] = 0
return
if self.input.captouch.petals[PETAL_B].whole.pressed and self.itemlist:
self.undraws += [self.itemlist.pop(self.highlight)]
self.highlight = min(len(self.itemlist) - 1, self.highlight)
if self.app.active_songlist is self.itemlist:
if not self.itemlist:
self.app.stop_song()
elif self.highlight_alt > self.highlight:
self.app.active_songlist_index -= 1
elif self.highlight_alt == self.highlight:
index = min(len(self.itemlist) - 1, self.app.active_songlist_index)
self.app.play_song(self.itemlist, index)
self.app.save_request = True
self.label_state[PETAL_B] = None
self.alt_scroll = ins.captouch.petals[PETAL_A].pressed
self.label_state[PETAL_A] = int(self.alt_scroll)
def move_highlighted_item(self, cw_dir, overflow):
if not self.itemlist:
return
if overflow:
highlight = (self.highlight + cw_dir) % len(self.itemlist)
else:
highlight = min(len(self.itemlist) - 1, max(0, self.highlight + cw_dir))
if highlight == self.highlight:
return
song = self.itemlist.pop(self.highlight)
self.highlight = highlight
self.itemlist.insert(self.highlight, song)
self.app.save_request = True
def draw_label(self, ctx, petal):
if petal == PETAL_B:
ctx.move_to(-4, 0)
ctx.rel_line_to(8, 0).stroke()
ctx.stroke()
elif petal == PETAL_A:
ctx.move_to(-3, -1)
ctx.rel_line_to(3, -3)
ctx.rel_line_to(3, 3).stroke()
ctx.move_to(3, 1)
ctx.rel_line_to(-3, 3)
ctx.rel_line_to(-3, -3).stroke()
elif petal == PETAL_D:
ctx.arc(-0.5, 1, 2, 0, math.tau, 0).stroke()
ctx.move_to(1.5, 1).rel_line_to(0, -6).stroke()
else:
super().draw_label(ctx, petal)
def scroll(self, cw_dir, overflow):
if self.alt_scroll:
self.move_highlighted_item(cw_dir, overflow)
return
super().scroll(cw_dir, overflow)
class ScrollThingy:
def __init__(self, petal_index, inverted=False):
self.petal_index = petal_index
self.inverted = inverted
self.angle = None
self.angle_ref_count = 5
self.angle_ref = 0
self.events = 0
self.raw_delay = [None] * 2
self.raw_delay_index = 0
def get_raw_angle(self, ins):
if ins.captouch.petals[self.petal_index].pressed:
_, angle = ins.captouch.petals[self.petal_index].position
angle /= 40000
angle *= abs(angle)
angle = min(1, max(-1, angle))
if self.inverted:
return -angle
else:
return angle
else:
return None
def think(self, ins, delta_ms):
raw_angle = self.get_raw_angle(ins)
if raw_angle is not None and raw_angle == self.raw_delay[self.raw_delay_index]:
return
self.raw_delay_index += 1
self.raw_delay_index %= len(self.raw_delay)
rdi = self.raw_delay_index
self.raw_delay[rdi] = raw_angle
rdi += 1
rdi %= len(self.raw_delay)
angle = self.raw_delay[rdi]
if self.raw_delay[self.raw_delay_index] is None or angle is None:
if self.angle is not None:
self.angle = None
self.angle_ref = 0
self.angle_ref_count = 3
return
if self.angle_ref_count:
self.angle_ref += angle / 3
self.angle_ref_count -= 1
return
if self.angle is None:
self.angle = self.angle_ref
self.angle += 0.37 * (angle - self.angle)
delta = self.angle - self.angle_ref
if abs(delta) > 0.2:
self.events += 1 if delta > 0 else -1
self.angle_ref = self.angle
class App(Application):
def get_help(self):
ret = (
"This is an mp3 player. It scans your SD card for files in /sd/music/ "
"that end in '.mp3' on startup.\n\n"
"Move your fingers on petal 8 and 2 up and down to scroll through the "
"current list. The other top petals can be tapped for more discrete "
"scrolling. \n\n"
"There's two major pages, songs and playlist. You can toggle between "
"them with petal {d}. Play back the currently highlighted song with "
"petal {c}. Use the shoulder button to pause and go forward/backward in "
"the playlist. \n\n"
"On the song page, petal {b} adds the selected song to the playlist. "
"You can also hold petal {a} to scroll through artists.\n\n"
"On the playlist page, petal {b} removes the selected song from the "
"playlist. You can hold petal {a} to move the selected song up and down.\n\n"
"Holding petal {e} puts some petals in alternative modes: Tapping "
"petal {a} scrolls through LED modes, petal {d} though repeat modes, and petal {d} "
"tries to scroll to the currently playing song in the active page.\n\n"
"You can lock/unlock captouch by holding down the app button.\n\n"
"The playlist autosaves but waits for a 7 second period of no changes "
"to keep writes low while doing many successive edits. "
"When a save is waiting to be performed a tiny save icon is displayed. "
"This icon also shows while the SD is scanned for files."
)
ret = ret.format(a=PETAL_A, b=PETAL_B, c=PETAL_C, d=PETAL_D, e=PETAL_E)
return ret
def __init__(self, app_ctx):
super().__init__(app_ctx)
self.full_redraw = True
self.draw_ms = 0
self.enter_done = False
self.dirpath = "/sd/app_data/mp3_player"
self.playlist_file = "playlist.m3u"
self.settings_file = "settings.json"
self.mp3_files = []
self.artists = []
self.playlist = []
self.media_page = MediaPage(self, self.mp3_files)
self.artists_page = ArtistsPage(self, self.artists)
self.playlist_page = PlaylistPage(self, self.playlist)
self.current_page = self.media_page
self.scan_generator = (
self.scandir("/sd/music") if utils.sd_card_plugged() else None
)
self.circer_rot = None
self.invert_scroll_dir = False
self.scroll_thingies = [
ScrollThingy(2, self.invert_scroll_dir),
ScrollThingy(8, not self.invert_scroll_dir),
]
self.save_request = False
self.save_timer = 0
self.playing = False
self.active_song = None
self.active_songlist = None
self.active_songlist_index = None
self.song_reset_cooldown = 0
self.app_button_release_ignore = False
self.input.buttons.app.middle.repeat_enable(800, 300)
self.captouch_locked = False
self.label_state = [0] * 10
self.playback_mode = 2
self.orig_led_brightness = None
self.orig_slew_rate = None
self.led_mode = 1
self.led_pattern_request = False
self.marquee = Marquee()
self.marquee.set_width(85)
self.time_ok = False
self.last_time_ms = -1
self.last_led_mode = -1
self.last_playback_mode = -1
self.last_sd_access = False
def play_song(self, songlist, song_index):
if song_index >= len(songlist) or song_index < 0:
return
if songlist is self.artists:
artist = songlist[song_index].artist
songlist = self.mp3_files
song_index = -1
for x, song in enumerate(songlist):
if song.artist == artist:
song_index = x
break
song = songlist[song_index]
media.load(song.path)
self.playing = True
self.time_ok = True
self.active_songlist = songlist
self.active_songlist_index = song_index
self.active_song = song
self.led_pattern_request = True
self.full_redraw = True
def stop_song(self):
media.stop()
self.active_songlist_index = None
self.active_songlist = None
self.active_song = None
self.playing = False
def scandir(self, dirname):
if os.path.isdir(dirname):
for entry in uos.ilistdir(dirname):
if entry[1] == 0x8000:
if entry[0].endswith(".mp3"):
song = Song(dirname + "/" + entry[0])
yield song
elif entry[1] == 0x4000:
inner_gen = self.scandir(dirname + "/" + entry[0])
for ret in inner_gen:
yield ret
@property
def playlist_path(self):
return self.dirpath + "/" + self.playlist_file
@property
def settings_path(self):
return self.dirpath + "/" + self.settings_file
def save_data(self):
if not os.path.exists(self.dirpath):
utils.mkdir_recursive(self.dirpath)
body = "\n".join([song.path for song in self.playlist])
if utils.save_file_if_changed(self.playlist_path, body):
print(f"saved playlist to {self.playlist_path}")
with open(self.settings_path, "w") as f:
f.write(
json.dumps(
{"playback mode": self.playback_mode, "led mode": self.led_mode}
)
)
self.save_request = False
def load_data(self):
if os.path.exists(self.playlist_path):
with open(self.playlist_path, "r") as f:
print(f"loading playlist from {self.playlist_path}")
self.playlist.clear()
for line in f:
if os.path.exists(line):
self.playlist.append(Song(line))
else:
print(f"playlist: couldn't find song {line}")
if os.path.exists(self.settings_path):
with open(self.settings_path, "r") as f:
data = f.read()
try:
settings = json.loads(data)
self.playback_mode = int(settings["playback mode"])
self.led_mode = int(settings["led mode"])
except (json.JSONDecodeError, KeyError, ValueError):
pass
def draw(self, ctx):
if not self.enter_done or not self.mp3_files:
self.full_redraw = True
incr = min(self.draw_ms, 50) / 250
for x in [PETAL_D, PETAL_C, PETAL_B, PETAL_A]: # add more if needed
if self.label_state[x] is not None and self.label_state[x] < 0:
self.label_state[x] = min(self.label_state[x] + incr, 0)
if self.full_redraw:
ctx.rgb(*COLOR_BG)
ctx.rectangle(-120, -120, 240, 240).fill()
ctx.rgb(*COLOR_FG)
ctx.font_size = 14
ctx.text_baseline = ctx.BOTTOM
ctx.text_align = ctx.CENTER
ctx.line_width = 1
ctx.save()
ctx.rectangle(-120, -90, 240, 67 + 90).clip()
if not self.mp3_files:
ctx.move_to(0, 0)
ctx.text("in /sd/music")
if self.scan_generator:
ctx.text("...")
ctx.move_to(0, -14)
ctx.text("scanning for .mp3 files")
else:
ctx.text(" :/")
ctx.move_to(0, -14)
ctx.text("no .mp3 files found")
else:
self.current_page.draw(ctx)
ctx.restore()
# order shouldn't matter as they don't overlap.
self.draw_header(ctx)
self.draw_footer(ctx)
# must be last
self.draw_circer(ctx)
if self.captouch_locked:
ctx.rgb(*COLOR_BG)
ctx.rectangle(-65, -35, 130, 70).fill()
ctx.rgb(*COLOR_FG)
ctx.rectangle(-60, -30, 120, 60).stroke()
ypos = -13
ctx.move_to(0, ypos)
ctx.text("captouch locked")
ypos += 20
ctx.move_to(0, ypos)
ctx.text("hold app button")
ypos += 14
ctx.move_to(0, ypos)
ctx.text("down to unlock")
self.full_redraw = False
self.draw_ms = 0
def draw_header(self, ctx):
if not self.full_redraw:
return
ctx.rgb(*COLOR_BG)
ctx.rectangle(-120, -120, 240, 30).fill()
ctx.rgb(*COLOR_FG)
ctx.rectangle(-120, -95, 240, 1).fill()
ctx.move_to(0, -100)
ctx.text(self.current_page.name)
def draw_footer(self, ctx):
if self.full_redraw:
ctx.rgb(*COLOR_BG)
ctx.rectangle(-120, 67, 240, 65).fill()
ctx.rgb(*COLOR_FG)
ctx.rectangle(-120, 72, 240, 1).fill()
# song name
if self.full_redraw:
self.marquee.full_redraw = True
if self.active_song is not None:
self.marquee.set_text(self.active_song.slug)
else:
self.marquee.set_text("")
self.marquee.think(self.draw_ms)
ctx.save()
ctx.translate(0, 87)
ctx.rgb(*COLOR_FG)
self.marquee.draw(ctx)
ctx.restore()
# progress bar
ctx.rectangle(-60 - 1, 92 - 1, 120 + 2, 5 + 2).fill()
media_dur = media.get_duration()
if media_dur:
progress = media.get_position() / media_dur
progress = min(1, max(0, progress))
else:
progress = 0
ctx.rgb(*COLOR_BG)
barlen = 120 * progress
ctx.rectangle(-60 + barlen, 92, 120 - barlen, 5).fill()
# time indicator
if self.time_ok:
time_ms = media.get_time()
else:
time_ms = None
if time_ms != self.last_time_ms:
ctx.rgb(*COLOR_BG)
ctx.rectangle(15, 101, 100, 14).fill()
if time_ms:
ctx.save()
ctx.rgb(*COLOR_FG)
ctx.text_align = ctx.LEFT
ctx.move_to(15, 113)
ctx.text(minutify(media.get_time()))
ctx.line_width = 1
ctx.restore()
self.last_time_ms = time_ms
# icons
sd_access = bool(self.save_request or self.scan_generator)
if sd_access != self.last_sd_access or self.full_redraw:
self.last_sd_access = sd_access
ctx.save()
ctx.translate(-12, 107)
ctx.rgb(*COLOR_BG)
ctx.rectangle(-10, -7, 12, 16).fill()
if self.last_sd_access:
ctx.rgb(*COLOR_FG)
self.draw_symbol(ctx, -1)
ctx.restore()
if self.playback_mode != self.last_playback_mode or self.full_redraw:
ctx.save()
ctx.translate(-31, 107)
ctx.rgb(*COLOR_BG)
ctx.rectangle(-7, -7, 16, 16).fill()
ctx.rgb(*COLOR_FG)
self.draw_symbol(ctx, self.playback_mode)
ctx.restore()
self.last_playback_mode = self.playback_mode
if self.led_mode != self.last_led_mode or self.full_redraw:
ctx.save()
ctx.translate(-44, 107)
ctx.rgb(*COLOR_BG)
ctx.rectangle(-7, -7, 12, 16).fill()
ctx.rgb(*COLOR_FG)
self.draw_symbol(ctx, self.led_mode + 4)
ctx.restore()
self.last_led_mode = self.led_mode
def draw_circer(self, ctx):
if self.circer_rot is None:
self.circer_rot = [
112 * cmath.exp(1j * math.tau * (x / 10 - 1 / 4)) for x in range(10)
]
for petal in range(1, 10, 2):
ctx.save()
rot = self.circer_rot[petal]
if petal == 5:
rot -= 2j
ctx.translate(rot.real, rot.imag)
ctx.rgb(*COLOR_BG)
ctx.arc(0, 0, 10, 0, math.tau, -1).fill()
col_fg = COLOR_FG
col_bg = COLOR_BG
if self.label_state[PETAL_E] or petal in [PETAL_C, PETAL_E]:
page = self
else:
page = self.current_page
if page.label_state[petal] is None:
page.label_state[petal] = -1
state = page.label_state[petal]
if state:
if state > 0:
col_bg = [x * state for x in col_fg]
col_fg = [x * (1 - state) for x in col_fg]
else:
col_bg = [x * -state for x in col_fg]
ctx.rgb(*col_bg)
ctx.arc(0, 0, 7, 0, math.tau, -1).fill()
ctx.rgb(*COLOR_FG)
ctx.arc(0, 0, 7, 0, math.tau, -1).stroke()
ctx.rgb(*col_fg)
page.draw_label(ctx, petal)
ctx.restore()
def draw_symbol(self, ctx, symbol):
# -1: save symbol
if symbol == -1:
ctx.move_to(0, 4)
ctx.rel_line_to(0, -8)
ctx.rel_line_to(-5, 0)
ctx.rel_line_to(-3, 3)
ctx.rel_line_to(0, 5)
ctx.rel_line_to(8, 0)
ctx.stroke()
ctx.move_to(-1, 4)
ctx.rel_line_to(0, -4)
ctx.rel_line_to(-3, 0)
ctx.rel_line_to(0, 4)
ctx.rel_line_to(3, 0)
ctx.stroke()
# 0: start of playback symbols: none
# 1: repeat single
# 2: repeat
if symbol == 1 or symbol == 2:
ctx.save()
if symbol == 2:
ctx.scale(1.25, 1.25)
ctx.translate(1, 1)
ctx.move_to(1.25, 0.5)
ctx.rel_line_to(-2, 2)
ctx.rel_line_to(2, 2)
ctx.stroke()
ctx.move_to(2.75, 2.5)
ctx.rel_line_to(1.5, -1.5)
ctx.rel_line_to(0, -2)
ctx.rel_line_to(-2, -2)
ctx.rel_line_to(-4.5, 0)
ctx.rel_line_to(-2, 2)
ctx.rel_line_to(0, 2)
ctx.rel_line_to(1.5, 1.5)
ctx.stroke()
ctx.restore()
if symbol == 1:
ctx.move_to(5, 5)
ctx.rel_line_to(2.5, -2.5)
ctx.rel_line_to(0, 5)
ctx.stroke()
pass
# 3: shuffle,
elif symbol == 3:
ctx.move_to(-4, -1.25)
ctx.rel_line_to(3.25, 0)
ctx.rel_line_to(3.5, 3.5)
ctx.rel_line_to(4.25, 0)
ctx.rel_line_to(-3, 3)
ctx.stroke()
ctx.move_to(-4, 3.25)
ctx.rel_line_to(3.25, 0)
ctx.stroke()
ctx.move_to(2.75, -0.25)
ctx.rel_line_to(4.25, 0)
ctx.rel_line_to(-3, -3)
ctx.stroke()
pass
# 4: start of rgb symbols: none
# 5: rgb static
elif symbol == 5:
ctx.rgb(0, 0, 1)
ctx.arc(0, -2.2, 2.5, 0, math.tau, 0).fill()
ctx.rgb(0, 1, 0)
ctx.arc(-1.5, 1.5, 2.5, 0, math.tau, 0).fill()
ctx.rgb(1, 0, 0)
ctx.arc(1.5, 1.5, 2.5, 0, math.tau, 0).fill()
def draw_label(self, ctx, petal):
if petal == PETAL_A:
self.draw_symbol(ctx, 5)
elif petal == PETAL_B:
ctx.move_to(-4, -1.5)
ctx.rel_line_to(2.5, 0)
ctx.rel_line_to(3, 3)
ctx.rel_line_to(2.5, 0)
ctx.stroke()
ctx.move_to(-4, 1.5)
ctx.rel_line_to(2.5, 0)
ctx.stroke()
ctx.move_to(4, -1.5)
ctx.rel_line_to(-2.5, 0)
ctx.stroke()
elif petal == PETAL_C:
if self.current_page.highlight_is_playing and self.playing:
ctx.move_to(-2, -3)
ctx.rel_line_to(0, 6).stroke()
ctx.move_to(2, -3)
ctx.rel_line_to(0, 6).stroke()
else:
ctx.move_to(-2, -3)
ctx.rel_line_to(6, 3)
ctx.rel_line_to(-6, 3)
ctx.rel_line_to(0, -6)
ctx.stroke()
elif petal == PETAL_D:
ctx.arc(-1, -1, 2, 0, math.tau, 0).stroke()
ctx.move_to(1, 1)
ctx.rel_line_to(2, 2).stroke()
elif petal == PETAL_E:
ctx.save()
ctx.line_width = 2
ctx.move_to(-3, 1)
ctx.rel_line_to(0, -2).stroke()
ctx.move_to(0, 1)
ctx.rel_line_to(0, -2).stroke()
ctx.move_to(3, 1)
ctx.rel_line_to(0, -2).stroke()
ctx.restore()
def background_think(self, ins, delta_ms):
# handle playlist continuity
if self.playing and media.get_position() >= media.get_duration():
media.stop()
self.playing = False
if self.playback_mode and self.active_songlist:
index = self.active_songlist_index
if self.playback_mode == 2:
index += 1
elif self.playback_mode == 3:
rng = random.randint(0, len(self.active_songlist) - 2)
index = -1 if rng == index else rng
index %= len(self.active_songlist)
self.active_songlist_index = index
if index is not None:
self.play_song(self.active_songlist, self.active_songlist_index)
def run_scan(self):
def song_key(thing):
if thing.artist is None:
return (1, thing.path)
album = "" if thing.album is None else thing.album
return (0, thing.artist, thing.year, album, thing.track_number, thing.path)
def artist_key(thing):
return (thing.artist is None, thing.artist)
try:
song = next(self.scan_generator)
except StopIteration:
self.scan_generator = None
return
pos = self.media_page.insort(song, key=song_key, unique=True)
if (
pos is not None
and self.active_songlist is self.mp3_files
and pos <= self.active_songlist_index
):
self.active_songlist_index += 1
self.artists_page.insort(Artist(song.artist), key=artist_key, unique=True)
def update_leds(self, delta_ms):
if self.orig_brightness is None:
return
if not self.led_mode:
leds.set_brightness(0)
return
if self.led_mode == 1:
leds.set_brightness(self.orig_brightness)
else:
# future plans
pass
if self.led_pattern_request:
led_patterns.pretty_pattern()
leds.update()
self.led_pattern_request = False
def think(self, ins, delta_ms):
super().think(ins, delta_ms)
media.think(delta_ms)
self.update_leds(delta_ms)
self.draw_ms += delta_ms
# if there's directories to be scanned do it iteratively now
if self.scan_generator:
self.run_scan()
# handle end of song
self.background_think(ins, delta_ms)
# handle pending saves
if self.save_request:
self.save_timer += delta_ms
if self.save_timer > 7000:
self.save_data()
else:
self.save_timer = 0
# handle lock
if (
self.input.buttons.app.middle.repeated
and not self.app_button_release_ignore
):
self.captouch_locked = not self.captouch_locked
self.app_button_release_ignore = True
self.full_redraw = True
# handle shoulder button play/pause
if self.input.buttons.app.middle.released:
if self.app_button_release_ignore:
self.app_button_release_ignore = False
elif self.active_song is not None:
if self.playing:
media.pause()
else:
media.play()
self.playing = not self.playing
else:
self.play_song(self.current_page.itemlist, self.current_page.highlight)
# handle playlist scroll
if self.song_reset_cooldown:
self.song_reset_cooldown -= delta_ms
if self.song_reset_cooldown < 0:
self.song_reset_cooldown = 0
lr_dir = self.input.buttons.app.right.released
lr_dir -= self.input.buttons.app.left.released
if lr_dir:
if self.app_button_release_ignore:
self.app_button_release_ignore = False
elif self.active_songlist:
index = self.active_songlist_index
if self.playback_mode == 3:
if lr_dir > 0:
rng = random.randint(0, len(self.active_songlist) - 2)
index = -1 if rng == index else rng
else:
if lr_dir < 1 and not self.song_reset_cooldown:
self.song_reset_cooldown += 7000
else:
index += lr_dir
index %= len(self.active_songlist)
self.play_song(self.active_songlist, index)
# handle playlist seek
lr_dir = self.input.buttons.app.right.repeated
lr_dir -= self.input.buttons.app.left.repeated
if lr_dir:
self.app_button_release_ignore = True
dur = media.get_duration()
if dur:
# attempt to scroll 5s, cross ur heart and hope to die
incr = 5 * 16000 / dur
media.seek(media.get_position() / dur + incr * lr_dir)
# time api stops working correctly after seek. this
# is a backend issue and should be fixed there.
# best we can do here is estimates, and not sure if
# that's worth the effort.
self.time_ok = False
if not ins.buttons.app:
self.app_button_release_ignore = False
# handle petal 5 load/play/pause
if (
not self.captouch_locked
and self.input.captouch.petals[PETAL_C].whole.pressed
):
self.label_state[PETAL_C] = None
if self.current_page.highlight_is_playing:
if self.playing:
media.pause()
else:
media.play()
self.playing = not self.playing
else:
self.play_song(self.current_page.itemlist, self.current_page.highlight)
# page swaps
if not self.captouch_locked and not self.label_state[PETAL_E]:
playlist_page_active = self.current_page == self.playlist_page
if self.input.captouch.petals[PETAL_D].whole.pressed:
playlist_page_active = not playlist_page_active
if playlist_page_active:
new_page = self.playlist_page
elif ins.captouch.petals[PETAL_A].pressed:
new_page = self.artists_page
else:
new_page = self.media_page
old_page = self.current_page
if new_page is not old_page:
if not (self.artists_page in [new_page, old_page]):
new_page.label_state[PETAL_D] = None
elif (
self.media_page in [new_page, old_page]
and old_page.itemlist
and new_page.itemlist
):
# scrolls to current artist in other page
artist = old_page.itemlist[old_page.highlight].artist
if not new_page.itemlist[new_page.highlight].artist == artist:
for x, song in enumerate(new_page.itemlist):
if song.artist == artist:
new_page.highlight = x
break
self.full_redraw = True
self.current_page = new_page
# scrolling thingies
cw_dir = 0
if not self.captouch_locked:
cw_dir -= self.input.captouch.petals[0].whole.pressed
cw_dir += self.input.captouch.petals[4].whole.pressed
cw_dir += self.input.captouch.petals[6].whole.pressed
overflow = bool(cw_dir)
# run even when captouch is locked to not have discontinuity
for thingy in self.scroll_thingies:
thingy.think(ins, delta_ms)
if not self.captouch_locked:
cw_dir += thingy.events
thingy.events = 0
if cw_dir:
self.current_page.scroll(cw_dir, overflow)
# handle alt function for bottom petals
if self.captouch_locked:
self.label_state[PETAL_E] = False
else:
self.label_state[PETAL_E] = ins.captouch.petals[PETAL_E].pressed
if self.label_state[PETAL_E]:
if self.input.captouch.petals[PETAL_B].whole.pressed:
self.playback_mode += 1
self.playback_mode %= 4
self.label_state[PETAL_B] = None
self.save_request = True
if self.input.captouch.petals[PETAL_A].whole.pressed:
self.led_mode += 1
self.led_mode %= 2
self.label_state[PETAL_A] = None
self.save_request = True
if (
self.input.captouch.petals[PETAL_D].whole.pressed
and self.active_songlist
and self.active_song
):
index = None
if self.current_page is self.artists_page:
for x, artist in enumerate(self.artists):
if self.active_song.artist == artist:
index = x
break
elif self.active_songlist is self.current_page.itemlist:
index = self.active_songlist_index
else:
try:
index = self.current_page.itemlist.index(self.active_song)
except ValueError:
pass
if index is not None:
self.current_page.highlight = index
self.label_state[PETAL_D] = None
# dirty hack:
# pretend captouch is locked if it's active.
# this means other think functions like track
# highlight advancing still work.
captouch_locked = self.captouch_locked
if self.label_state[PETAL_E]:
self.captouch_locked = True
self.current_page.think(ins, delta_ms)
self.captouch_locked = captouch_locked
def on_enter(self, vm):
self.enter_done = False
self.orig_brightness = leds.get_brightness()
self.orig_slew_rate = leds.get_slew_rate()
leds.set_slew_rate(min(140, self.orig_slew_rate))
self.load_data()
def on_enter_done(self):
self.enter_done = True
def on_exit(self):
leds.set_brightness(self.orig_brightness)
if self.save_request:
self.save_data()
if __name__ == "__main__":
import st3m.run
st3m.run.run_app(App)
[app]
name = "mp3 player"
category = "Media"
wifi_preference = false
[metadata]
author = "Flow3r Badge Authors"
license = "LGPL-3.0-only"
url = "https://git.flow3r.garden/flow3r/flow3r-firmware"
from st3m.application import Application, ApplicationContext
from st3m.ui.colours import PUSH_RED, GO_GREEN, BLACK
from st3m.goose import Dict, Any, Tuple
from st3m.goose import Tuple, Any
from st3m.input import InputState
from ctx import Context
import leds
import json
import math
CONFIG_SCHEMA: dict[str, dict[str, Any]] = {
"name": {"types": [str]},
"size": {"types": [int, float], "cast_to": int},
"font": {"types": [int, float], "cast_to": int},
"pronouns": {"types": ["list_of_str"]},
"pronouns_size": {"types": [int, float], "cast_to": int},
"color": {"types": ["hex_color"]},
"mode": {"types": [int, float], "cast_to": int},
}
class Configuration:
def __init__(self) -> None:
......@@ -17,6 +26,8 @@ class Configuration:
self.pronouns_size: int = 25
self.color = "0x40ff22"
self.mode = 0
self.config_errors: list[str] = []
self.config_loaded: bool = False
@classmethod
def load(cls, path: str) -> "Configuration":
......@@ -25,53 +36,62 @@ class Configuration:
with open(path) as f:
jsondata = f.read()
data = json.loads(jsondata)
res.config_loaded = True
except OSError:
data = {}
if "name" in data and type(data["name"]) == str:
res.name = data["name"]
if "size" in data:
if type(data["size"]) == float:
res.size = int(data["size"])
if type(data["size"]) == int:
res.size = data["size"]
if "font" in data and type(data["font"]) == int:
res.font = data["font"]
# type checks don't look inside collections
if (
"pronouns" in data
and type(data["pronouns"]) == list
and set([type(x) for x in data["pronouns"]]) == {str}
except ValueError:
res.config_errors = ["nick.json decode failed!"]
data = {}
# verify the config format and generate an error message
config_type_errors: list[str] = []
for config_key, type_data in CONFIG_SCHEMA.items():
if config_key not in data.keys():
continue
key_type_valid = False
for allowed_type in type_data["types"]:
if isinstance(allowed_type, type):
if isinstance(data[config_key], allowed_type):
key_type_valid = True
break
elif allowed_type == "list_of_str":
if isinstance(data[config_key], list) and (
len(data[config_key]) == 0
or set([type(x) for x in data[config_key]]) == {str}
):
res.pronouns = data["pronouns"]
if "pronouns_size" in data:
if type(data["pronouns_size"]) == float:
res.pronouns_size = int(data["pronouns_size"])
if type(data["pronouns_size"]) == int:
res.pronouns_size = data["pronouns_size"]
key_type_valid = True
break
elif allowed_type == "hex_color":
if (
"color" in data
and type(data["color"]) == str
and data["color"][0:2] == "0x"
and len(data["color"]) == 8
isinstance(data[config_key], str)
and data[config_key][0:2] == "0x"
and len(data[config_key]) == 8
):
res.color = data["color"]
if "mode" in data:
if type(data["mode"]) == float:
res.mode = int(data["mode"])
if type(data["mode"]) == int:
res.mode = data["mode"]
key_type_valid = True
break
if not key_type_valid:
config_type_errors.append(config_key)
else:
# Cast to relevant type if needed
if type_data.get("cast_to"):
data[config_key] = type_data["cast_to"](data[config_key])
setattr(res, config_key, data[config_key])
if config_type_errors:
res.config_errors += [
"data types wrong",
"in nick.json for:",
"",
] + config_type_errors
return res
def save(self, path: str) -> None:
d = {
"name": self.name,
"size": self.size,
"font": self.font,
"pronouns": self.pronouns,
"pronouns_size": self.pronouns_size,
"color": self.color,
"mode": self.mode,
config_key: getattr(self, config_key) for config_key in CONFIG_SCHEMA.keys()
}
jsondata = json.dumps(d)
with open(path, "w") as f:
f.write(jsondata)
......@@ -96,6 +116,8 @@ class NickApp(Application):
self._config = Configuration.load(self._filename)
self._pronouns_serialized = " ".join(self._config.pronouns)
self._angle = 0.0
if not self._config.config_loaded and not self._config.config_errors:
self._config.save(self._filename)
def draw(self, ctx: Context) -> None:
ctx.text_align = ctx.CENTER
......@@ -106,6 +128,19 @@ class NickApp(Application):
ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
ctx.rgb(*self._config.to_normalized_tuple())
if self._config.config_errors:
draw_y = (-20 * len(self._config.config_errors)) / 2
ctx.move_to(0, draw_y)
ctx.font_size = 20
# 0xff4500, red
ctx.rgb(1, 0.41, 0)
ctx.font = ctx.get_font_name(8)
for config_error in self._config.config_errors:
draw_y += 20
ctx.move_to(0, draw_y)
ctx.text(config_error)
return
ctx.move_to(0, 0)
ctx.save()
if self._config.mode == 0:
......@@ -131,9 +166,6 @@ class NickApp(Application):
leds.update()
# ctx.fill()
def on_exit(self) -> None:
self._config.save(self._filename)
def think(self, ins: InputState, delta_ms: int) -> None:
super().think(ins, delta_ms)
......@@ -156,4 +188,4 @@ class NickApp(Application):
if __name__ == "__main__":
import st3m.run
st3m.run.run_view(NickApp(ApplicationContext()))
st3m.run.run_app(NickApp)
[app]
name = "Nick"
menu = "Badge"
category = "Badge"
[entry]
class = "NickApp"
......
......@@ -17,6 +17,7 @@ from ctx import Context
class Blob(Responder):
def __init__(self) -> None:
self._yell = 0.0
self._wah = 0.0
self._blink = False
self._blinking: Optional[int] = None
......@@ -38,6 +39,7 @@ class Blob(Responder):
v = 0
v /= 1.5
v *= 0.66 + 0.33 * self._wah
if v < 0.1:
v = 0.1
......@@ -76,49 +78,108 @@ class Otamatone(Application):
"""
PETAL_NO = 7
WAH_PETAL_NO = 3
def __init__(self, app_ctx: ApplicationContext) -> None:
super().__init__(app_ctx)
self._ts = 0
self._blob = Blob()
self._blm = bl00mbox.Channel("Otamatone")
# Sawtooth oscillator
self._osc = self._blm.new(bl00mbox.patches.tinysynth)
# Distortion plugin used as a LUT to convert sawtooth into custom square
# wave.
self._dist = self._blm.new(bl00mbox.plugins._distortion)
# Lowpass.
self._lp = self._blm.new(bl00mbox.plugins.lowpass)
# Wire sawtooth -> distortion -> lowpass
self._osc.signals.output = self._dist.signals.input
self._dist.signals.output = self._lp.signals.input
self._lp.signals.output = self._blm.mixer
# Closest thing to a waveform number for 'saw'.
self._osc.signals.waveform = 20000
self._osc.signals.attack = 1
self._osc.signals.decay = 0
# Built custom square wave (low duty cycle)
table_len = 129
self._dist.table = [
32767 if i > (0.1 * table_len) else -32768 for i in range(table_len)
self._blm = None
self._intensity = 0.0
self._formants = [
[250, 595, 595],
[360, 640, 640],
[310, 870, 2250],
[450, 1030, 2380],
[550, 869, 2540],
[710, 1100, 2540],
[690, 1660, 2490],
[550, 1770, 2490],
[400, 1920, 2560],
[280, 2250, 2890],
]
self._lp.signals.freq = 4000
self._intensity = 0.0
def _build_synth(self):
if self._blm is None:
self._blm = bl00mbox.Channel("Otamatone")
self.input.captouch.petals[self.PETAL_NO].whole.repeat_disable()
self._osc = self._blm.new(bl00mbox.plugins.osc)
self._env = self._blm.new(bl00mbox.plugins.env_adsr)
self._bp = self._blm.new(bl00mbox.plugins.filter)
self._bp2 = self._blm.new(bl00mbox.plugins.filter)
self._bp3 = self._blm.new(bl00mbox.plugins.filter)
self._mixer = self._blm.new(bl00mbox.plugins.mixer, 4)
self._osc.signals.output = self._env.signals.input
self._bp.signals.input = self._env.signals.output
self._bp2.signals.input = self._env.signals.output
self._bp3.signals.input = self._env.signals.output
self._bp.signals.output = self._mixer.signals.input[0]
self._bp2.signals.output = self._mixer.signals.input[1]
self._bp3.signals.output = self._mixer.signals.input[2]
self._env.signals.output = self._mixer.signals.input[3]
self._mixer.signals.input_gain[0].dB = 0
self._mixer.signals.input_gain[1].dB = 0
self._mixer.signals.input_gain[2].dB = -6
self._mixer.signals.input_gain[3].dB = -15
self._mixer.signals.output = self._blm.mixer
self._osc.signals.waveform = (
self._osc.signals.waveform.switch.SQUARE * 3
+ self._osc.signals.waveform.switch.SAW
) // 4
self._osc.signals.morph = 27000
self._env.signals.attack = 1
self._env.signals.decay = 0
self._env.signals.sustain = 32767
self._bp.signals.mode.switch.BANDPASS = True
self._bp2.signals.mode.switch.BANDPASS = True
self._bp3.signals.mode.switch.BANDPASS = True
self._bp.signals.reso = 24000
self._bp2.signals.reso = 24000
self._bp3.signals.reso = 24000
self._set_wah(0.8)
def _set_wah(self, wah_ctrl):
lerp = (wah_ctrl + 1) / 2 * (len(self._formants) - 1)
index = int(lerp)
lerp = lerp - index
if index == (len(self._formants) - 1):
self._bp.signals.cutoff.freq = self._formants[index][0]
self._bp2.signals.cutoff.freq = self._formants[index][1]
self._bp3.signals.cutoff.freq = self._formants[index][2]
else:
self._bp.signals.cutoff.freq = (1 - lerp) * self._formants[index][
0
] + lerp * self._formants[index + 1][0]
self._bp2.signals.cutoff.freq = (1 - lerp) * self._formants[index][
1
] + lerp * self._formants[index + 1][1]
self._bp3.signals.cutoff.freq = (1 - lerp) * self._formants[index][
2
] + lerp * self._formants[index + 1][2]
self._wah = wah_ctrl
def on_exit(self):
if self._blm is not None:
self._blm.clear()
self._blm.free = True
self._blm = None
self._intensity = 0
def on_enter(self, vm: Optional[ViewManager]) -> None:
super().on_enter(vm)
for i in range(40):
leds.set_rgb(i, 0, 0, 0)
for i in range(26, 30 + 1):
leds.set_rgb(i, 62, 159, 229)
for i in range(10, 14 + 1):
leds.set_rgb(i, 62, 229, 159)
leds.update()
if self._blm is None:
self._build_synth()
self._blm.foreground = True
def draw(self, ctx: Context) -> None:
ctx.save()
......@@ -126,7 +187,6 @@ class Otamatone(Application):
ctx.gray(0)
ctx.rectangle(-120, -120, 240, 240)
ctx.fill()
self._blob.draw(ctx)
ctx.restore()
......@@ -145,20 +205,42 @@ class Otamatone(Application):
ctrl = 1
ctrl *= -1
if petal.whole.down:
wah_petal = self.input.captouch.petals[self.WAH_PETAL_NO]
wah_pos = ins.captouch.petals[self.WAH_PETAL_NO].position
wah_ctrl = wah_pos[0] / 40000 - 0.2
if wah_ctrl < -1:
wah_ctrl = -1
if wah_ctrl > 1:
wah_ctrl = 1
wah_ctrl *= -1
if petal.whole.pressed:
self._env.signals.trigger.start()
if petal.whole.down or petal.whole.pressed:
if self._intensity < 1.0:
self._intensity += 0.1 * (delta_ms / 20)
self._osc.signals.pitch.tone = ctrl * 12
self._osc.signals.trigger.start()
self._osc.signals.pitch.tone = (ctrl * 15) - 3
if petal.whole.up:
if petal.whole.released:
self._intensity = 0
self._osc.signals.trigger.stop()
self._env.signals.trigger.stop()
if wah_petal.whole.down:
self._set_wah(wah_ctrl)
self._blob._yell = self._intensity * 0.8 + (ctrl + 1) * 0.1
self._blob._wah = self._blob._wah * 0.8 + self._wah * 0.2
def get_help(self):
ret = (
"The blue petal makes noise. The green petal modulates a dual "
"resonant lowpass filter that roughly mimics human formants."
)
return ret
if __name__ == "__main__":
from st3m.run import run_view
from st3m.run import run_app
run_view(Otamatone(ApplicationContext()))
run_app(Otamatone)
[app]
name = "Otamatone"
menu = "Music"
category = "Music"
[entry]
class = "Otamatone"
......
from st3m.ui.view import ViewManager
from st3m.goose import Optional, List, Enum
from st3m.input import InputState
from st3m.application import Application, ApplicationContext
from ctx import Context
import captouch
import bl00mbox
import errno
import leds
import math
import time
import json
import os
blm = bl00mbox.Channel("Scalar")
class Scale:
__slots__ = ("name", "notes")
name: str
notes: List[int]
def __init__(self, name: str, notes: List[int]):
self.name = name
self.notes = notes
def note(self, i: int, mode: int) -> int:
octave, note = divmod(i + mode, len(self.notes))
return self.notes[note] - self.notes[mode] + octave * 12
note_names = [
"A",
"A# / Bb",
"B",
"C",
"C# / Db",
"D",
"D# / Eb",
"E",
"F",
"F# / Gb",
"G",
"G# / Ab",
]
UI_PLAY = 0
UI_KEY = 1
UI_SCALE = 2
UI_MODE = 3
UI_OFFSET = 4
UI_SELECT = 5
DOUBLE_TAP_THRESH_MS = 500
class ScalarApp(Application):
def __init__(self, app_ctx: ApplicationContext) -> None:
super().__init__(app_ctx)
self._load_settings()
self._ui_state = UI_PLAY
self._ui_mid_prev_time = 0
self._ui_cap_prev = captouch.read()
self._color_intensity = 0.0
self._scale_key = 0
self._scale_offset = 0
self._scale_mode = 0
self._scale_index = 0
self._scale: Scale = self._scales[0]
self._synths = [blm.new(bl00mbox.patches.tinysynth) for i in range(10)]
for synth in self._synths:
synth.signals.decay = 500
synth.signals.waveform = 0
synth.signals.attack = 50
synth.signals.volume = 0.3 * 32767
synth.signals.sustain = 0.6 * 32767
synth.signals.release = 800
synth.signals.output = blm.mixer
self._update_leds()
def _load_settings(self) -> None:
default_path = self.app_ctx.bundle_path + "/scalar-default.json"
settings_path = "/flash/scalar.json"
settings = self._try_load_settings(default_path)
assert settings is not None, "failed to load default settings"
user_settings = self._try_load_settings(settings_path)
if user_settings is None:
self._try_write_default_settings(settings_path, default_path)
else:
settings.update(user_settings)
self._scales = [
Scale(scale["name"], scale["notes"]) for scale in settings["scales"]
]
self._ui_labels = settings["ui_labels"]
def _try_load_settings(self, path: str) -> Optional[dict]:
try:
with open(path, "r") as f:
return json.load(f)
except OSError as e:
if e.errno != errno.ENOENT:
raise # ignore file not found
def _try_write_default_settings(self, path: str, default_path: str) -> None:
with open(path, "w") as outfile, open(default_path, "r") as infile:
outfile.write(infile.read())
def _update_leds(self) -> None:
hue = 30 * (self._scale_key % 12) + (30 / len(self._scales)) * self._scale_index
leds.set_all_hsv(hue, 1, 0.2)
leds.update()
def _set_key(self, i: int) -> None:
if i != self._scale_key:
self._scale_key = i
self._update_leds()
def _set_scale(self, i: int) -> None:
i = i % len(self._scales)
if i != self._scale_index:
self._scale_index = i
self._scale = self._scales[i]
self._scale_mode %= len(self._scale.notes)
self._scale_offset %= len(self._scale.notes)
self._update_leds()
def _set_mode(self, i: int) -> None:
i = i % len(self._scale.notes)
self._scale_mode = i
def _set_offset(self, i: int) -> None:
i = i % len(self._scale.notes)
self._scale_offset = i
def _key_name(self) -> str:
return note_names[self._scale_key % 12]
def on_enter(self, vm: Optional[ViewManager]) -> None:
super().on_enter(vm)
self._load_settings()
def on_exit(self) -> None:
for synth in self._synths:
synth.signals.trigger.stop()
def draw(self, ctx: Context) -> None:
ctx.rgb(0, 0, 0)
ctx.rectangle(-120, -120, 240, 240)
ctx.fill()
# center UI text
ctx.text_align = ctx.CENTER
ctx.text_baseline = ctx.MIDDLE
ctx.font_size = 32
ctx.rgb(255, 255, 255)
ctx.move_to(0, -12)
ctx.text(self._key_name())
while ctx.text_width(self._scale.name) > 200:
ctx.font_size -= 1
ctx.rgb(255, 255, 255)
ctx.move_to(0, 12)
ctx.text(self._scale.name)
def draw_text(petal, r, text, inv=False):
ctx.save()
if inv:
petal = (petal + 5) % 10
r = -r
ctx.rotate(petal * math.tau / 10 + math.pi)
ctx.move_to(0, r)
ctx.text(text)
ctx.restore()
def draw_dot(petal, r):
ctx.save()
ctx.rotate(petal * math.tau / 10 + math.pi)
ctx.rectangle(-5, -5 + r, 10, 10).fill()
ctx.restore()
def draw_tri(petal, r):
ctx.save()
ctx.rotate(petal * math.tau / 10 + math.pi)
ctx.move_to(-5, -5 + r)
ctx.line_to(5, -5 + r)
ctx.line_to(0, 5 + r)
ctx.close_path()
ctx.fill()
ctx.restore()
def draw_line(petal, r):
ctx.save()
ctx.rotate(petal * math.tau / 10 + math.pi)
ctx.move_to(-1, -5 + r)
ctx.line_to(1, -5 + r)
ctx.line_to(1, 5 + r)
ctx.line_to(-1, 5 + r)
ctx.close_path()
ctx.fill()
ctx.restore()
ctx.font_size = 14
if self._ui_state == UI_KEY or self._ui_state == UI_SELECT:
draw_dot(8, 90)
if self._ui_labels:
draw_text(8, 75, "KEY", inv=True)
if self._ui_state == UI_SCALE or self._ui_state == UI_SELECT:
draw_dot(2, 90)
if self._ui_labels:
draw_text(2, 75, "SCALE", inv=True)
if self._ui_state == UI_MODE or self._ui_state == UI_SELECT:
draw_dot(6, 90)
if self._ui_labels:
draw_text(6, 75, "MODE")
if self._ui_state == UI_OFFSET or self._ui_state == UI_SELECT:
draw_dot(4, 90)
if self._ui_labels:
draw_text(4, 75, "OFFSET")
if self._ui_state == UI_SELECT:
draw_dot(0, 90)
if self._ui_labels:
draw_text(0, 75, "PLAY", inv=True)
draw_tri(self._scale_offset, 110)
if self._scale_mode != 0:
orig_root_scale_degree = len(self._scale.notes) - self._scale_mode
orig_root_petal = (orig_root_scale_degree + self._scale_offset) % len(
self._scale.notes
)
draw_line(orig_root_petal, 110)
def think(self, ins: InputState, delta_ms: int) -> None:
super().think(ins, delta_ms)
if self._color_intensity > 0:
self._color_intensity -= self._color_intensity / 20
if self.input.buttons.app.middle.pressed:
mid_time = time.ticks_ms()
if self._ui_state == UI_SELECT:
self._ui_state = UI_PLAY
if (
self._ui_state != UI_PLAY
or mid_time - self._ui_mid_prev_time < DOUBLE_TAP_THRESH_MS
):
self._ui_state = UI_SELECT
self._ui_mid_prev_time = mid_time
if self._ui_state == UI_PLAY:
if self.input.buttons.app.left.pressed:
self._set_key(self._scale_key - 12)
if self.input.buttons.app.right.pressed:
self._set_key(self._scale_key + 12)
elif self._ui_state == UI_KEY:
if self.input.buttons.app.left.pressed:
self._set_key(self._scale_key - 1)
if self.input.buttons.app.right.pressed:
self._set_key(self._scale_key + 1)
elif self._ui_state == UI_SCALE:
if self.input.buttons.app.left.pressed:
self._set_scale(self._scale_index - 1)
if self.input.buttons.app.right.pressed:
self._set_scale(self._scale_index + 1)
elif self._ui_state == UI_MODE:
if self.input.buttons.app.left.pressed:
self._set_mode(self._scale_mode - 1)
if self.input.buttons.app.right.pressed:
self._set_mode(self._scale_mode + 1)
elif self._ui_state == UI_OFFSET:
if self.input.buttons.app.left.pressed:
self._set_offset(self._scale_offset - 1)
if self.input.buttons.app.right.pressed:
self._set_offset(self._scale_offset + 1)
cts = captouch.read()
for i in range(10):
pressed = cts.petals[i].pressed and not self._ui_cap_prev.petals[i].pressed
released = not cts.petals[i].pressed and self._ui_cap_prev.petals[i].pressed
if self._ui_state == UI_SELECT:
if pressed:
if i == 0:
self._ui_state = UI_PLAY
elif i == 8:
self._ui_state = UI_KEY
elif i == 2:
self._ui_state = UI_SCALE
elif i == 6:
self._ui_state = UI_MODE
elif i == 4:
self._ui_state = UI_OFFSET
else:
half_step_up = int(self.input.buttons.app.middle.down)
if pressed:
self._synths[i].signals.pitch.tone = (
self._scale_key
+ self._scale.note(i - self._scale_offset, self._scale_mode)
+ half_step_up
)
self._synths[i].signals.trigger.start()
self._color_intensity = 1.0
elif released:
self._synths[i].signals.trigger.stop()
self._ui_cap_prev = cts
if __name__ == "__main__":
from st3m.run import run_app
run_app(ScalarApp, "/flash/sys/apps/scalar")
[app]
name = "Scalar"
category = "Music"
[entry]
class = "ScalarApp"
[metadata]
author = "yrlf"
description = "A better melodic instrument application for the flow3r that supports many different musical scales."
license = "LGPL-3.0-only"
url = "https://github.com/Ferdi265/flow3r-scalar"
version = 3
{
"scales": [
{ "name": "Major", "notes": [0, 2, 4, 5, 7, 9, 11] },
{ "name": "Natural Minor", "notes": [0, 2, 3, 5, 7, 8, 10] },
{ "name": "Harmonic Minor", "notes": [0, 2, 3, 5, 7, 8, 11] },
{ "name": "Major Pentatonic", "notes": [0, 2, 4, 7, 9] },
{ "name": "Minor Pentatonic", "notes": [0, 3, 5, 7, 10] },
{ "name": "Blues", "notes": [0, 3, 5, 6, 7, 10] },
{ "name": "Diminished", "notes": [0, 2, 3, 5, 6, 8, 9, 11] },
{ "name": "Augmented", "notes": [0, 3, 4, 7, 8, 11] },
{ "name": "Whole Tone", "notes": [0, 2, 4, 6, 8, 10] }
],
"songs": [
{
"name": "Saria's Song", "key": 5, "scale": 0, "mode": 1, "offset": 0,
"notes": [
2, 4, 5, 2, 4, 5, 2, 4, 5, 8, 7, 5, 6, 5, 3, 1, 0, 1, 3, 1,
2, 4, 5, 2, 4, 5, 2, 4, 5, 8, 7, 5, 6, 7, 5, 3, 5, 3, 0, 1
]
},
{
"name": "Song of Storms", "key": 5, "scale": 0, "mode": 1, "offset": 0,
"notes": [
0, 2, 7, 0, 2, 7, 8, 9, 8, 9, 8, 6, 4, 4, 0, 2, 3, 4, 4, 0, 2, 3, 1,
0, 2, 7, 0, 2, 7, 8, 9, 8, 9, 8, 6, 4, 4, 0, 2, 3, 4, 4, 0
]
}
],
"learn": true,
"ui_labels": true
}
from st3m.application import Application, ApplicationContext
from st3m.goose import Dict, Any, List, Optional
from st3m.ui.view import View, ViewManager
from st3m.ui import colours
from st3m.input import InputState
from ctx import Context
import json
import math
import leds
from st3m.application import Application, ApplicationContext
from st3m.ui import widgets
class App(Application):
def __init__(self, app_ctx: ApplicationContext) -> None:
super().__init__(app_ctx)
self.offset = -60
self.interval = 20
self.data_exists = False
self.rotate = 0
self.rot_velo = 0
self.rot_mass = 2
self.rot_friction = 0.93
self.alt_slow_widget = widgets.Altimeter()
self.alt_fast_widget = widgets.Altimeter(filter_stages=0)
self.inc_widget = widgets.Inclinometer(buffer_len=1)
self.widgets = [self.alt_slow_widget, self.alt_fast_widget, self.inc_widget]
self.single_line_titles = [
"pressure",
"temperature",
"battery voltage",
"relative altitude",
]
self.double_line_titles = ["accelerometer", "gyroscope"]
self.units = ["m", "deg"]
self.units = [f"(x, y, z) [{x}/s]" for x in self.units]
def draw_background(self, ctx):
ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
interval = self.interval
counter = self.offset
ctx.font = "Camp Font 2"
ctx.text_align = ctx.RIGHT
ctx.rgb(0.8, 0.8, 0.6)
ctx.font_size = 22
gap = -4
for x in self.single_line_titles:
ctx.move_to(gap, counter)
counter += interval
ctx.text(x)
for x in self.double_line_titles:
ctx.move_to(gap, counter)
counter += 2 * interval
ctx.text(x)
gap = 4
ctx.text_align = ctx.LEFT
ctx.rgb(0.6, 0.7, 0.6)
ctx.font = "Arimo Bold Italic"
ctx.font_size = 14
counter -= 4 * interval
for x in self.units:
ctx.move_to(gap, counter)
counter += 2 * interval
ctx.text(x)
def draw(self, ctx: Context) -> None:
azi = self.inc_widget.azimuth
inc = self.inc_widget.inclination
if azi is not None and inc is not None:
delta = azi - self.rotate
if delta > math.pi:
delta -= math.tau
elif delta < -math.pi:
delta += math.tau
# normalize to 1
delta /= math.pi
inc /= math.pi
if delta > 0:
self.rot_velo += delta * (1 - delta) * inc * (1 - inc)
else:
delta = -delta
self.rot_velo -= delta * (1 - delta) * inc * (1 - inc)
self.rotate += self.rot_velo / self.rot_mass
self.rotate = self.rotate % math.tau
self.rot_velo *= self.rot_friction
ctx.rotate(-self.rotate)
self.draw_background(ctx)
if not self.data_exists:
return
ctx.font = "Camp Font 2"
ctx.font_size = 25
counter = self.offset
interval = self.interval
gap = 4
ctx.rgb(0.5, 0.8, 0.8)
single_lines = [
str(self.pressure / 100)[:6] + "hPa",
str(self.temperature)[:5] + "degC",
"n/a",
str(self.altitude)[:6] + "m",
]
if self.battery_voltage is not None:
single_lines[2] = str(self.battery_voltage)[:5] + "V"
for x in single_lines:
ctx.move_to(gap, counter)
counter += interval
ctx.text(x)
counter += interval
ctx.text_align = ctx.MIDDLE
double_lines = [
", ".join([str(y)[:4] for y in x]) for x in [self.acc, self.gyro]
]
for x in double_lines:
ctx.move_to(0, counter)
counter += 2 * interval
ctx.text(f"({x})")
def think(self, ins: InputState, delta_ms: int) -> None:
super().think(ins, delta_ms)
for widget in self.widgets:
widget.think(ins, delta_ms)
self.pressure = ins.imu.pressure
self.battery_voltage = ins.battery_voltage
self.temperature = ins.temperature
self.acc = ins.imu.acc
self.gyro = ins.imu.gyro
self.altitude = self.alt_fast_widget.meters_above_sea
self.data_exists = True
led_altitude = self.alt_slow_widget.meters_above_sea
if led_altitude is not None:
hue = 6.28 * (led_altitude % 1.0)
leds.set_all_rgb(*colours.hsv_to_rgb(hue, 1, 1))
leds.update()
def on_enter(self, vm: Optional[ViewManager]) -> None:
super().on_enter(vm)
for widget in self.widgets:
widget.on_enter()
self.data_exists = False
def on_exit(self):
super().on_exit()
for widget in self.widgets:
widget.on_exit()
def get_help(self):
ret = (
"Shows output of several onboard sensors. LED hue changes with smoothed "
"relative altitude and goes full circle on a 1m height difference."
"The Screen rotates to an upright position.\n\n"
"The sensor data shown on the display is purposefully unfiltered to "
"reflect the expected noise floor when using the raw data."
)
return ret
# For running with `mpremote run`:
if __name__ == "__main__":
import st3m.run
st3m.run.run_view(App(ApplicationContext()))
[app]
name = "sensors"
menu = "Demos"
[metadata]
author = "Flow3r Badge Authors"
license = "LGPL-3.0-only"
url = "https://git.flow3r.garden/flow3r/flow3r-firmware"
......@@ -3,14 +3,18 @@ from st3m.goose import Dict, Any, List, Optional
from st3m.ui.view import View, ViewManager
from st3m.input import InputState
from ctx import Context
from st3m.ui import colours
import json
import errno
import math
import captouch
import bl00mbox
import leds
import random
import math
import sys_display
chords = [
[-5, -5, 2, 7, 10],
......@@ -27,11 +31,11 @@ class ShoegazeApp(Application):
def __init__(self, app_ctx: ApplicationContext) -> None:
super().__init__(app_ctx)
# synth is initialized in on_enter!
# synth is initialized in on_enter_done!
self.blm: Optional[bl00mbox.Channel] = None
self.chord_index = 0
self.chord: List[int] = []
self._set_chord(3)
self._organ_chords = [None] * 5
self._tilt_bias = 0.0
self._detune_prev = 0.0
self._git_string_tuning = [0] * 4
......@@ -41,101 +45,118 @@ class ShoegazeApp(Application):
self._rand_limit = 16
self._rand_rot = 0.0
self.delay_on = True
self.fuzz_on = True
self.organ_on = False
self.hue = 0
self._set_chord(3, force_update=True)
def _build_synth(self) -> None:
if self.blm is None:
self.blm = bl00mbox.Channel("shoegaze")
self.main_lp = self.blm.new(bl00mbox.plugins.lowpass)
self.main_fuzz = self.blm.new(bl00mbox.patches.fuzz)
self.main_fuzz = self.blm.new(bl00mbox.plugins.distortion)
self.main_mixer = self.blm.new(bl00mbox.plugins.mixer, 2)
self.git_strings = [
self.blm.new(bl00mbox.patches.karplus_strong) for i in range(4)
]
self.bass_string = self.blm.new(bl00mbox.patches.karplus_strong)
self.git_mixer = self.blm.new(bl00mbox.plugins.mixer, 4)
self.git_fuzz = self.blm.new(bl00mbox.patches.fuzz)
self.git_delay = self.blm.new(bl00mbox.plugins.delay)
self.git_lp = self.blm.new(bl00mbox.plugins.lowpass)
self.bass_lp = self.blm.new(bl00mbox.plugins.lowpass)
self.git_mixer.signals.input0 = self.git_strings[0].signals.output
self.git_mixer.signals.input1 = self.git_strings[1].signals.output
self.git_mixer.signals.input2 = self.git_strings[2].signals.output
self.git_mixer.signals.input3 = self.git_strings[3].signals.output
self.git_mixer.signals.block_dc.switch.ON = True
self.main_mixer.signals.block_dc.switch.ON = True
self.git_fuzz = self.blm.new(bl00mbox.plugins.distortion)
self.git_delay = self.blm.new(bl00mbox.plugins.delay_static)
self.git_lp = self.blm.new(bl00mbox.plugins.filter)
self.bass_lp = self.blm.new(bl00mbox.plugins.filter)
self.main_lp = self.blm.new(bl00mbox.plugins.filter)
self.git_lp.signals.cutoff.freq = 700
self.git_lp.signals.reso = 10000
self.bass_lp.signals.cutoff.freq = 400
self.bass_lp.signals.reso = 12000
self.main_lp.signals.cutoff.freq = 2500
self.main_lp.signals.reso = 8000
for i in range(4):
self.git_mixer.signals.input[i] = self.git_strings[i].signals.output
self.git_strings[i].signals.reso = -2
self.git_strings[i].signals.decay = 3000
self.bass_string.signals.reso = -2
self.bass_string.signals.decay = 1000
self.git_mixer.signals.output = self.git_lp.signals.input
self.git_fuzz._special_sauce = 2
self.git_fuzz.signals.input = self.git_lp.signals.output
self.main_mixer.signals.input[0] = self.git_delay.signals.output
self.bass_lp.signals.input = self.bass_string.signals.output
self.main_mixer.signals.input1 = self.bass_lp.signals.output
self.main_mixer.signals.input[1] = self.bass_lp.signals.output
self.main_mixer.signals.input_gain[1].dB = -3
self.main_fuzz._special_sauce = 2
self.main_fuzz.signals.input = self.main_mixer.signals.output
self.main_fuzz.signals.output = self.main_lp.signals.input
self.main_lp.signals.output = self.blm.mixer
self.git_delay.signals.input = self.git_fuzz.signals.output
self.git_delay.signals.time = 200
self.git_delay.signals.dry_vol = 32767
self.git_delay.signals.level = 16767
self.git_delay.signals.feedback = 27000
self.git_fuzz.intensity = 8
self.git_fuzz.gate = 1500
self.git_fuzz.volume = 12000
self.git_lp.signals.freq = 700
self.git_lp.signals.reso = 2500
self.bass_lp.signals.freq = 400
self.bass_lp.signals.reso = 3000
self.main_fuzz.intensity = 8
self.main_fuzz.gate = 1500
self.main_fuzz.volume = 32000
self.main_fuzz.curve_set_power(8, 32000, 1500)
self.git_fuzz.curve_set_power(9, 12000, 750)
self.main_mixer.signals.gain = 2000
self.main_lp.signals.reso = 2000
self.bass_lp.signals.gain = 32767
self.git_lp.signals.gain = 32767
self.main_lp.signals.gain = 2000
self.main_lp.signals.input = self.main_fuzz.signals.output
self._update_connections()
def _update_connections(self) -> None:
if self.blm is None:
return
if self.fuzz_on:
self.bass_lp.signals.gain = 32767
self.git_lp.signals.gain = 32767
self.main_lp.signals.freq = 2500
self.main_lp.signals.gain = 2000
self.git_mixer.signals.gain = 4000
self.main_lp.signals.input = self.main_fuzz.signals.output
if self.delay_on:
self.git_delay.signals.input = self.git_fuzz.signals.output
self.main_mixer.signals.input0 = self.git_delay.signals.output
else:
self.main_mixer.signals.input0 = self.git_fuzz.signals.output
else:
self.bass_lp.signals.gain = 2000
self.git_lp.signals.gain = 2000
self.main_lp.signals.freq = 6000
self.main_lp.signals.gain = 4000
self.git_mixer.signals.gain = 500
self.main_lp.signals.input = self.main_mixer.signals.output
if self.delay_on:
self.git_delay.signals.input = self.git_lp.signals.output
self.main_mixer.signals.input0 = self.git_delay.signals.output
self.git_delay.signals.level = 16767
else:
self.main_mixer.signals.input0 = self.git_lp.signals.output
def fuzz_toggle(self) -> None:
self.fuzz_on = not self.fuzz_on
self._update_connections()
self.git_delay.signals.level = 0
def _try_load_settings(self, path):
try:
with open(path, "r") as f:
return json.load(f)
except OSError as e:
if e.errno != errno.ENOENT:
raise # ignore file not found
def _load_settings(self):
settings_path = "/flash/harmonic_demo.json"
settings = self._try_load_settings(settings_path)
if settings is not None:
for i, chord in enumerate(settings["chords"]):
if i > 4:
break
if "tones_readonly" in chord:
self._organ_chords[i] = chord["tones_readonly"]
def organ_toggle(self) -> None:
self.organ_on = not self.organ_on
if self.organ_on and self._organ_chords[0] is None:
self._load_settings()
if self._organ_chords[0] is None:
self.organ_on = False
self._set_chord(self.chord_index, force_update=True)
def delay_toggle(self) -> None:
self.delay_on = not self.delay_on
self._update_connections()
def _set_chord(self, i: int) -> None:
hue = int(54 * (i + 0.5)) % 360
if i != self.chord_index:
def _set_chord(self, i: int, force_update=False) -> None:
if i != self.chord_index or force_update:
self.hue = (54 * (i + 0.5)) * math.tau / 360
self.chord_index = i
leds.set_all_hsv(hue, 1, 0.2)
leds.update()
if self.organ_on and self._organ_chords[i] is not None:
tmp = self._organ_chords[i]
# tmp[0] -= 12
self.chord = tmp
else:
self.chord = chords[i]
def draw(self, ctx: Context) -> None:
......@@ -151,43 +172,51 @@ class ShoegazeApp(Application):
ctx.font = ctx.get_font_name(5)
ctx.font_size = 35
ctx.move_to(0, -112)
ctx.move_to(0, -105)
ctx.rgb(0.2, 0, 0.2)
ctx.text("bass")
rot = self._spinny # + self._detune_prev
ctx.rgb(0, 0.5, 0.5)
ctx.rotate(rot + self._rand_rot)
if self.organ_on:
ctx.rgb(0.5, 0, 0.6)
ctx.rotate(0.69)
ctx.move_to(0, -10)
ctx.text("chordorganchordorganchord")
ctx.move_to(0, 10)
ctx.text("organchordorganchordorgan")
ctx.rotate(4.20 + 0.69)
else:
ctx.rgb(0, 0.5, 0.5)
ctx.move_to(0, -10)
ctx.text("shoegazeshoegazeshoe")
ctx.move_to(0, 10)
ctx.text("gazeshoegazeshoegaze")
ctx.rotate(-2.2 * (rot + self._rand_rot) - 0.5)
if self.organ_on:
rgb = (0.7, 0.7, 0)
else:
rgb = (0, 0.8, 0)
ctx.move_to(40, 40)
if self.delay_on:
ctx.rgb(0, 0.8, 0)
ctx.rgb(*rgb)
ctx.text("delay ON!")
else:
ctx.rgb(0, 0.6, 0)
ctx.rgb(*tuple([x * 0.75 for x in rgb]))
ctx.text("delay off")
ctx.rgb(*rgb)
ctx.rotate(0.2 + self._rand_rot)
ctx.move_to(50, -50)
detune = "detune: " + str(int(self._detune_prev * 100))
ctx.rgb(0, 0.8, 0)
ctx.rgb(*rgb)
ctx.text(detune)
ctx.rotate(-2.5 * (rot + 4 * self._rand_rot))
ctx.move_to(-50, 50)
if self.fuzz_on:
ctx.rgb(0, 0.8, 0)
ctx.text("fuzz ON!")
else:
ctx.rgb(0, 0.6, 0)
ctx.text("fuzz off")
def think(self, ins: InputState, delta_ms: int) -> None:
super().think(ins, delta_ms)
......@@ -205,8 +234,7 @@ class ShoegazeApp(Application):
if buttons.app.right.pressed:
self.delay_toggle()
if buttons.app.left.pressed:
pass
# self.fuzz_toggle()
self.organ_toggle()
for i in range(1, 10, 2):
if petals[i].whole.pressed:
......@@ -220,30 +248,45 @@ class ShoegazeApp(Application):
if petals[i].whole.pressed:
self._git_string_tuning[k] = self.chord[k] - 12
self.git_strings[k].signals.pitch.tone = self._git_string_tuning[k]
self.git_strings[k].decay = 3000
self.git_strings[k].signals.trigger.start()
self.git_strings[k].signals.pitch.tone = self._git_string_tuning[k] + detune
if petals[0].whole.pressed:
self.bass_string.signals.pitch.tone = self.chord[0] - 24
self.bass_string.decay = 1000
self.bass_string.signals.trigger.start()
leds.set_all_rgb(*colours.hsv_to_rgb(self.hue + self._rand_rot * 1.2, 1, 0.7))
leds.update()
def on_enter(self, vm: Optional[ViewManager]) -> None:
super().on_enter(vm)
leds.set_slew_rate(min(leds.get_slew_rate(), 200))
self._set_chord(self.chord_index, force_update=True)
def on_enter_done(self) -> None:
if self.blm is None:
self._build_synth()
if self.blm is not None: # silly mypy
self.blm.foreground = True
def on_exit(self) -> None:
if self.blm is not None:
self.blm.free = True # yeeting the channel in the backend
self.blm.clear()
self.blm.free = True
self.blm = None
def get_help(self):
ret = (
"Electric guitar simulator with fuzz and reverb. Tilt for wiggle stick. "
"Top petals play notes in chord, bottom petals change chord. App button "
"left turns delay on and off, app button right checks for chords in the "
"savefile of chord organ and toggles between them if found."
)
return ret
# For running with `mpremote run`:
if __name__ == "__main__":
import st3m.run
st3m.run.run_view(ShoegazeApp(ApplicationContext()))
st3m.run.run_app(ShoegazeApp)
[app]
name = "shoegaze"
menu = "Music"
category = "Music"
wifi_preference = false
[entry]
class = "ShoegazeApp"
......
......@@ -9,50 +9,270 @@ from st3m.goose import Tuple, Iterator, Optional, Callable, List, Any, TYPE_CHEC
from ctx import Context
from st3m.ui.view import View, ViewManager
import math
import os
class TinySampler(Application):
def __init__(self, app_ctx: ApplicationContext) -> None:
super().__init__(app_ctx)
self.blm = bl00mbox.Channel("tiny sampler")
self.blm = None
self.file_path = "/sd/tiny_sampler/"
self.samplers: List[bl00mbox.patches._Patch | Any] = [None] * 5
self.line_in = self.blm.new(bl00mbox.plugins.line_in)
self.blm.volume = (
30000 # TODO: increase onboard mic line in gain and remove this
)
self.line_in.signals.gain = 30000
for i in range(5):
self.samplers[i] = self.blm.new(bl00mbox.patches.sampler, 5000)
self.samplers[i].signals.output = self.blm.mixer
self.samplers[i].signals.rec_in = self.line_in.signals.right
self.is_recording = [False] * 5
audio.input_set_source(audio.INPUT_SOURCE_ONBOARD_MIC)
self.is_playing = [False] * 5
self.has_data = [False] * 5
self.has_data_stored = [False] * 5
self.ct_prev = captouch.read()
self._num_modes = 6
self._mode = 0
self.press_event = [False] * 10
self.release_event = [False] * 10
self.playback_speed = [0] * 5
def _check_mode_avail(self, mode):
if mode == 0:
return audio.input_engines_get_source_avail(audio.INPUT_SOURCE_ONBOARD_MIC)
if mode == 1:
return audio.input_engines_get_source_avail(audio.INPUT_SOURCE_LINE_IN)
if mode == 2:
return audio.input_engines_get_source_avail(audio.INPUT_SOURCE_HEADSET_MIC)
return True
@property
def mode(self):
return self._mode
@mode.setter
def mode(self, val):
val = val % self._num_modes
if self.mode == 0:
if val < 3:
direction = 1
else:
direction = -1
elif val == 0:
if self.mode > 3:
direction = 1
else:
direction = -1
elif val > self.mode:
direction = 1
else:
direction = -1
while not self._check_mode_avail(val):
val = (val + direction) % self._num_modes
self._mode = val
def _build_synth(self):
if self.blm is None:
self.blm = bl00mbox.Channel("tiny sampler")
self.blm.volume = 32768
self.samplers = [None] * 5
self.line_in = self.blm.new(bl00mbox.plugins.bl00mbox_line_in)
for i in range(5):
self.samplers[i] = self.blm.new(bl00mbox.plugins.sampler, 1000)
self.samplers[i].signals.playback_output = self.blm.mixer
self.samplers[i].signals.record_input = self.line_in.signals.right
path = self.file_path + "tiny_sample_" + str(i) + ".wav"
if not os.path.exists(path):
path = "/flash/sys/samples/" + "tiny_sample_" + str(i) + ".wav"
try:
self.samplers[i].load(path)
self.has_data[i] = True
except (OSError, bl00mbox.Bl00mboxError) as e:
self.has_data[i] = False
if self.has_data[i]:
self.samplers[i].signals.playback_speed.tone = self.playback_speed[i]
self.has_data_stored[i] = self.has_data[i]
def _highlight_petal(self, num: int, r: int, g: int, b: int) -> None:
for i in range(7):
leds.set_rgb((4 * num - i + 3) % 40, r, g, b)
def draw(self, ctx: Context) -> None:
dist = 90
ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
def draw_two_petal_group(
self, ctx, start=0, stop=5, outer_rad=110, inner_rad=50, offset=10
):
cos72 = 0.31
sin72 = 0.95
cos36 = 0.81
sin36 = 0.59
ctx.save()
ctx.rotate(start * math.tau / 5 - math.tau / 20)
curve = 1.15
for x in range(stop - start):
ctx.move_to(offset, -inner_rad)
ctx.line_to(offset, -outer_rad)
ctx.quad_to(
sin36 * outer_rad * curve,
-cos36 * outer_rad * curve,
sin72 * outer_rad - cos72 * offset,
-cos72 * outer_rad - sin72 * offset,
)
ctx.rotate(math.tau / 5)
ctx.line_to(-offset, -inner_rad)
ctx.quad_to(
-sin36 * inner_rad * curve,
-cos36 * inner_rad * curve,
-sin72 * inner_rad + cos72 * offset,
-cos72 * inner_rad - sin72 * offset,
)
ctx.stroke()
ctx.restore()
ctx.font = ctx.get_font_name(0)
ctx.text_align = ctx.MIDDLE
ctx.font_size = 24
def draw_play_mode(self, ctx):
ctx.save()
dist = 90
ctx.line_width = 5
for i in range(5):
if not self.has_data[i]:
ctx.rgb(0.4, 0.4, 0.4)
elif self.is_playing[i] and audio.input_thru_get_mute():
ctx.rgb(0.2, 0.9, 0.2)
else:
ctx.rgb(0.8, 0.8, 0.8)
ctx.move_to(0, -dist)
ctx.text("play" + str(i))
ctx.rel_line_to(0, -8)
ctx.rel_line_to(11, 8)
ctx.rel_line_to(-11, 8)
ctx.rel_line_to(0, -8)
ctx.fill()
ctx.rotate(6.28 / 10)
if not self.has_data[i]:
ctx.rgb(0.4, 0.4, 0.4)
elif self.is_playing[i] and not audio.input_thru_get_mute():
ctx.rgb(0.2, 0.2, 0.9)
else:
ctx.rgb(0.8, 0.8, 0.8)
ctx.move_to(-7, -dist)
ctx.rel_line_to(0, -8)
ctx.rel_line_to(11, 8)
ctx.rel_line_to(-11, 8)
ctx.rel_line_to(0, -8)
ctx.fill()
ctx.move_to(-7, 12 - dist)
ctx.rel_line_to(11, 0)
ctx.stroke()
ctx.move_to(0, 0)
ctx.move_to(0, 0)
ctx.rotate(6.28 / 10)
ctx.restore()
def draw_rec_mode(self, ctx):
ctx.save()
dist = 90
ctx.line_width = 5
for i in range(5):
if not self.has_data[i]:
ctx.rgb(0.4, 0.4, 0.4)
elif self.is_playing[i]:
ctx.rgb(0.2, 0.9, 0.2)
else:
ctx.rgb(0.8, 0.8, 0.8)
ctx.move_to(0, -dist)
if self.is_recording[i]:
ctx.rel_line_to(0, -8)
ctx.rel_line_to(11, 8)
ctx.rel_line_to(-11, 8)
ctx.rel_line_to(0, -8)
ctx.fill()
ctx.rotate(6.28 / 10)
if not self.is_recording[i]:
ctx.rgb(0.7, 0, 0)
ctx.arc(0, -dist, 6, 0, math.tau, 1)
ctx.stroke()
else:
ctx.rgb(1, 0, 0)
ctx.text("rec" + str(i))
ctx.arc(0, -dist, 9, 0, math.tau, 1)
ctx.fill()
ctx.move_to(0, 0)
ctx.rotate(6.28 / 10)
ctx.restore()
def draw_save_mode(self, ctx):
ctx.save()
dist = 80
text_shift = 5
ctx.font = "Comic Mono"
ctx.text_align = ctx.MIDDLE
ctx.font_size = 18
rot_over = 6.28 / 50
ctx.rotate(-(6.28 / 4) + rot_over)
for i in range(5):
if not self.has_data_stored[i]:
ctx.rgb(0.4, 0.4, 0.4)
else:
ctx.rgb(0.8, 0.8, 0.8)
ctx.move_to(dist, text_shift)
ctx.text("load")
ctx.rotate(6.28 / 10 - 2 * rot_over)
if not self.has_data[i]:
ctx.rgb(0.4, 0.4, 0.4)
else:
ctx.rgb(0.8, 0.8, 0.8)
ctx.move_to(dist, text_shift)
ctx.text("save")
ctx.move_to(0, 0)
ctx.rotate(6.28 / 10 + 2 * rot_over)
ctx.restore()
def draw_pitch_mode(self, ctx):
ctx.save()
dist = 80
text_shift = 5
ctx.font = "Comic Mono"
ctx.text_align = ctx.MIDDLE
ctx.font_size = 25
ctx.move_to(0, 5)
ctx.rgb(0.8, 0.8, 0.8)
ctx.text("pitch")
ctx.rotate(-(6.28 / 4) + (6.28 / 20))
if self.blm is not None:
for i in range(5):
ctx.move_to(dist, text_shift)
ctx.text(str(int(self.samplers[i].signals.playback_speed.tone)))
ctx.move_to(0, 0)
ctx.rotate(6.28 / 5)
ctx.restore()
def draw(self, ctx: Context) -> None:
ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
ctx.rgb(0.1, 0.5, 0.6)
ctx.line_width = 6
self.draw_two_petal_group(ctx, stop=5)
if self.mode < 3:
self.draw_rec_mode(ctx)
if self.mode == 3:
self.draw_play_mode(ctx)
elif self.mode == 4:
self.draw_save_mode(ctx)
elif self.mode == 5:
self.draw_pitch_mode(ctx)
ctx.text_align = ctx.CENTER
ctx.gray(0.8)
ctx.font_size = 18
if self.mode == 0:
ctx.move_to(0, 0)
ctx.text("onboard")
ctx.move_to(0, ctx.font_size)
ctx.text("mic")
elif self.mode == 1:
ctx.move_to(0, ctx.font_size / 2)
ctx.text("line in")
elif self.mode == 2:
ctx.move_to(0, 0)
ctx.text("headset")
ctx.move_to(0, ctx.font_size)
ctx.text("mic")
def think(self, ins: InputState, delta_ms: int) -> None:
super().think(ins, delta_ms)
......@@ -65,26 +285,188 @@ class TinySampler(Application):
self._highlight_petal(i * 2, 0, 255, 0)
leds.update()
if self.blm is None:
return
if self.input.buttons.app.left.pressed:
self.mode -= 1
if self.mode == 3:
self.mode -= 1
release_all = True
elif self.input.buttons.app.right.pressed:
self.mode += 1
if self.mode == 3:
self.mode += 1
release_all = True
else:
release_all = False
ct = captouch.read()
self.press_event = [False] * 10
if release_all:
self.release_event = [True] * 10
else:
self.release_event = [False] * 10
for i in range(10):
if ct.petals[i].pressed and not self.ct_prev.petals[i].pressed:
self.press_event[i] = True
elif not ct.petals[i].pressed and self.ct_prev.petals[i].pressed:
self.release_event[i] = True
if self.mode == 0:
audio.input_engines_set_source(audio.INPUT_SOURCE_ONBOARD_MIC)
elif self.mode == 1:
audio.input_engines_set_source(audio.INPUT_SOURCE_LINE_IN)
elif self.mode == 2:
audio.input_engines_set_source(audio.INPUT_SOURCE_HEADSET_MIC)
else:
audio.input_engines_set_source(audio.INPUT_SOURCE_NONE)
if self.mode < 3 or release_all:
for i in range(5):
if ct.petals[i * 2].pressed and not self.ct_prev.petals[i * 2].pressed:
if not self.is_recording[i]:
self.samplers[i].signals.trigger.start()
if self.press_event[i * 2]:
self.samplers[i].signals.playback_trigger.start()
self.is_playing[i] = True
audio.input_thru_set_mute(True)
if self.release_event[i * 2]:
self.samplers[i].signals.playback_trigger.stop()
self.is_playing[i] = False
audio.input_thru_set_mute(False)
for i in range(5):
if (
ct.petals[(i * 2) + 1].pressed
and not self.ct_prev.petals[(i * 2) + 1].pressed
):
if self.press_event[i * 2 + 1]:
if not self.is_recording[i]:
self.samplers[i].signals.rec_trigger.start()
self.samplers[i].sample_rate = 12000
self.samplers[i].signals.record_trigger.start()
self.is_recording[i] = True
if (
not ct.petals[(i * 2) + 1].pressed
and self.ct_prev.petals[(i * 2) + 1].pressed
):
if self.mode == 0:
audio.input_thru_set_mute(True)
if self.release_event[i * 2 + 1]:
if self.is_recording[i]:
self.samplers[i].signals.rec_trigger.stop()
self.samplers[i].signals.record_trigger.stop()
self.is_recording[i] = False
self.has_data[i] = True
if self.mode == 0:
audio.input_thru_set_mute(False)
if self.mode == 3 or release_all:
for i in range(5):
if self.press_event[i * 2]:
self.samplers[i].signals.playback_trigger.start()
self.is_playing[i] = True
audio.input_thru_set_mute(True)
if self.release_event[i * 2]:
self.samplers[i].signals.playback_trigger.stop()
self.is_playing[i] = False
audio.input_thru_set_mute(False)
for i in range(5):
if self.press_event[i * 2 + 1]:
self.samplers[i].signals.playback_trigger.start()
self.is_playing[i] = True
if self.release_event[i * 2 + 1]:
self.samplers[i].signals.playback_trigger.stop()
self.is_playing[i] = False
elif self.mode == 4 or release_all:
for i in range(5):
if self.press_event[i * 2]:
path = self.file_path + "tiny_sample_" + str(i) + ".wav"
if not os.path.exists(path):
path = "/flash/sys/samples/" + "tiny_sample_" + str(i) + ".wav"
if os.path.exists(path):
try:
self.samplers[i].load(path)
self.has_data[i] = True
except (OSError, bl00mbox.Bl00mboxError) as e:
self.has_data_stored[i] = False
for i in range(5):
if self.press_event[i * 2 + 1]:
if self.has_data[i]:
try:
if not os.path.exists(self.file_path):
os.mkdir(self.file_path)
path = self.file_path + "tiny_sample_" + str(i) + ".wav"
print("saving at: " + path)
self.samplers[i].save(path)
self.has_data_stored[i] = True
except (OSError, bl00mbox.Bl00mboxError) as e:
print("failed")
self.has_data_stored[i] = False
elif self.mode == 5 or release_all:
for i in range(5):
if self.press_event[i * 2]:
self.samplers[i].signals.playback_speed.tone += 1
self.playback_speed[i] = self.samplers[
i
].signals.playback_speed.tone
self.samplers[i].signals.playback_trigger.start()
self.is_playing[i] = True
if self.release_event[i * 2]:
self.samplers[i].signals.playback_trigger.stop()
self.is_playing[i] = False
for i in range(5):
if self.press_event[i * 2 + 1]:
self.samplers[i].signals.playback_speed.tone -= 1
self.playback_speed[i] = self.samplers[
i
].signals.playback_speed.tone
self.samplers[i].signals.playback_trigger.start()
self.is_playing[i] = True
if self.release_event[i * 2 + 1]:
self.samplers[i].signals.playback_trigger.stop()
self.is_playing[i] = False
self.ct_prev = ct
def on_enter(self, vm) -> None:
super().on_enter(vm)
self.orig_source = audio.input_engines_get_source()
self.orig_thru_mute = audio.input_thru_get_mute()
self._mode = self._num_modes - 1
self.mode = 0
if self.blm is None:
self._build_synth()
def on_exit(self) -> None:
audio.input_engines_set_source(self.orig_source)
audio.input_thru_set_mute(self.orig_thru_mute)
for i in range(5):
if self.is_recording[i]:
self.samplers[i].signals.record_trigger.stop()
self.is_recording[i] = False
if self.blm is not None:
self.blm.clear()
self.blm.free = True
self.blm = None
def get_help(self):
ret = (
"5-slot sampler. Each slot records up to 4s at 12kHz. There are 4 page "
"groups, cycle through the pages with left/right on the app button: \n\n"
"Record and Play (default): Hold the bottom petals to record samples "
"into their respective slots. Hold the corresponding top petals as "
"indicated by the screen to replay them. This mode is actually up to 3 "
"pages depending on how many input sources are available, such as "
"headset mic or line in. If you experience clipping or very low signal "
"you might wanna try to readjust input gain in the audio settings for "
"the respective source.\n\n"
"Play with/without passthrough: If you have global passthrough for any "
"source enabled one of the play buttons mutes passthrough while the "
"button is pressed.\n\n"
"Save and Load: Tap a bottom petal to save a sample into the SD card if "
"recording is available in the slot. Tap a top petal to load the "
"corresponding sample from the SD card if available there. Not "
"functioning if there is no SD card.\n\n"
"Pitch Shift: Tap a top petal to increase replay speed of the sample by "
"a semitone, tap a bottom one to decrease it. You can also hold the "
"petals to directly replay the results.\n\n"
"Samples are saved at /sd/tiny_sampler/tiny_sample_*.wav. "
"Note for developers: This is not a good save file location and will be "
"moved in the future, see documentation."
)
return ret
# For running with `mpremote run`:
if __name__ == "__main__":
import st3m.run
st3m.run.run_app(TinySampler)
[app]
name = "tiny sampler"
menu = "Music"
category = "Music"
[entry]
class = "TinySampler"
......
from st3m.application import Application, ApplicationContext
from st3m.input import InputState
from st3m.goose import Optional
from st3m.utils import sd_card_plugged, sd_card_unreliable
from ctx import Context
import sys_kernel
import urequests
import math
import sys
import re
from st3m.ui.view import ViewManager
import st3m.wifi
class UpdaterApp(Application):
def __init__(self, app_ctx: ApplicationContext) -> None:
super().__init__(app_ctx)
def draw(self, ctx: Context) -> None:
ctx.text_align = ctx.CENTER
ctx.text_baseline = ctx.MIDDLE
ctx.line_width = 3
ctx.font = ctx.get_font_name(1)
ctx.rgb(0, 0, 0).rectangle(-120, -120, 240, 240).fill()
ctx.rgb(1, 1, 1)
if self.download_percentage:
ctx.save()
ctx.rotate(270 * math.pi / 180)
ctx.arc(
0, 0, 117, math.tau * (1 - self.download_percentage), math.tau, 0
).stroke()
ctx.restore()
ctx.font_size = 15
ctx.move_to(0, -90)
ctx.text("you are running")
if len(self._firmware_version) > 10:
ctx.font_size = 18
else:
ctx.font_size = 25
ctx.move_to(0, -70)
ctx.text(self._firmware_version)
ctx.font_size = 15
state_lines = self._state_text.split("\n")
y_offset = max(-((len(state_lines) - 1) * 15 / 2), -30)
for line in self._state_text.split("\n"):
ctx.move_to(0, y_offset)
ctx.text(line)
y_offset += 15
ctx.gray(0.75)
if (
not st3m.wifi.is_connected()
and not st3m.wifi.is_connecting()
and not self.fetched_version
and not self.vm.transitioning
):
ctx.move_to(0, 45)
ctx.text("press the app button to")
ctx.move_to(0, 60)
ctx.text("enter Wi-Fi settings")
def version_to_number(self, version_raw: str):
if "dev" in version_raw:
return 0
version_re = re.search("[0-9]+.[0-9]+.[0-9]+", version_raw)
if not version_re:
return 0
major, minor, patch = version_re.group(0).split(".")
try:
version_number = (int(major) * 1000000) + (int(major) * 1000) + int(patch)
except ValueError:
return 0
return version_number
def on_enter(self, vm: Optional[ViewManager]):
super().on_enter(vm)
self._firmware_version = sys_kernel.firmware_version()
self._firmware_version_number = self.version_to_number(self._firmware_version)
self.fetched_version = []
self.selected_version = None
self.download_instance = None
self.filename = None
self.use_dev_version = False
self.download_percentage = 0
self._download_error = False
self._sd_present = sd_card_plugged()
self._sd_failed = False
if self._sd_present:
self._state_text = "getting latest version...\n\n(check your connection\nif you're stuck here)"
if not st3m.wifi.enabled() or (
not st3m.wifi.is_connected() and not st3m.wifi.is_connecting()
):
self._state_text = "no connection"
else:
self._state_text = "no SD card detected!\n\nif you have one in there\nturn off and on flow3r power (ha)\nthen try to reattempt\ndownloading the update"
def on_exit(self) -> None:
super().on_exit()
def download_file(self, url: str, path: str, block_size=40960) -> None:
path_fd = None
try:
path_fd = open(path, "wb")
except OSError as e:
if "EIO" in str(e):
self._sd_failed = True
return
req = urequests.get(url)
total_size = int(req.headers["Content-Length"])
try:
while True:
new_data = req.raw.read(block_size)
path_fd.write(new_data)
yield path_fd.tell(), total_size
if len(new_data) < block_size:
break
except OSError as e:
if "EIO" in str(e):
self._sd_failed = True
finally:
path_fd.close()
req.close()
def download_file_no_yield(self, url: str, path: str, block_size=10240) -> None:
req = urequests.get(url)
print("opened url")
path_fd = open(path, "wb")
print("opened file")
while True:
new_data = req.raw.read(block_size)
path_fd.write(new_data)
print(path_fd.tell())
if len(new_data) < block_size:
break
path_fd.close()
print("closed file")
req.close()
print("closed req")
def change_selected_version(self):
if self.fetched_version and self.use_dev_version:
self.selected_version = self.fetched_version[-1]
self._state_text = "latest dev build\n\npress app shoulder button\nto start downloading\n\n(tilt shoulder left to\nswitch to latest version)"
elif self.fetched_version:
self.selected_version = self.fetched_version[0]
latest_version_number = self.version_to_number(self.selected_version["tag"])
self._state_text = f"latest version: \n{self.selected_version['name']}"
if latest_version_number > self._firmware_version_number:
self._state_text += (
"\n\npress app shoulder button\nto start downloading"
)
else:
self._state_text += "\n\nyou are up to date :)"
self._state_text += "\n\n(tilt shoulder right to\nswitch to dev version)"
def think(self, ins: InputState, delta_ms: int) -> None:
super().think(ins, delta_ms)
# TODO: verify hash
if self.input.buttons.app.right.pressed:
self.use_dev_version = True
self.change_selected_version()
elif self.input.buttons.app.left.pressed:
self.use_dev_version = False
self.change_selected_version()
if not self._sd_present:
return
if sd_card_unreliable():
self._state_text = (
"Your SD card model is\n"
"known to cause errors.\n"
"It is recommended to swap it!\n"
"Flash firmware via USB instead\nhttps://flow3r.garden/flasher"
)
return
if self._sd_failed:
self._state_text = (
"don't panic, but...\na weird SD bug happened D:\nturn off and on flow3r power (ha)\n"
"and retry. Please report the \nSD IDs in the About menu to the team\n\n"
"if this error repeats try\nhttps://flow3r.garden/flasher\ninstead!"
)
return
if not st3m.wifi.is_connected() and not st3m.wifi.is_connecting():
if self.input.buttons.app.middle.pressed:
st3m.wifi.run_wifi_settings(self.vm)
return
if (
not self.fetched_version
and st3m.wifi.is_connected()
and not self.vm.transitioning
and not self._download_error
):
try:
req = urequests.get("https://flow3r.garden/api/releases.json")
self.fetched_version = req.json()
req.close()
self.change_selected_version()
except Exception as e:
self._state_text = "download error :("
self._download_error = True
sys.print_exception(e)
if self.download_instance is not None:
try:
download_state, total_size = next(self.download_instance)
self.download_percentage = download_state / total_size
self._state_text = f"downloading...\nDON'T TURN OFF YOUR BADGE!\n\n{download_state}/{total_size}b"
except StopIteration:
self.download_instance = None
self.download_percentage = 0
self._state_text = f'downloaded to\n{self.filename}\n\nhold right shoulder when\nbooting then pick\n"Flash Firmware Image"\nto continue'
if self.selected_version and self.input.buttons.app.middle.pressed:
self._state_text = "downloading...\nDON'T TURN OFF YOUR BADGE!\n\n"
url = ""
for partition in self.selected_version["partitions"]:
if partition["name"] == "flow3r":
url = partition["url"]
break
self.filename = f"/sd/flow3r_{self.selected_version['tag']}.bin"
self.download_instance = self.download_file(url, self.filename)
# For running with `mpremote run`:
if __name__ == "__main__":
import st3m.run
st3m.run.run_view(UpdaterApp(ApplicationContext()))
[app]
name = "Check for Updates"
category = "Hidden"
wifi_preference = true
[entry]
class = "UpdaterApp"
[metadata]
author = "ave"
license = "LGPL-3.0-only"
url = "https://git.flow3r.garden/flow3r/flow3r-firmware"
version = 1
import captouch
import bl00mbox
import leds
import cmath
import math
from st3m.application import Application
from st3m.ui import led_patterns, widgets
# i stole a waveform from wikipedia and all i got was this lousy waveform
# fmt: off
wavetable_G = [ -31908, -31214, -29327, -25617, -19835, -13173, -5860, 430, 6178, 9928, 10517, 8145, 5175, 5247, 9082,
14845, 21967, 27964, 31532, 32686, 31010, 27298, 23469, 19145, 16123, 12363, 7738, 4198, 1774, 470,
-1782, -5034, -8392, -9682, -7834, -4587, 384, 5137, 8867, 9100, 5054, -1239, -7294, -11015, -11725,
-11018, -10495, -10393, -11481, -13339, -15281, -17068, -18206, -18616, -18261, -17259, -16580, -14969,
-13589, -11777, -9421, -6406, -3500, -622, 190, 275, 1082, 3731, 6917, 10332, 13113, 15365, 15551, 13645,
9494, 4473, 822, -571, -225, 3121, 6186, 8317, 8741, 8079, 5348, 2070, -961, -2887, -3492, -1762, 154,
2427, 3639, 3447, 1661, 836, 1407, 2701, 4776, 5851, 6284, 5217, 3111, 1050, -478, -2179, -3504, -5057,
-7776, -10438, -9483, -2039, 7867, 14062, 14379, 11906, 9318, 7723, 6459, 5114, 5220, 5800, 5678, 2184,
-5499, -14483, -22161, -27325, -29727]
wavetable_A = [ -32767, -31475, -30284, -28603, -26239, -24215, -23129, -21511, -19390, -17493, -16263, -15358, -14344,
-12856, -10677, -8105, -4704, -682, 3135, 7347, 12576, 17683, 21531, 24140, 26388, 27561, 27630, 27327,
25666, 22949, 19891, 17256, 13853, 10037, 6571, 3688, 843, -355, -869, -792, -50, 3607, 7571, 11713,
15976, 20482, 24073, 27454, 30329, 31943, 31778, 30676, 28887, 26673, 24447, 21763, 19467, 17814, 16826,
16464, 16516, 17115, 18404, 20744, 23196, 25173, 26214, 25629, 23905, 20921, 16930, 12316, 7937, 4004,
385, -2880, -5435, -6997, -7843, -7838, -6809, -5498, -4290, -3957, -5058, -8246, -11763, -14893, -17610,
-20118, -22048, -22408, -21980, -21133, -18463, -14705, -11003, -7341, -3085, -1132, 173, 569, -268,
-3059, -5441, -8344, -11743, -14770, -16139, -16539, -16075, -14852, -13130, -11212, -9252, -7450, -6252,
-5424, -5269, -5695, -6513, -7898, -10547, -13393, -16203, -20419, -25175, -28446, -30443]
# fmt: on
background_color = (0, 0, 0)
default_color = (0.7, 0.7, 0.7)
active_string_color = (0, 0.5, 1)
target_string_color = (0, 1, 0)
class WavetableOsc(bl00mbox.Patch):
def __init__(self, chan, curve):
super().__init__(chan)
osc = self.new(bl00mbox.plugins.osc)
osc.antialiasing = False
osc.signals.waveform.switch.SAW = True
wave = self.new(bl00mbox.plugins.distortion)
wave.signals.input << osc.signals.output
self._wave = wave
self.signals.pitch = osc.signals.pitch
self.signals.output = wave.signals.output
self.set_curve_normalized(curve)
def set_curve_normalized(
self, curve, autobias=True, autoscale=True, suggest_curve=True
):
ref_curve = curve
# for audio we want the average of the waveform to be 0. if fed by a saw or triangle wave,
# this directly transfers to the average of the wavetable, so we remove it.
# if you switch up the waveform of the oscillator this doesn't hold true and you
# must remove DC by other means.
if autobias:
avg = sum(curve) / len(curve)
curve = [x - avg for x in curve]
# scale it to the max. must be done after zeroing average.
# maybe RMS would be smarter here but then we'd have to set an expected crest ratio
# and all that and we're not gonna do that :3
if autoscale:
max_abs = max([abs(x) for x in curve])
curve = [int(32767 * x / max_abs) for x in curve]
self._wave.curve = curve
# if you save your wavetable somewhere (as we do at the top of the file), you might as well
# save it as it is actually applied. if there's a difference this option prints it out
# on the REPL so that you can paste it in your code file.
# we already did that here, so we could call this with all optional arguemnts False to make
# loading a teeny tiny bit faster, but since we expect people to copy-paste this code and
# change up the wavetable we are not doing this here. but we could.
if suggest_curve:
curve = self._wave.curve
already_good = False
if len(ref_curve) == len(curve):
already_good = any(
[curve[x] != ref_curve[x] for x in range(len(curve))]
)
if not already_good:
print("curve was resampled/-scaled/-biased, try this:")
print(self._wave.curve)
class ViolinPatch(bl00mbox.Patch):
def __init__(self, chan):
super().__init__(chan)
mp = self.new(bl00mbox.plugins.multipitch, 1)
lo_osc = self.new(WavetableOsc, wavetable_G)
lo_osc.signals.pitch << mp.signals.thru
hi_osc = self.new(WavetableOsc, wavetable_A)
hi_osc.signals.pitch << mp.signals.thru
mixer = self.new(bl00mbox.plugins.mixer, 2)
mixer.signals.input[0] << hi_osc.signals.output
mixer.signals.input[1] << lo_osc.signals.output
prs = [self.new(bl00mbox.plugins.range_shifter) for x in range(2)]
prs[1].signals.input << prs[0].signals.output
for x in range(2):
pr = prs[x]
mixer.signals.input_gain[x] << pr.signals.output
if x:
pr.signals.input_range[0] = 0
pr.signals.input_range[1] = 4096
pr.signals.output_range[0] = 4096
pr.signals.output_range[1] = 0
mixer.signals.gain.mult = 0
lp = self.new(bl00mbox.plugins.filter)
lp.signals.cutoff.freq = 8000
mixer.signals.output >> lp.signals.input
# we were thinking about this for a while: wouldn't it be cool to just
# directly drive pitch bend from from user input instead of having this
# pesky osc in-between? it sounds good on paper, but turns out u can't
# switch up ur curl that quickly. how about modulo on the phase of position?
# ...interesting, we'd have to chase the average tilt of the wiggle, but also
# to control low wiggle sensitivity we'd probs need to adjust field on the fly
# or smth... it an amount of math in there. and this rigging works alright
# we think.
# about waveform: our widget vibrato output is unstable enough that we can get
# free random AM/FM from it, so that's cool
vib_osc = self.new(bl00mbox.plugins.osc)
vib_osc.speed = "lfo"
vib_osc.signals.waveform.switch.SINE = True
vib_osc.signals.pitch.freq = 5
mp.signals.mod_in << vib_osc.signals.output
mp.signals.mod_sens = 0
env = self.new(bl00mbox.plugins.env_adsr)
env.signals.input << lp.signals.output
env.signals.attack = 150
env.signals.release = 200
env.signals.sustain = 32767
self.signals.vibrato_speed = vib_osc.signals.pitch
self.signals.vibrato_depth = mp.signals.mod_sens
self.signals.volume = mixer.signals.gain
self.signals.timbre = prs[0].signals.input
self.signals.pitch = mp.signals.input
self.signals.output = env.signals.output
self.signals.trigger = env.signals.trigger
self.signals.env_gain = env.signals.env_output
class ViolinReverb(bl00mbox.Patch):
def __init__(self, chan):
super().__init__(chan)
num_delays = 4
delays = [self.new(bl00mbox.plugins.delay_static) for x in range(num_delays)]
mixers = [
self.new(bl00mbox.plugins.mixer, num_delays) for x in range(num_delays)
]
buffer = self.new(bl00mbox.plugins.mixer, 1)
feedback_buffer = self.new(bl00mbox.plugins.mixer, 1)
buffer.signals.output >> delays[0].signals.input
self.signals.input = buffer.signals.input[0]
self.signals.output = mixers[0].signals.output
delays[0].signals.feedback = 0
delays[0].signals.dry_vol = 0
for x in range(1, num_delays):
delays[x].signals.input << mixers[x].signals.output
delays[x].signals.output >> mixers[0].signals.input[x]
mixers[x].signals.input[0] << delays[0].signals.output
for j in range(1, num_delays):
delays[j].signals.output >> mixers[x].signals.input[j]
delays[x].signals.feedback = 0
delays[x].signals.dry_vol = 0
delays[x].signals.time = min(30 * (x ** (3 / 2)), 500)
mixers[x].signals.gain << feedback_buffer.signals.output
mixers[0].signals.input[0] << buffer.signals.output
mixers[0].signals.gain.mult = 1
delays[0].signals.time = 100
delays[0].signals.level = 0
self.signals.volume = delays[0].signals.level
# not great, be careful of oscillations
self.signals.feedback = feedback_buffer.signals.input[0]
feedback_buffer.signals.input[0] = 4096 * 0.4
feedback_buffer.signals.gain.mult = -1
def sqabs(val):
return val.real * val.real + val.imag * val.imag
class ViolinWidget(widgets.PetalWidget):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.ring_len = 16
self.min_len = 3
self.min_log_len = 2
self.max_log_len = 6
self.avg_pos = None
self.curls = [0.0] * self.ring_len
self.sqvels = [0.0] * self.ring_len
self._autoclear()
def think(self, ins, delta_ms):
log = ins.captouch.petals[self.petal].log
if not log:
return
for frame in log:
if not self._append_and_validate(frame):
continue
if self._log.length() < self.min_log_len:
continue
self._log.crop(-self.max_log_len)
pos = self._log.frames[-1].pos
prevpos = self._log.frames[-2].pos
# we're not using this timestamp as a negative cpu load
# random noise modulation load :> units are whatever
vel = pos - prevpos
pos = (pos + prevpos) / 2
if self.avg_pos is None:
self.avg_pos = pos
else:
self.avg_pos += (pos - self.avg_pos) * 0.1
pos -= self.avg_pos
sqvel = sqabs(vel)
self.sqvels[self.ring_index] = sqvel
curl = 0
if pos:
# cross product to measure angle between movement
# and
curl = pos.real * vel.imag - pos.imag * vel.real
# it kinda helps for low amplitude circles
curl /= abs(pos)
# getting rid of sign here but also squaring it.
# curls need a lot of squaring kinda.
# but if u wanna not square do put an abs in there
self.curls[self.ring_index] = curl * curl
if self.num_entries < self.ring_len:
self.num_entries += 1
self.ring_index += 1
self.ring_index %= self.ring_len
self.active = self.num_entries >= self.min_len
if self.active:
curl = 0
sqvel = 0
for i in range(self.num_entries):
curl += self.curls[i]
sqvel += self.sqvels[i]
sqvel = sqvel / self.ring_len
curl /= self.num_entries
if sqvel:
# random thoughtless curve mangling, it feels alright ig?
# it is questionable whether this should be before the filter instead.
# but it's here, for now, and that's good enough ^w^
curl /= sqvel * 4 + 0.2
curl /= sqvel * 2 + 0.6
else:
curl = 0
# eyeballin it
# you'll probably have to change gain here if u make adjustments to
# the math above, just print values while interacting
sqvel *= 4
curl *= 7
curl *= curl
self.volume = min(sqvel, 1)
self.vibrato = min(curl, 1)
def on_exit(self):
super().on_exit()
self._autoclear()
def _autoclear(self):
self.avg_pos = None
self.num_entries = 0
self.ring_index = 0
self.volume = 0
self.vibrato = 0
class App(Application):
def __init__(self, app_ctx) -> None:
super().__init__(app_ctx)
self.captouch_conf = captouch.Config.default()
# we wamt equal spin times on top and bottom so that we don't have to deal
# with behavioral differences based on sample rate
self.captouch_conf.petals[1].mode = 1
self.violin_widgets = [
ViolinWidget(self.captouch_conf, petal) for petal in range(0, 10, 2)
]
self.reverb_widget = widgets.Slider(self.captouch_conf, 5)
self.tilt_widget = widgets.Inclinometer(buffer_len=8)
self.widgets = self.violin_widgets + [self.reverb_widget, self.tilt_widget]
# index of the widget we consider active. [0..4] or None.
self.active_widget_index = None
# which string we are targeting
self.target_string = 1
# same but continuous
self.target_string_unfloored = 1
# fret corresponding to active_widget_index
self.fret = 4
self.string = None
self.pitch = None
# string that is targeted when setting tilt reference.
# lowest string is 0, highest is 3
self.ref_string = 2
# stores tilt reference
self.ref_tilt = 0
# angular tilt distance between strings, in radians (here 12 degrees)
self.tilt_spacing = 12 * math.tau / 360
# tilt hysteresis in units of tilt_spacing, i.e. 0.2*12deg = 2.4deg
self.tilt_hysteresis = 0.2
self.bass = 1
self.app_is_left = None
# some helper storage for drawing
self.draw_sin = 0
self.full_redraw = True
self.always_full_redraw = False
self.reverb_volume = 0.1
self.show_reverb_bar = False
self.volume = 0
self.vibrato = 0
def _build_synth(self):
self.blm = bl00mbox.Channel("violin")
self.synth = self.blm.new(ViolinPatch)
self.reverb = self.blm.new(ViolinReverb)
self.synth.signals.output >> self.reverb.signals.input
self.blm.signals.line_out << self.reverb.signals.output
def draw(self, ctx) -> None:
ctx.text_align = ctx.CENTER
ctx.text_baseline = ctx.IDEOGRAPHIC
ctx.font = "Comic Mono"
if self.always_full_redraw:
self.full_redraw = True
if self.full_redraw:
ctx.rgb(*background_color).rectangle(-120, -120, 240, 240).fill()
ctx.rgb(*default_color)
num_holes = max(self.bass + 1, 1)
f_hole = "f" * num_holes
width = 90 - 3 * (3 - num_holes)
for x in range(2):
ctx.save()
if x:
ctx.apply_transform(-1, 0, 0, 0, 1, 0, 0, 0, 1)
ctx.translate(-width, 20)
ctx.move_to(1, 2)
ctx.rotate(-0.2)
if self.bass == -1:
ctx.rel_move_to(1, 6)
ctx.scale(0.8, 1)
ctx.font_size = 32
ctx.text(f_hole)
ctx.move_to(0, 0)
ctx.rotate(math.pi)
if self.bass == -1:
ctx.rel_move_to(1, 6)
ctx.text(f_hole)
ctx.restore()
else:
ctx.rgb(*background_color).rectangle(-120, 75, 240, 10).fill()
ctx.rgb(*background_color).rectangle(-120, -105, 240, 10).fill()
pos = 63
ctx.rgb(*background_color).rectangle(-pos, -120, pos * 2, 195).fill()
ctx.save()
ctx.translate(0, 100)
size = 30
if self.full_redraw:
ctx.line_width = 2
ctx.rgb(*default_color)
if self.show_reverb_bar:
for x in range(2):
angle = 0.045
rad = 20
pos = size + 10 - rad # + x * 5
ctx.arc(
-pos,
0,
rad + x * 5,
(0.5 - angle) * math.tau,
(0.5 + angle) * math.tau,
0,
).stroke()
ctx.arc(
pos,
0,
rad + x * 5,
(1 - angle) * math.tau,
(1 + angle) * math.tau,
0,
).stroke()
else:
ctx.move_to(-size - 10, -6)
ctx.rel_line_to(0, 11)
ctx.rel_curve_to(-5, -2, -5, -7, 0, -5)
ctx.stroke()
ctx.save()
ctx.translate(size + 10 - 1, 0)
ctx.move_to(3, -6)
ctx.rel_line_to(0, 11).stroke()
ctx.move_to(0, -2).rel_line_to(6, -1).stroke()
ctx.move_to(0, 2).rel_line_to(6, -1).stroke()
ctx.restore()
padding = 4
ctx.round_rectangle(
-size - padding,
-5 - padding,
(size + padding) * 2,
(5 + padding) * 2,
3 + padding - 1,
).fill()
ctx.rgb(*background_color)
padding = 2
ctx.round_rectangle(
-size - padding,
-5 - padding,
(size + padding) * 2,
2 * (5 + padding),
3 + padding + 1,
).fill()
ctx.rgb(*default_color)
if self.show_reverb_bar:
ctx.round_rectangle(-size, -5, self.reverb_volume * 2 * size, 10, 3).fill()
else:
ctx.round_rectangle(
-self.vibrato * size, -5, self.vibrato * size * 2, 10, 3
).fill()
ctx.restore()
if self.app_is_left is not None:
string_spacing = 30
if not self.app_is_left:
string_spacing = -string_spacing
target_pos = (self.target_string_unfloored - 2) * string_spacing
ctx.rgb(*target_string_color)
ctx.arc(target_pos, 80, 3, 0, math.tau, 0).fill()
ctx.arc(target_pos, -100, 3, 0, math.tau, 0).fill()
for x in range(4):
ctx.move_to((x - 1.5) * string_spacing, -90)
ctx.line_width = 5 - x
if x == self.string:
ctx.rgb(*active_string_color)
# hey we could just take the output of the synthesizer here, wouldn't
# that be much better than this fake sine?
# ... yyyeah it doesn't really look all that good. sad. :'(
self.draw_sin %= math.tau
amplitude = 25 * self.volume * math.sin(self.draw_sin)
amplitude *= self.synth.signals.env_gain.mult
number = 3 + self.fret
quad_len = 160 / number
for _ in range(number):
amplitude *= -1
ctx.rel_quad_to(amplitude, quad_len / 2, 0, quad_len)
else:
ctx.rgb(*default_color)
ctx.rel_line_to(0, 160)
if x == self.target_string:
ctx.rgb(*target_string_color)
ctx.stroke()
self.full_redraw = False
def think(self, ins, delta_ms) -> None:
super().think(ins, delta_ms)
self.app_is_left = ins.buttons.app_is_left
for widget in self.widgets:
widget.think(ins, delta_ms)
self.draw_sin += delta_ms / 20
bass = self.bass
bass += self.input.captouch.petals[1].whole.pressed
bass += self.input.captouch.petals[9].whole.pressed
bass -= self.input.captouch.petals[3].whole.pressed
bass -= self.input.captouch.petals[7].whole.pressed
bass = max(-1, min(2, bass))
if bass != self.bass:
self.bass = bass
self.full_redraw = True
tilt = self.tilt_widget.roll
if tilt is not None:
if ins.buttons.app == 2 or self.ref_tilt is None:
self.ref_tilt = tilt
tilt = None
else:
tilt -= self.ref_tilt
tilt_spacing = self.tilt_spacing
if not self.app_is_left:
tilt_spacing = -tilt_spacing
string = tilt / tilt_spacing + 0.5 + self.ref_string
predicted_string = math.floor(string)
if predicted_string > self.target_string:
string -= self.tilt_hysteresis
elif predicted_string < self.target_string:
string += self.tilt_hysteresis
self.target_string_unfloored = string
self.target_string = max(0, min(3, math.floor(string)))
active_widget_index = None
volume = 0
self.vibrato = 0
for x, widget in enumerate(self.violin_widgets):
if widget.volume > volume:
volume = widget.volume
self.volume = volume
self.vibrato = widget.vibrato
active_widget_index = x
new_note_happened = False
if self.active_widget_index != active_widget_index:
self.active_widget_index = active_widget_index
if active_widget_index is None:
self.string = None
self.synth.signals.trigger.stop()
else:
self.string = self.target_string
self.fret = 4 - self.active_widget_index
self.pitch = self.fret + self.string * 5 - self.bass * 12 - 14
self.synth.signals.pitch.tone = self.pitch
self.synth.signals.trigger.start()
new_note_happened = True
self.synth.signals.volume.value = 1000 * self.volume
if self.string is not None:
self.synth.signals.vibrato_speed.freq = self.vibrato * 4 + 3
self.synth.signals.vibrato_depth.value = 4096 * self.vibrato / 24
# TODO: this isn't very nice or cover much range
timbre = (19 - self.pitch) / 19
timbre = max(-1, min(1, timbre * 2 - 1))
self.synth.signals.timbre.value = 32767 * timbre
if self.reverb_widget.active != self.show_reverb_bar:
self.full_redraw = True
self.show_reverb_bar = self.reverb_widget.active
if self.reverb_widget.active:
self.reverb_volume = (1 - self.reverb_widget.pos.real) / 2
self.reverb.signals.volume.value = self.reverb_volume * 30000
if new_note_happened:
led_patterns.pretty_pattern()
leds.update()
def on_enter(self, vm):
super().on_enter(vm)
for widget in self.widgets:
widget.on_enter()
self.ref_tilt = None
self.string = None
self._build_synth()
self.captouch_conf.apply()
led_patterns.pretty_pattern()
leds.update()
self.always_full_redraw = True
self.full_redraw = True
def on_exit(self):
super().on_exit()
for widget in self.widgets:
widget.on_exit()
self.blm = None
self.always_full_redraw = True
def on_enter_done(self):
self.always_full_redraw = False
def on_exit_done(self):
self.always_full_redraw = False
def get_help(self):
ret = (
"Violin Emulator\n\n"
'Rub the top petals to play different "frets".\n\n'
"Tilt left/right to switch string. String switching occurs when the active petal changes. "
"Pressing the app button down zeros the tilt reference to the current orientation.\n\n"
"You can add vibrato by rubbing in circles.\n\n"
"Tapping petals 1 or 9 shifts the global pitch an octave lower, tapping petals 3 or 7 shifts it "
"an octave higher.\n\n"
"Petal 5 is a slider to control the amount of reverb."
)
return ret
if __name__ == "__main__":
from st3m.run import run_app
run_app(App, "/flash/sys/apps/violin")