pybeamshow/beamshow.py

382 lines
13 KiB
Python
Raw Normal View History

2023-02-16 01:14:50 +01:00
from argparse import ArgumentParser
2023-02-19 02:01:28 +01:00
from typing import Generator, List, Tuple
2023-02-18 23:16:49 +01:00
2023-02-15 21:08:47 +01:00
import pygame as pg
import sys
2023-02-22 22:55:11 +01:00
from effects.effect import Effect
2023-02-17 02:08:21 +01:00
from effects.presets import Presets
2023-02-22 22:55:11 +01:00
from util.color import Colors
2023-03-05 01:46:59 +01:00
from util.audio import AudioProcess, find_all_inputs
2023-02-15 21:08:47 +01:00
2023-02-16 01:14:50 +01:00
def print_displays() -> None:
pg.display.init()
display = pg.display.get_desktop_sizes()
for i, d in enumerate(display):
print(f"#{i} - {d[0]}x{d[1]}")
sys.exit(0)
2023-02-15 21:08:47 +01:00
2023-02-15 22:38:02 +01:00
2023-03-05 01:46:59 +01:00
def print_inputs() -> None:
devs = find_all_inputs()
print("\n".join((x["name"] for x in devs)))
sys.exit(0)
2023-02-19 02:01:28 +01:00
class Beamshow:
def __init__(
self,
2023-02-23 02:02:24 +01:00
audio_device_name: str,
2023-02-19 02:01:28 +01:00
render3d: bool,
window: bool,
trails: bool,
randomize_interval: float,
fps: bool,
display: int,
2023-02-20 02:01:49 +01:00
beat_reactive: bool,
2023-02-19 02:01:28 +01:00
) -> None:
self.render3d = render3d
self.windowed = window
self.trails = trails
self.randomize = bool(randomize_interval)
self.randomize_interval = randomize_interval or 5
self.print_fps = fps
self.display_id = display
self.effects: List[Effect] = []
self.clock = pg.time.Clock()
2023-02-20 02:01:49 +01:00
self.beat_reactive = beat_reactive
2023-02-22 03:43:45 +01:00
self.beat_factor = 1
self.beat_skip_counter = 0
2023-02-23 01:56:05 +01:00
self.last_preset = ""
2023-02-19 02:01:28 +01:00
pg.display.init()
self.window, self.background = self.initialize()
2023-02-23 02:02:24 +01:00
self.audio = AudioProcess(audio_device_name)
2023-02-19 02:01:28 +01:00
def initialize(self) -> Tuple[pg.Surface, pg.Surface]:
2023-02-23 01:56:05 +01:00
recreate_effect = (
not self.effects or pg.display.is_fullscreen() == self.windowed
)
2023-02-19 02:01:28 +01:00
displays = pg.display.get_desktop_sizes()
if not 0 <= self.display_id < len(displays):
raise ValueError(
f"Display ID {self.display_id} invalid. Must be between 0 and {len(displays)}!"
)
win = pg.display.set_mode(
size=displays[self.display_id]
if not self.windowed
else (displays[self.display_id][0] // 2, displays[self.display_id][1] // 2),
flags=pg.FULLSCREEN if not self.windowed else 0,
display=self.display_id,
2023-02-16 01:14:50 +01:00
)
2023-02-19 02:01:28 +01:00
if self.trails:
background = pg.Surface(win.get_size(), flags=pg.SRCALPHA)
background.fill(pg.Color(0, 0, 0, 5))
else:
background = pg.Surface(win.get_size())
background.fill(Colors.Black)
2023-02-22 03:43:45 +01:00
if recreate_effect:
self.presets = Presets(
bounds=win.get_rect(), beat_reactive=self.beat_reactive
)
2023-02-23 01:56:05 +01:00
if self.last_preset and self.last_preset in self.presets:
self.effects = self.presets[self.last_preset]
2023-02-22 03:43:45 +01:00
else:
2023-02-23 01:56:05 +01:00
if self.randomize:
self.last_preset = self.presets.randomize()
self.effects = self.presets[self.last_preset]
else:
self.effects = self.presets.default()
2023-02-19 02:01:28 +01:00
return win, background
def render_loop_normal(
self,
) -> Generator[List[pg.event.Event], Tuple[bool, float], None]:
is_beat = False
frames_per_beat = 0.0
while True:
unhandled_events: List[pg.event.Event] = []
for event in pg.event.get():
unhandled_events.append(event)
self.window.blit(self.background, (0, 0))
for e in self.effects:
e.update(is_beat=is_beat, frames_per_beat=frames_per_beat)
e.draw(self.window)
is_beat, frames_per_beat = yield unhandled_events
def render_loop_3d(
self,
) -> Generator[List[pg.event.Event], Tuple[bool, float], None]:
stage = pg.Surface(size=self.window.get_size())
stage.fill(Colors.Black)
full_size = stage.get_size()
scaled_sizes = [
(((full_size[0] * i) // 100), ((full_size[1] * i) // 100))
for i in range(101)
]
2023-02-15 21:08:47 +01:00
2023-02-19 02:01:28 +01:00
scaled_positions = [
((full_size[0] - s[0]) // 2, (full_size[1] - s[1]) // 2)
for s in scaled_sizes
]
2023-02-16 01:14:50 +01:00
2023-02-19 02:01:28 +01:00
is_beat = False
frames_per_beat = 0.0
while True:
unhandled_events: List[pg.event.Event] = []
for event in pg.event.get():
unhandled_events.append(event)
stage.blit(self.background, (0, 0))
for e in self.effects:
e.update(is_beat=is_beat, frames_per_beat=frames_per_beat)
e.draw(stage)
self.window.fill(Colors.Black)
stage.set_colorkey(Colors.Black)
for i in range(0, 101, 4):
stage.set_alpha(192 - 192 * ((i / 100) ** 0.5))
self.window.blit(
pg.transform.scale(stage, scaled_sizes[int(100 * (i / 100) ** 2)]),
scaled_positions[int(100 * (i / 100) ** 2)],
)
is_beat, frames_per_beat = yield unhandled_events
def main(self):
if self.render3d:
loop = self.render_loop_3d()
else:
loop = self.render_loop_normal()
next(loop)
2023-02-16 01:14:50 +01:00
2023-02-19 02:01:28 +01:00
framecounter = 0
blackout = False
single_random = False
2023-02-23 00:18:11 +01:00
strobe = False
2023-02-19 02:01:28 +01:00
fps_slidewindow = []
frames_per_beat = 0
2023-02-24 17:19:33 +01:00
pause_beat = False
2023-02-19 02:01:28 +01:00
while True:
2023-02-22 03:43:45 +01:00
with self.audio.lock:
is_beat = self.audio.is_beat
self.audio.is_beat = False
if is_beat:
if self.beat_skip_counter > 0:
is_beat = False
2023-02-23 00:18:11 +01:00
# print(" skip")
2023-02-22 03:43:45 +01:00
self.beat_skip_counter -= 1
else:
2023-02-23 00:18:11 +01:00
# print("beat")
2023-02-22 03:43:45 +01:00
self.beat_skip_counter = self.beat_factor - 1
2023-02-19 02:01:28 +01:00
fps_mean = (
sum(fps_slidewindow) / len(fps_slidewindow) if fps_slidewindow else 0
)
2023-02-24 17:19:33 +01:00
common_events = loop.send((is_beat and not pause_beat, frames_per_beat))
2023-02-19 02:01:28 +01:00
reinitialize = False
for event in common_events:
if event.type == pg.QUIT or (
event.type == pg.KEYDOWN and event.key == pg.K_ESCAPE
):
2023-02-22 03:43:45 +01:00
self.audio.stop()
2023-02-19 02:01:28 +01:00
pg.quit()
sys.exit()
elif event.type == pg.KEYDOWN:
if event.key == pg.K_F5:
single_random = True
print("Switching to new random preset")
2023-02-22 03:43:45 +01:00
elif event.key == pg.K_F6:
print("Reload")
self.effects.clear()
reinitialize = True
2023-02-24 17:19:33 +01:00
elif event.key == pg.K_F7:
pause_beat = not pause_beat
print(f"Pause beat: {pause_beat}")
2023-02-19 02:01:28 +01:00
elif event.key == pg.K_F8:
self.randomize = not self.randomize
state_str = (
f"on, {self.randomize_interval} s"
if self.randomize
else "off"
)
print(f"Random preset switching: [{state_str}]")
elif event.key == pg.K_F9:
self.render3d = not self.render3d
print(f'Pseudo-3d view [{"on" if self.render3d else "off"}]')
reinitialize = True
elif event.key == pg.K_F10:
self.trails = not self.trails
print(f'Trails [{"on" if self.trails else "off"}]')
reinitialize = True
elif event.key == pg.K_F11:
self.windowed = not self.windowed
print(f'Windowed mode [{"on" if self.windowed else "off"}]')
reinitialize = True
elif event.key == pg.K_SPACE:
blackout = not blackout
print(f'BLACKOUT [{"ON" if blackout else "off"}]')
elif event.key == pg.K_HOME:
print("resetting beat timing")
2023-02-22 03:43:45 +01:00
self.beat_skip_counter = 0
2023-02-19 02:01:28 +01:00
elif event.key == pg.K_PAGEUP:
2023-02-22 03:43:45 +01:00
self.beat_factor = (
2023-02-23 00:18:11 +01:00
self.beat_factor // 2 if self.beat_factor > 1 else 1
2023-02-19 02:01:28 +01:00
)
2023-02-22 03:43:45 +01:00
print(f"Trigger on every {self.beat_factor} beat")
2023-02-19 02:01:28 +01:00
elif event.key == pg.K_PAGEDOWN:
2023-02-22 03:43:45 +01:00
self.beat_factor = self.beat_factor * 2
print(f"Trigger on every {self.beat_factor} beat")
2023-03-05 02:36:41 +01:00
elif event.key == pg.K_RETURN or event.key == pg.K_KP_ENTER:
2023-02-23 00:18:11 +01:00
strobe = True
elif event.type == pg.KEYUP:
2023-03-05 02:36:41 +01:00
if event.key == pg.K_RETURN or event.key == pg.K_KP_ENTER:
2023-02-23 00:18:11 +01:00
strobe = False
2023-02-19 02:01:28 +01:00
if reinitialize:
self.window, self.background = self.initialize()
if self.render3d:
loop = self.render_loop_3d()
else:
loop = self.render_loop_normal()
next(loop)
2023-02-23 00:18:11 +01:00
if strobe:
self.window.fill(
2023-02-24 17:19:33 +01:00
Colors.White if (framecounter % 4 <= 1) else Colors.Black
2023-02-23 00:18:11 +01:00
)
2023-02-19 02:01:28 +01:00
if blackout:
self.window.fill(Colors.Black)
if is_beat:
2023-02-22 22:55:11 +01:00
pg.draw.rect(self.window, Colors.White, (0, 0, 20, 20))
2023-02-19 02:01:28 +01:00
2023-02-24 17:19:33 +01:00
current_fps = self.clock.get_fps()
fps_slidewindow.append(current_fps)
while len(fps_slidewindow) > 120:
fps_slidewindow.pop(0)
if self.print_fps and (framecounter & 0x3F == 0):
print(f"FPS: {current_fps:5.2f}, avg {fps_mean:5.2f}")
if fps_mean < 50:
pg.draw.rect(
self.window,
Colors.Yellow,
(self.window.get_width() - 20, 0, 20, 20),
)
elif fps_mean < 40:
pg.draw.rect(
self.window,
Colors.Red,
(self.window.get_width() - 20, 0, 20, 20),
)
2023-02-19 02:01:28 +01:00
if (
self.randomize and (framecounter % (self.randomize_interval * 60)) == 0
) or single_random:
single_random = False
new_preset = self.presets.randomize()
while new_preset == self.effects:
new_preset = self.presets.randomize()
2023-02-22 03:43:45 +01:00
print(new_preset)
2023-02-23 01:56:05 +01:00
self.last_preset = new_preset
2023-02-19 02:01:28 +01:00
self.effects.clear()
2023-02-23 01:56:05 +01:00
self.effects.extend(self.presets[self.last_preset])
2023-02-19 02:01:28 +01:00
2023-02-24 17:19:33 +01:00
pg.display.flip()
self.clock.tick(60)
framecounter += 1
2023-02-19 02:01:28 +01:00
def app_main() -> None:
2023-02-16 01:14:50 +01:00
argparser = ArgumentParser(
description="beamshow - Render a light show for a video projector"
)
2023-02-23 02:02:24 +01:00
argparser.add_argument(
"-a",
"--audio",
metavar="NAME",
type=str,
2023-03-05 01:50:14 +01:00
default="",
2023-02-23 02:02:24 +01:00
help="The audio device to use. Can be any substring",
)
2023-02-16 01:14:50 +01:00
argparser.add_argument(
"--3d",
dest="render3d",
action="store_true",
help="Render a 3D preview instead of the normal output",
)
argparser.add_argument(
"--list-displays", action="store_true", help="Show available displays"
)
2023-03-05 01:46:59 +01:00
argparser.add_argument(
"--list-inputs", action="store_true", help="Show available audio inputs"
)
2023-02-16 01:14:50 +01:00
argparser.add_argument(
"-w", "--window", action="store_true", help="Display in a window"
)
2023-02-20 02:01:49 +01:00
argparser.add_argument(
"-b", "--beat", action="store_true", help="Effects should react to beats"
)
2023-02-16 01:14:50 +01:00
argparser.add_argument(
"--trails", action="store_true", help="Fade patterns out (trail mode)"
)
2023-02-17 02:08:21 +01:00
argparser.add_argument(
"--randomize",
metavar="N",
type=int,
nargs="?",
default=0,
const=5,
help="Select random effect presets after <N> seconds",
)
2023-02-16 01:14:50 +01:00
argparser.add_argument("--fps", action="store_true", help="Show FPS in console")
argparser.add_argument(
"-d", "--display", type=int, default=0, help="ID of the display to use"
)
# print some nice output after the pygame banner to separate our stuff from theirs
print("")
2023-02-18 23:16:49 +01:00
print("-" * (len(str(argparser.description)) + 4))
2023-02-16 01:14:50 +01:00
print(f" {argparser.description} ")
2023-02-18 23:16:49 +01:00
print("-" * (len(str(argparser.description)) + 4))
2023-02-16 01:14:50 +01:00
print("")
args = argparser.parse_args()
if args.list_displays:
print_displays()
2023-03-05 01:50:14 +01:00
if args.list_inputs:
2023-03-05 01:46:59 +01:00
print_inputs()
2023-03-05 01:50:14 +01:00
if not args.audio:
print("Must select audio input")
sys.exit(-1)
2023-02-19 02:01:28 +01:00
show = Beamshow(
2023-02-23 02:02:24 +01:00
audio_device_name=args.audio,
2023-02-19 02:01:28 +01:00
render3d=args.render3d,
window=args.window,
trails=args.trails,
randomize_interval=args.randomize,
fps=args.fps,
display=args.display,
2023-02-20 02:01:49 +01:00
beat_reactive=args.beat,
2023-02-16 01:14:50 +01:00
)
2023-02-19 02:01:28 +01:00
show.main()
2023-02-16 01:14:50 +01:00
if __name__ == "__main__":
2023-02-19 02:01:28 +01:00
app_main()