Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion software/contrib/pams.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ The vizualization does not have any submenu items and simply displays the voltag

The main clock menu has the following options:

- `BPM` -- the main BPM for the clock. Must be in the range `[1, 240]`.
- `BPM` -- the main BPM for the clock. Must be in the range `[1, 300]`.

The submenu for the main clock has the following options:

Expand All @@ -106,9 +106,24 @@ The submenu for the main clock has the following options:
- `Trigger`: the clock will toggle between the running & stopped states on a rising edge
- `Reset`: the clock will not change, but all waveforms & euclidean patterns will reset to the
beginning
- `Ext. Clk`: the clock's BPM is dynamically calculated based on the input square wave. The input
clock is synchronized to the `x1` outputs.
- `Stop-Rst` -- Stop & Reset: if true, all waves & euclidean patterns will reset when the clock
starts. Otherwise they will continue from where they stopped

### External Clocking Limitations

Pam's can only be clocked within the `BPM` range described above. Any external clock signal that
is slower than the minimum BPM (1) or faster than the maximum BPM (300 at the time of writing) will
be clamped within this range.

Pam's internal clock will be hard-sync'd with the external signal on the external signal's rising
edge, so even at out-of-range speeds the system will make a best-effort to stay synchronized.

Clocking Pam's with a highly-variable clock source may result in synchronization issues. Because of
the hard-syncing that occurs, any `x1` outputs will remain mostly synchronized, but other outputs
may become desynchronized if the external clock speed varies too much.

## CV Channel Options

Each of the 6 CV output channels has the following options:
Expand Down Expand Up @@ -375,6 +390,7 @@ least 10ms. The table below shows approximate trigger times for some common BPM

| BPM | Trigger length (ms, approx.) | PPQN pulses |
|-----|------------------------------|-------------|
| 300 | 12.5 | 3 |
| 240 | 10.4 | 2 |
| 120 | 10.4 | 1 |
| 90 | 13.9 | 1 |
Expand Down
89 changes: 84 additions & 5 deletions software/contrib/pams.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@

import gc
import math
import micropython
import time
import random

Expand Down Expand Up @@ -295,11 +296,18 @@
## Reset on a rising edge, but don't start/stop the clock
DIN_MODE_RESET = 'Reset'

## External clock
#
# The clock is assumed to be x1 input; we interpolate the BPM from
# the input and use that to set the master clock's frequency dynamically
DIN_MODE_EXTERNAL = "Ext. Clk"

## Sorted list of DIN modes for display
DIN_MODES = [
DIN_MODE_GATE,
DIN_MODE_TRIGGER,
DIN_MODE_RESET
DIN_MODE_RESET,
DIN_MODE_EXTERNAL,
]

## True/False labels for yes/no settings (e.g. mute)
Expand Down Expand Up @@ -334,6 +342,19 @@
]


@micropython.native
def us2bpm(us, ppqn=1):
"""Convert the length of a gate (rise-to-rise) to a BPM

@param us The elapsed time in microseconds between consecutive rising edges
@param ppqn The PPQN value for the clock

@return The equivalent BPM of the gate signal
"""
us_per_quarter_note = us * ppqn
return 60000000.0 / us_per_quarter_note


class BufferedAnalogueReader(AnalogueReader):
"""A wrapper for basic AnalogueReader instances that read the ADC hardware on-demand

Expand Down Expand Up @@ -436,7 +457,7 @@ class MasterClock:
MIN_BPM = 1

## The absolute fastest the clock can go
MAX_BPM = 240
MAX_BPM = 300

def __init__(self, bpm):
"""Create the main clock to run at a given bpm
Expand Down Expand Up @@ -485,6 +506,7 @@ def add_channels(self, channels):
for ch in channels:
self.channels.append(ch)

@micropython.native
def on_tick(self, timer):
"""Callback function for the timer's tick
"""
Expand Down Expand Up @@ -959,11 +981,13 @@ def update_menu_visibility(self, new_value=None, old_value=None, config_point=No
self.t_lock.is_visible = show_turing
self.t_mode.is_visible = show_turing

@micropython.native
def change_e_length(self, new_value=None, old_value=None, config_point=None, arg=None):
self.e_trig.modify_choices(list(range(self.e_step.value+1)), self.e_step.value)
self.e_rot.modify_choices(list(range(self.e_step.value+1)), self.e_step.value)
self.recalculate_e_pattern()

@micropython.native
def recalculate_e_pattern(self, new_value=None, old_value=None, config_point=None, arg=None):
"""Recalulate the euclidean pattern this channel outputs
"""
Expand All @@ -981,6 +1005,7 @@ def change_clock_mod(self):
self.real_clock_mod = self.clock_mod.mapped_value
self.clock_mod_dirty = False

@micropython.native
def square_wave(self, tick, n_ticks):
"""Calculate the [0, 1] value of a square wave with PWM

Expand All @@ -1005,6 +1030,7 @@ def square_wave(self, tick, n_ticks):
else:
return 0.0

@micropython.native
def triangle_wave(self, tick, n_ticks):
"""Calculate the [0, 1] value of a triangle wave

Expand Down Expand Up @@ -1033,6 +1059,7 @@ def triangle_wave(self, tick, n_ticks):
y = peak - step * (tick - rising_ticks)
return y

@micropython.native
def sine_wave(self, tick, n_ticks):
"""Calculate the [0, 1] value of a sine wave

Expand All @@ -1049,6 +1076,7 @@ def sine_wave(self, tick, n_ticks):
s_theta = (math.sin(theta) + 1) / 2 # (sin(x) + 1)/2 since we can't output negative voltages
return s_theta

@micropython.native
def adsr_wave(self, tick, n_ticks):
"""Calculate the [0, 1] level of an ADSR envelope

Expand Down Expand Up @@ -1102,6 +1130,7 @@ def adsr_wave(self, tick, n_ticks):
# outside of the ADSR
return 0.0

@micropython.native
def turing_shift(self):
"""Shift the turing machine register by 1 bit
"""
Expand All @@ -1112,6 +1141,7 @@ def turing_shift(self):
incoming_bit = (self.turing_register >> (self.t_length.value - 1)) & 0x01
self.turing_register = ((self.turing_register << 1) & 0xffff) | incoming_bit

@micropython.native
def turing_wave(self, tick, n_ticks):
"""Calculate the [0, 1] output of a Turing Machine wave

Expand Down Expand Up @@ -1161,6 +1191,7 @@ def reset_settings(self):
for s in self.all_settings:
s.reset_to_default()

@micropython.native
def tick(self):
"""Advance the current pattern one tick and calculate the output voltage

Expand Down Expand Up @@ -1264,6 +1295,7 @@ def tick(self):

self.out_volts = out_volts

@micropython.native
def apply(self):
"""Apply the calculated voltage to the output channel

Expand Down Expand Up @@ -1354,17 +1386,20 @@ def __init__(self):
# Are UI elements _not_ managed by the main menu dirty?
self.ui_dirty = True

# create the clock first; we need to assign its callbacks
# to other settings later
self.clock = MasterClock(120)

self.din_mode = SettingMenuItem(
config_point = ChoiceConfigPoint(
"din",
DIN_MODES,
DIN_MODE_GATE
),
prefix = "Clk",
title = "DIN Mode"
title = "DIN Mode",
)

self.clock = MasterClock(120)
self.channels = [
PamsOutput(cv1, self.clock, 1),
PamsOutput(cv2, self.clock, 2),
Expand Down Expand Up @@ -1460,13 +1495,35 @@ def __init__(self):
)
self.main_menu.load_defaults(self._state_filename)

## Keep an array of the last few intervals between incoming external clock signals
#
# Initially 1 microsecond just to avoid division-by-zero issues; 1us won't cause significant issues with the
# timing for most applications
self.external_clock_intervals_us = [1] * 2
self.next_external_clock_index = 0

## The time we received the last external clock signal in microseconds
self.last_external_clock_at_us = time.ticks_us()

@din.handler
def on_din_rising():
if self.din_mode.value == DIN_MODE_GATE:
self.clock.start()
elif self.din_mode.value == DIN_MODE_RESET:
for ch in self.channels:
ch.reset()
elif self.din_mode.value == DIN_MODE_EXTERNAL:
now = time.ticks_us()
self.external_clock_intervals_us[self.next_external_clock_index] = time.ticks_diff(now, self.last_external_clock_at_us)
self.last_external_clock_at_us = now
self.next_external_clock_index = self.next_external_clock_index + 1
if self.next_external_clock_index == len(self.external_clock_intervals_us):
self.next_external_clock_index = 0

# to keep the internal & external clocks from de-syncing too much, hard-sync
# the internal clock to the nearest beat
self.clock.elapsed_pulses = self.clock.PPQN * round(self.clock.elapsed_pulses / self.clock.PPQN)

else:
if self.clock.is_running:
self.clock.stop()
Expand Down Expand Up @@ -1536,6 +1593,7 @@ def save_bank(self, bank, channel):
def bank_filename(self, bank):
return f'saved_state_{self.__class__.__qualname__}_{bank.lower().replace(" ", "_")}.json'

@micropython.native
def main(self):
prev_k1 = CV_INS["KNOB"].percent()
prev_k2 = k2_bank.current.percent()
Expand All @@ -1547,13 +1605,34 @@ def main(self):
current_k1 = CV_INS["KNOB"].percent()
current_k2 = k2_bank.current.percent()

# Handle dynamic BPM calculations based on the external clock
if self.din_mode.value == DIN_MODE_EXTERNAL:
avg_duration = sum(self.external_clock_intervals_us) / len(self.external_clock_intervals_us)
bpm = round(us2bpm(avg_duration, 1))
if bpm < MasterClock.MIN_BPM:
bpm = MasterClock.MIN_BPM
elif bpm > MasterClock.MAX_BPM:
bpm = MasterClock.MAX_BPM

if bpm != self.clock.bpm.value:
self.clock.bpm.choose(bpm - MasterClock.MIN_BPM) # convert to a 0-based index, allowed range is [1, MAX_BPM]
self.clock.bpm.display_override = f"{bpm} (Ext)"
self.ui_dirty = True
else:
self.clock.bpm.display_override = None

# wake up from the screensaver if we rotate a knob
if abs(current_k1 - prev_k1) > 0.02 or abs(current_k2 - prev_k2) > 0.02:
self.ui_dirty = True
ssoled.notify_user_interaction()

# only re-render the UI if necessary
if self.main_menu.ui_dirty or self.ui_dirty:
if self.ui_dirty and self.clock.bpm.display_override and self.main_menu.active_item == self.clock.bpm:
# re-draw if the external BPM needs updating, but don't suppress the screensaver
ssoled.fill(0)
self.main_menu.draw(ssoled)
self.ui_dirty = False
elif self.main_menu.ui_dirty or self.ui_dirty:
ssoled.notify_user_interaction()
ssoled.fill(0)
self.main_menu.draw(ssoled)
Expand Down
Loading
Loading