-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathinterface.py
1482 lines (1263 loc) · 52.3 KB
/
interface.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""
Module for the GUI of rhythmonics, wrapped in a console aesthetic.
This module contains and lays out all GUI components for interacting
with the Overtone objects and all sound and math simulations of the
harmonics module of rhythmonics. The GUI components are modular and can
be added to so long as all are added as attributes of the Console class
as this wraps the entire GUI and is what is interacted with in the main
event loop of rhythmonics.
While the harmonics module creates all the conceptual simulations of the
Overtones, this module visualizes, makes audible, and allows for
interaction with those objects.
The console is meant to be conceptually illustrative in its own right as
well, however, and each area of the console is meant to further convey
the formal equivalence of polyrhythms and harmony: Most centrally, the
screen conveys movement, time, and speed, turning a polygon-based rhythm
into a pitch. The slider area shows a clear gradient between rhythm and
pitch, where it can be read in BPM or Hz simultaneously. The radio
buttons pictorally show the overtone sine waves correspond to the
polygon rhythms. And the ratios between active overtones can both be
read as polyrhythm ratios and as ratio between frequency pitches, making
clear how polyrhythms and harmonies are one and the same as the slider
moves from low Hz to high Hz for the same ratios.
Classes
-------
Console
Wrap all of the GUI elements and display them as a console.
ScreenArea
Area for the console's Screen and the border around it.
Screen
Screen displays polygons and balls.
SliderArea
Area for displaying and interacting with the Slider.
Slider
Draggable slider position updates the Hz of the overtones.
RadioArea
Area containing all radio buttons and their kill switch.
RadioBtn
Radio button that controls the `active` status of an Overtone.
KillSwitch
Killswitch button that turns off all radio buttons.
RatioDisp
Area of the console displaying the ratio of active overtones.
See Also
--------
harmonics.py : Module of Overtone objects that this module is a GUI for.
"""
import pygame
import math
import harmonics as hmx
import config
class Console:
"""
Wrap all GUI elements and decide how they're displayed as a console.
This class sets up the console aesthetics (e.g. colors, fonts,
layout) and initializes and collects all of its areas: ScreenArea,
SliderArea, RadioArea, and RatioDisp. It also has a method for
drawing itself and all of its areas onto a Surface.
Its initialization will also create the Overtone objects that the
console acts as a GUI for displaying, voicing, and interacting with.
Attributes
----------
origin : tuple
Position relative to window/target Surface that Console will
drawn on.
size : tuple
Size of rectangular console area.
baseColor
Color of console background, see pygame.Color for supported
formats.
secColor
Color of console foreground pieces, see pygame.Color for
supported formats.
surf : pygame.Surface
Surface to draw console and its components onto.
screenArea : ScreenArea
Area for screen that displays polygons and the border around it.
overtones : list of harmonics.Overtone
Overtones that the console displays/voices and allows
interaction with.
labelsFont : pygame.font.Font
Font all labels on the console are written in.
labelsCol
Color of font of console's labels, see pygame.Color for
supported formats.
digitalFont : pygame.font.Font
Font on the console's digital displays.
digitalOn
Color of digital font when "lit up," see pygame.Color for
supported formats.
digitalOff
Color of idle digital font when not "lit," see pygame.Color for
supported formats.
digitalBG
Background color of digital displays, see pygame.Color for
supported formats.
sliderArea : SliderArea
Area for displaying and interacting with slider.
radioArea : RadioArea
Area for displaying and interacting with radio buttons and kill
switch.
ratioDisp : RatioDisp
Area for digital displays of ratios between active overtones.
Methods
-------
draw
Draw console and all of its components/areas.
"""
def __init__(self, origin, size, startHz):
"""
Initialize the console and all of its area components it wraps.
Initialize all of the console aesthetics (e.g. colors, fonts,
layout) and initialize and collect all of its areas: ScreenArea,
SliderArea, RadioArea, and RatioDisp. The ScreenArea's
initialization will instantiate the Polygon objects to display
and, with them, the Overtone objects that the console acts as
GUI for interacting with.
Parameters
----------
origin : tuple
Origin of the console's top left corner to display.
size : tuple
Size of the console area to display.
startHz : float
Positive number of the fundamental Hz the console will begin
with.
"""
self.origin = origin
self.size = pygame.Vector2(size)
self.baseColor = config.PALE_PINK
self.secColor = config.TEAL
self.surf = pygame.Surface(self.size)
# Initialize the screen area that displays the polygons. The
# Screen both creates the Polygon objects and initializes all
# Overtone objects based on them that the console and all if its
# components will use and interact with. All other areas of the
# console can now be instantiated after this to interact with
# these overtones.
screenAreaSize = pygame.Vector2(515, 425)
screenColor = config.SURF_GREEN
screenCenter = screenAreaSize / 2
consoleCenter = self.size / 2
screenAreaOrigin = consoleCenter - screenCenter - (0, 22)
self.screenArea = ScreenArea(
screenAreaOrigin,
screenAreaSize,
screenColor,
self.secColor,
startHz,
)
self.overtones = self.screenArea.screen.overtones
# Set up all fonts of the console so console areas can use them.
labelsFontSize = 18
self.labelsFont = pygame.font.Font("fonts/Menlo.ttc", labelsFontSize)
self.labelsCol = config.DARK_POMEGRANATE
digitalFontSize = 30
self.digitalFont = pygame.font.Font(
"fonts/digital-7 (mono).ttf", digitalFontSize
)
self.digitalOn = config.CYAN
self.digitalOff = config.MURKY_CYAN
self.digitalBG = config.DARK_CYAN
# Initialize slider area.
sliderAreaOrigin = (55, 45)
sliderAreaHeight = self.size[1] * 0.85
self.sliderArea = SliderArea(self, sliderAreaOrigin, sliderAreaHeight)
# Initialize area for radio buttons and kill switch.
radioAreaOrigin = (810, 65)
radioAreaSize = (135, 390)
self.radioArea = RadioArea(self, radioAreaOrigin, radioAreaSize)
# Initialize area for displays of ratios between active overtones.
ratioOrigin = screenAreaOrigin + (138, screenAreaSize[1] + 30)
self.ratioDisp = RatioDisp(self, ratioOrigin)
def draw(self, targetSurf):
"""
Draw the console and all of its components onto a Surface.
Parameters
----------
targetSurf : pygame.Surface
Surface to blit the console's surface to.
"""
# Clear screen by drawing console base onto console's surface.
pygame.draw.rect(
self.surf, self.baseColor, ((0, 0), self.size), border_radius=50
)
# Draw all of console's areas onto console's surface.
self.screenArea.draw(self.surf)
self.sliderArea.draw(self.surf)
self.radioArea.draw(self.surf)
self.ratioDisp.draw(self.surf)
# Blit console's surface onto the target surface/window.
targetSurf.blit(self.surf, self.origin)
class ScreenArea:
"""
Area for the Screen that displays polygons and the border around it.
The screen area size and origin accounts for the border and a Screen
object is initalized to be a smaller size and offsets its origin
within the border area.
Attributes
----------
border : pygame.Rect
Rectangle used to draw the border around the Screen on the
Console.
borderRadius : int
Radius of corners of `border` to draw a border with rounded
corners.
bigBorderRadius : int
Large radius to draw a very round corner if wanted.
borderCol
Color to draw `border` rectangle, see pygame.Color for supported
formats.
screen : Screen
Screen object that Polygon objects are drawn on.
Methods
-------
draw
Draw the screen area (border and screen) on a surface.
"""
def __init__(self, origin, size, screenCol, borderCol, startHz):
"""
Initialize the screen area with a border and a Screen object.
Initializing the Screen object will create the Overtone objects
that the screen will display and the rest of the console will
interact with.
Parameters
----------
origin : pygame.Vector2
Position relative to Console origin that this area will be
drawn onto.
size : tuple
Size of entire screen area, including border.
screenCol
Color of screen background, see pygame.Color for supported
formats.
borderCol
Color of border around screen, see pygame.Color for
supported formats.
startHz : float
Positive number of the fundamental Hz to initialize
overtones with.
"""
self.border = pygame.Rect(origin, size)
self.borderRadius = 5
self.bigBorderRad = 40
self.borderCol = borderCol
# Offset and size the screen within the border and instantiate screen.
screenOrigin = pygame.Vector2(origin) + (45, 25)
screenSize = pygame.Vector2(size) - (90, 76)
self.screen = Screen(screenOrigin, screenSize, screenCol, startHz)
def draw(self, surf):
"""
Draw the screen area (border and screen) on a surface.
Parameters
----------
surf : pygame.Surface
Surface to draw the screen area onto.
"""
# Draw the border and then the screen draws itself on top.
pygame.draw.rect(
surf,
self.borderCol,
self.border,
border_radius=self.borderRadius,
border_bottom_right_radius=self.bigBorderRad,
)
self.screen.draw(surf)
class Screen:
"""
Screen displays polygons/balls and instantiates console's overtones.
This class creates and holds the overtones for the console and draws
the polygons of those overtones and their balls. This is where
rhythms are visualized at low Hz: balls rhythmically traversing
polygons at certain speeds. It is still visualized at high speeds,
although the balls' paths become blurred at high Hz. This is the
main visual component of the console, the rest is for GUI
interaction.
Attributes
----------
origin : pygame.Vector2
Position relative to Console origin that this area will be drawn
onto.
size : pygame.Vector2
Size of screen where polygons are displayed.
color
Color of screen background, see pygame.Color for supported
formats.
surf : pygame.Surface
Surface of screen to draw onto.
overtones : list of harmonics.Overtone
Overtones whose polygons the screen will display.
"""
def __init__(self, origin, size, color, startHz):
"""
Initialize the screen, its polygons, and the overtones.
The polygons that will be drawn on the screen are all
instantiated here with respect to the screen position. Polygons
are crafted and nested aesthetically but arbitrarily, their
layout can be changed here. Once the polgyons are instantiated
they are used to instantiate the attached overtones that the
rest of the console will use.
Parameters
----------
origin
Position relative to Console origin that this area will be
drawn onto.
size
Size of screen where polygons are displayed.
color
Color of screen background, see pygame.Color for supported
formats.
startHz
Positive number of the fundamental Hz to initialize
overtones with.
"""
self.origin = origin
self.size = size
self.color = color
self.surf = pygame.Surface(size)
center = self.size / 2
# Polygon aesthetics and nesting can be crafted here. This is
# aesthetic and there are many possible choices, there is
# nothing inherent about the choices made here.
rootColor = config.KHAKI
thirdColor = config.LIGHT_COBALT
fifthColor = config.SORBET
seventhColor = config.POMEGRANATE
rootRadius = (0.94 * min(self.size[0], self.size[1])) / 2
# Polygons are named by their scale degree relative to the
# fundamental root frequency, `root1`. Order is mixed based on
# which polygons are inscribed in each other since they need to
# access their circumscribing Polygon object's `inCirc`
# attribute. The first parameter in the instantiation of a
# Polygon object is the number of vertices - i.e. which overtone
# it is.
root4 = hmx.Polygon(8, rootRadius, center, rootColor)
root1 = hmx.Polygon(
1, root4.inCirc - 10, center, rootColor, isPointy=False
)
root3 = hmx.Polygon(4, root4.inCirc - 10, center, rootColor)
root2 = hmx.Polygon(2, root3.inCirc, center, rootColor, isPointy=False)
fifth1 = hmx.Polygon(3, root3.inCirc, center, fifthColor)
fifth2 = hmx.Polygon(6, root3.inCirc, center, fifthColor)
third1 = hmx.Polygon(5, fifth1.inCirc, center, thirdColor)
seventh1 = hmx.Polygon(7, third1.inCirc, center, seventhColor)
polys = [root1, root2, fifth1, root3, third1, fifth2, seventh1, root4]
numOvertones = len(polys)
self.overtones = [
hmx.Overtone(len(poly.verts), poly, numOvertones, startHz)
for poly in polys
]
def draw(self, targetSurf, offset=pygame.Vector2(0, 0)):
"""
Draw the screen and everything on the screen onto a surface.
All polygons are always drawn and only the balls of active
overtones are drawn. The screen can be offset from its origin
but the default is no offset. The screen origin is relative to
the console the screen is on but if the screen is being drawn to
a different surface, such as the main window so the console
doesn't have to be redrawn as often as the screen, then the
offset should be set accordingly to keep it drawn to the right
spot on the console, see Examples.
Parameters
----------
targetSurf : pygame.Surface
Surface to draw the screen onto
offset : pygame.Vector2, default=pygame.Vector2(0,0)
x, y distances to offset the screen's origin by when
drawing. E.g. If `targetSurf` is the main window, so that
the screen is drawn directly to the window instead of the
Console surface, that should be accounted for by offsetting
our draw position by the console's origin.
Examples
--------
To draw on the console, the offset can be ignored.
>>> screen.draw(console.surf)
If drawing on the main window, the origin should be offset by
the console origin so that it continues to draw relative to its
position on the console.
>>> screen.draw(window, console.origin)
"""
self.surf.fill(self.color)
for overtone in self.overtones:
overtone.poly.draw(self.surf)
if overtone.active:
overtone.poly.ball.draw(self.surf)
targetSurf.blit(self.surf, self.origin + offset)
class SliderArea:
"""
Area for Slider and its UI: slider, labels, BPM/Hz digital displays.
This class creates the slider, the labels along the slider, and the
slider's digital display boxes and lays them all out visually with
its draw method.
Attributes
----------
origin : pygame.Vector2
Position relative to Console origin that this area will be drawn
onto.
height : int
Height of entire slider area, including digital display boxes.
color
Color of slider handle, see pygame.Color for supported formats.
HzLabel : pygame.Surface
Surface with 'Hz' label of Hz digital display box rendered onto
it.
BPM_Label : pygame.Surface
Surface with 'BPM' label of BPM digital display box rendered
onto it.
labels : list of pygame.Surface
List of surfaces with the slider's labels rendered onto them.
digitalFont : pygame.font.Font
Font of the slider's digital displays.
digitalOn
Color of digital font when "lit up," see pygame.Color for
supported formats.
HzBox : pygame.Surface
Surface with the digital display box for Hz rendered onto it.
BPM_Box : pygame.Surface
Surface with the digital display box for BPM rendered onto it.
horizontalBuf : int
Horizontal buffer space for laying out slider graphics visually.
labelsWidth : int
Maximum width of all the slider's labels.
slider : Slider
The Slider object of the slider's controllable handle.
Methods
-------
draw
Draw the entire slider area on a surface.
"""
def __init__(self, console, origin, height):
"""
Initialize slider components: slider, labels, and displays.
Initializes the Slider object and renders all slider's label
text and digital displays. Origin is relative to console's
origin and height of slider area includes the digital display
boxes.
Parameters
----------
console : Console
Console that the slider controls and is a component of.
origin : pygame.Vector2
Position relative to Console origin that this area will be
drawn onto.
height : int
Height of entire slider area, including digital display
boxes.
"""
self.origin = origin
self.height = height
# Color of slider handle is console's color for foreground pieces.
self.color = console.secColor
# Create all labels for the Slider Area.
labelsFont = console.labelsFont
self.labelsCol = console.labelsCol
self.HzLabel = labelsFont.render("Hz", True, self.labelsCol)
self.BPM_Label = labelsFont.render("BPM", True, self.labelsCol)
labelsText = ["FREEZE", "GROOVE", "CHAOS", "HARMONY", "EEEEEE"]
self.labels = [
labelsFont.render(text, True, self.labelsCol)
for text in labelsText
]
# Create digital display boxes for Hz and BPM.
self.digitalFont = console.digitalFont
self.digitalOn = console.digitalOn
digitalOff = console.digitalOff
digitalBG = console.digitalBG
self.HzBox = self.digitalFont.render(
" 8888.88 ", False, digitalOff, digitalBG
)
self.BPM_Box = self.digitalFont.render(
" 888888 ", False, digitalOff, digitalBG
)
# Set up parameters to instantiate the slider, nested between Hz
# and BPM digital displays.
# Note, `sliderMaxy` will be the *lowest* on the screen that the
# slider handle can go since the origin of things is the top
# left corner and y increases downwards.
verticalBuf = 30
self.horizontalBuf = 20
sliderMiny = self.origin[1] + self.HzBox.get_height() + verticalBuf
sliderMaxy = (
self.origin[1]
+ self.height
- self.BPM_Box.get_height()
- verticalBuf
)
# Find the slider starting position (offset x coordinate with
# enough room for labels) and create slider.
sliderSize = (20, 40)
self.labelsWidth = max([label.get_width() for label in self.labels])
sliderOffset = (
self.origin[0]
+ self.labelsWidth
+ self.horizontalBuf
+ sliderSize[0]
)
# Slider knob starts at this fraction of the slider range.
# GROOVE is at .25.
sliderStart = 0.25
sliderPos = pygame.Vector2(
sliderOffset, sliderMaxy - sliderStart * (sliderMaxy - sliderMiny)
)
self.slider = Slider(
sliderPos,
sliderSize,
self.color,
console.overtones,
sliderMiny,
sliderMaxy,
)
def draw(self, surface):
"""
Draw the entire slider area on a surface.
Parameters
----------
surface : pygame.Surface
Surface to draw the slider area onto.
"""
# Draw slider track's rut and then the slider handle.
sliderRutCol = (150, 150, 150)
sliderMin = (self.slider.pos[0], self.slider.miny)
sliderMax = (self.slider.pos[0], self.slider.maxy)
pygame.draw.line(surface, sliderRutCol, sliderMin, sliderMax, width=2)
self.slider.draw(surface)
# Draw Hz display: HzBox and label and then current Hz in box.
surface.blit(self.HzBox, (self.origin[0], self.origin[1]))
HzLabelOffset = (
self.origin[0] + self.HzBox.get_width() + self.horizontalBuf / 2
)
surface.blit(self.HzLabel, (HzLabelOffset, self.origin[1]))
Hz = self.slider.overtones[0].Hz
HzString = " " + f"{Hz:07.2f}".replace("1", " 1") + " "
HzDisp = self.digitalFont.render(HzString, False, self.digitalOn)
surface.blit(HzDisp, (self.origin[0], self.origin[1]))
# Draw slider labels and arrows next to them.
for i, label in enumerate(self.labels):
fntHeight = label.get_height()
xPos = self.origin[0]
yPos = (self.slider.maxy - fntHeight / 2) - (i / 4) * (
self.slider.maxy - self.slider.miny
)
surface.blit(label, (xPos, yPos))
xOffset = xPos + self.labelsWidth + 10 # Draw arrow next to label.
arrowWidth = 5
arrowPoints = [
(xOffset, yPos + 3),
(xOffset, yPos + fntHeight - 3),
(xOffset + arrowWidth, yPos + fntHeight / 2),
]
pygame.draw.polygon(surface, self.labelsCol, arrowPoints)
# Draw BPM display: BPM_Box and label and then current BPM in box.
yOffset = self.origin[1] + self.height - self.BPM_Box.get_height()
surface.blit(self.BPM_Box, (self.origin[0], yOffset))
BPM_Label_Offset = (
self.origin[0] + self.BPM_Box.get_width() + self.horizontalBuf / 2
)
surface.blit(self.BPM_Label, (BPM_Label_Offset, yOffset))
BPM = Hz * 60
BPM_String = " " + f"{BPM:06.0f}".replace("1", " 1") + " "
BPM_Disp = self.digitalFont.render(BPM_String, False, self.digitalOn)
surface.blit(BPM_Disp, (self.origin[0], yOffset))
class Slider:
"""
Slider can be dragged and its position updates the overtones' Hz.
This class creates a slider handle whose position controls the Hz of
the console's overtones. This class' `updateVolt` method defines
how the position of the slider is translated into voltage for the
fundamental frequency for the overtones.
Attributes
----------
pos : pygame.Vector2
Position of center of slider handle relative to Console origin.
size : pygame.Vector2
Size of the slider handle rectangle.
color
Color of slider handle, see pygame.Color for supported formats.
overtones : list of harmonics.Overtone
Overtones that the slider is controlling the Hz of.
miny : int
Minimum y value Slider.pos can take on relative to the Console
its on. Note, this is positional and so will be the *highest*
that the slider can go since the console origin is in the top
left corner.
maxy : int
Maximum y value Slider.pos can take on relative to the Console
its on. Note, this is positional and so will be the *lowest*
that the slider can go since the console origin is in the top
left corner.
isSelected : bool
Boolean of whether the slider handle is being controlled.
handle : pygame.Rect
Rect object that is visually displayed as the slider handle.
quarterTarget : float
Hz that fundamental overtone will be set to at the quarter point
of the slider scale.
halfTarget : float
Hz that fundamental overtone will be set to at the halfway point
of the slider scale.
threeQuartTarget : float
Hz that fundamental overtone will be set to at the three
quarters point of the slider scale.
topTarget : float
Hz that fundamental overtone will be set to at the top of the
slider scale.
Methods
-------
updateVolt
Update the Hz of the overtones' oscillators from slider's
position.
draw
Draw the slider handle on a surface.
"""
def __init__(self, position, size, color, overtones, miny, maxy):
"""
Initialize the slider and the target Hz values it should hit.
Create a slider handle, a boolean of whether it is currently
selected, and the target Hz values at the quarter marks of the
slider track that the slider should interpolate between when
updating the Hz of the fundamental frequency of the overtones.
Parameters
----------
position : tuple
Starting osition of center of slider handle relative to
Console origin.
size : tuple
Size of the slider handle rectangle.
color
Color of slider handle, see pygame.Color for supported
formats.
overtones : list of harmonics.Overtone
Overtones that the slider will be controlling the Hz of.
miny
Minimum y value Slider.pos can take on relative to the
Console its on. Note, this is positional and so will be the
*highest* that the slider can go since the console origin is
in the top left corner.
maxy
Maximum y value Slider.pos can take on relative to the
Console its on. Note, this is positional and so will be the
*lowest* that the slider can go since the console origin is
in the top left corner.
"""
self.pos = pygame.Vector2(position)
self.size = pygame.Vector2(size)
self.color = color
self.overtones = overtones
self.miny = miny
self.maxy = maxy
self.isSelected = False
self.handle = pygame.Rect(self.pos - self.size / 2, self.size)
# Set the target Hz values the slider should affect at the
# quarter marks of the slider track. The quarter and halfway
# point should not yet sound like a pitch and should sound like
# a (possibly very fast) rhythm. After the halfway point even
# the lowest overtone should sound like a pitch.
#
# This both fits conceptually with rhythmonics in relating
# rhythm and pitch, where the slider makes this relationship
# clear, and also matches the implementation: In this class'
# updateVolt method, the quarter and halway point are scaled
# linearly while the other target points are scaled to
# logarithmically. The linear scaling in the rhythmic BPM half
# of the slider scale matches a smooth scaling of rhythm, while
# the rest of the slider track fits human perception of pitch
# since we hear increase in pitches logarithmically. Choosing
# appropriately gives a smooth and natural scaling in both
# conceptual components of rhythmonics: rhythm and pitch.
# Up to quarter of the way, linearly scale up to 1Hz=60bpm.
self.quarterTarget = 1
# Linearly scale up to halway point at 275/60Hz = 275bpm
self.halfTarget = 275 / 60
# Log scale up to 110Hz (4th harmonic/2nd octave will be 440Hz).
self.threeQuartTarget = 110
# Caps at 2000 Hz (seventh harmonic will be at 14000Hz)
self.topTarget = 2000
def updateVolt(self, beat_offset, clock):
"""
Update the Hz of all overtones based on the slider's position.
The slider has target Hz values to be assigned at the quarter
marks of the slider and either scales linearly or
logarithmically towards those values depending on whether the Hz
is in a range that sounds like discrete rhythms or a continuous
pitch, respectively. This function updates the Hz of all the
overtones according to the scaling based on where the slider
handle is positioned.
This function uses takes as parameters the number of
milliseconds since the last beat, `beat_offset`, and the main
event loop's `clock` to make sure the the phase of the sound
waves matches the beat offset so that the sound and graphics of
the console system are matched up. The new `beat_offset` and
number of milliseconds in a beat, `ms_per_beat`, (updated with
the new Hz) are returned to keep the event loop in sync with the
sound.
Parameters
----------
beat_offset : int
Number of milliseconds since last beat occurred.
clock : pygame.time.Clock
Clock of the main event loop keeping everything synced.
Returns
-------
beat_offset : int
Number of milliseconds since last beat occurred.
ms_per_beat : int
Number of milliseconds in a beat.
Notes
-----
The name `updateVolt` is used to conceptually capture the analog
sound systems this console conceptually emulates: Sounds are
produced by the harmonics.Oscillator function which is inspired
by analog circuits called Voltage-Controlled Oscillators (VCOs)
which create a sound wave at a frequency that depends on the
voltage supplied to it. Conceptually, the slider is providing
the voltage here and speeding up or slowing down the VCO, and
thus increasing or decreasing the pitch of the sound,
respectively.
"""
# Translate the slider position to the new Hz.
HzScale = abs(self.pos[1] - self.maxy) / (
self.maxy - self.miny
) # [0,1] value of where slider is on track (1 is top).
# TODO Define a log function for that is intuitive and
# parameterizable for scaling these.
if HzScale <= 0.25:
# Linearly scale up to quarterTarget.
Hz = self.quarterTarget * HzScale / 0.25
# Too low Hz takes too much time to make Sound object, just
# make it 0.
if Hz <= 0.02:
Hz = 0
elif HzScale <= 0.5:
# Linearly scale up to halfTarget.
Hz = (
self.quarterTarget - (HzScale - 0.25) / 0.25
) + self.halfTarget * (HzScale - 0.25) / 0.25
elif HzScale <= 0.75:
# Logarithmically scale up to threeQuartTarget.
Hz = self.halfTarget + (
self.threeQuartTarget - self.halfTarget
) * math.log(1 + (HzScale - 0.5) / 0.25, 2)
else:
# Logarithmically scale up to topTarget.
Hz = self.threeQuartTarget + (
self.topTarget - self.threeQuartTarget
) * math.log(1 + (HzScale - 0.75) / 0.25, 2)
# Update all the oscillators with the new Hz.
if Hz == 0:
ms_per_beat = 0
for overtone in self.overtones:
overtone.Hz = 0
overtone.oscillator.stop()
else:
# Start updated soundwaves in the future by buffer_time and
# then wait to play them so that they will be in sync with
# graphics.
#
# This is done since, at low Hz, the function harmonics.
# Oscillator can take a long time to create a Sound object
# and so using the beat_offset time to shift the sound
# waves' phases by may be stale by the time all the Sound
# objects are created and ready to be played.
#
# Giving ourselves enough buffer time to finish updating the
# Hz of the oscillators and play them allows them to be in
# proper sync with the clock and thus the movement of the
# polygons' balls.
ms_per_beat = 1000 / Hz
# Chosen ad hoc; graphics sync well and doesn't take
# noticeably longer time.
buffer_time = (1 / Hz) * 26
beat_offset = (beat_offset + clock.get_time()) % ms_per_beat
clock.tick()
for overtone in self.overtones:
overtone.updateHz(
Hz, (beat_offset + buffer_time) / ms_per_beat
)
msLeftToWait = int(
max(buffer_time - clock.tick(), 0)
) # Start immediately if we passed our buffer_time.
pygame.time.wait(msLeftToWait)
for overtone in self.overtones:
overtone.oscillator.play(loops=-1)
return beat_offset, ms_per_beat
def draw(self, surface):
"""
Update slider handle with position and draw it to a surface.
Parameters
----------
surface : pygame.Surface
Surface to draw slider handle onto.
"""
self.handle = pygame.Rect(self.pos - self.size / 2, self.size)
pygame.draw.rect(surface, self.color, self.handle)
class RadioArea:
"""
Area containing radio buttons, sine wave graphics, and kill switch.
This area has radio buttons that allow for turning each overtone on
or of - i.e. toggling the overtone `active` boolean attribute. It
has sine waves to graphically represent the overtone next to its
associated radio button. And there is a kill switch button that
allows all the radio buttons to be turned off at once.
Attributes
----------
origin : pygame.Vector2
Position relative to Console origin that this area will be drawn
onto.
size : pygame.Vector2
Size of the radio area.
radios : list of RadioBtn
A radio button corresponding to each overtone of the Console.
sines : list of pygame.Surface
Each surface has a sine wave of an overtone drawn on it.
killSwitch : KillSwitch
Kill switch for turning off all radio buttons.
killSwitchLabel : pygame.Surface
Surface with a label for the kill switch rendered onto it.
Methods
-------
draw
Draw the radio button area onto a surface.
"""
def __init__(self, console, origin, size):
"""
Create radio and kill switch buttons and draw sine waves.
Make a radio button for each overtone and draw a sine wave
representing the overtone on a surface. Create a kill switch
and a label for the kill switch.
Parameters
----------
console : Console
Console that the radio area is a component of and controls.
origin : tuple
Position relative to Console origin that this area will be
drawn onto.
size : tuple
Size of the radio area.
"""
self.origin = pygame.Vector2(origin)
self.size = pygame.Vector2(size)
# For each overtone create a radio button and draw a
# representative sine wave.
self.radios = []
self.sines = []
totalOvertones = len(console.overtones)
self.horizontalBuf = 20
radioRad = 5
# Parameters for drawing sine waves
sineLength = size[0] - self.horizontalBuf
sampRate = 55
peakHeight = 10
tickLength = 4
tickWidth = 1
sineCol = config.LIGHT_MAROON
for overtone in console.overtones:
overtoneNum = overtone.overtone
# Create radio button associated with the overtone.