pulsepal.synth_pal

Python interface for Synth Pal, a waveform synthesizer for Pulse Pal 3.

Synth Pal is alternative firmware for Pulse Pal 3 hardware. Each output channel plays a sine, triangle, square or sawtooth wave or white noise, or steps to a fixed voltage, when it is triggered: by a TTL pulse on a trigger channel, from software, or from the thumb joystick. Each channel has its own waveform, peak to peak voltage, mean voltage, resting voltage, play duration, and on and off ramps, and one frequency, 1 Hz to 20 kHz in steps of 0.01 Hz, applies to all four.

Everything is accessed through SynthPalDevice. Import it, connect to the device's serial port, set the waveforms, and trigger, e.g.

from pulsepal import SynthPalDevice

with SynthPalDevice("COM3") as S:
    S.frequency = 440                  # Hz, all channels
    S.waveform[1] = "Sine"
    S.peak_to_peak[1] = 4              # volts: swings from -2 V to 2 V
    S.play_duration[1] = 0.5           # seconds
    S.trigger(1)

The device needs Synth Pal firmware, which is in /Firmware/SynthPal in the Pulse Pal repository. Its USB protocol is documented in PROTOCOL.md.

Channel settings

Settings that apply to one output channel, such as SynthPalDevice.peak_to_peak, are lists indexed by channel number: index 0 is unused and holds None, and indices 1 to 4 hold the settings of output channels 1-4, as in pulsepal.PulsePalDevice. Setting an element or a slice programs the device at once. Assigning a whole list sets all four channels, and takes one value per channel: a single value raises an error, because it does not say which channels it is meant for.

S.peak_to_peak[2] = 5              # channel 2 only
S.play_duration[1:5] = [1, 2, 3, 4]
S.waveform = ["Square"] * 4        # all four channels

SynthPalDevice.trigger_mode is indexed the same way by trigger channel number, 1 or 2.

To set several of one channel's settings at once, use SynthPalDevice.configure:

S.configure(2, waveform="Triangle", peak_to_peak=20, mean_voltage=0)

Units and levels

Voltages are in volts, times in seconds, and frequencies in Hz. A periodic waveform, or white noise, swings peak_to_peak / 2 above and below its mean_voltage, and must stay within -10 V to 10 V: abs(mean_voltage) + peak_to_peak / 2 <= 10. A "Fixed Voltage" channel steps to its fixed_voltage, -10 V to 10 V, for its play duration. Between playbacks, a channel outputs its resting_voltage, and its on_ramp_duration and off_ramp_duration fade it in from there and back to it (see "Ramps" below).

Ramps

A channel's on ramp follows each trigger, and fades its waveform in from the resting voltage: its peak to peak voltage rises linearly from 0 to its full value, and its mean from the resting voltage to the mean voltage (a fixed voltage ramps from the resting voltage to its voltage). The play duration follows at full amplitude, then the off ramp fades back to the resting voltage. The off ramp also follows a stop: stop(), a toggle or gated trigger, or the joystick. So the ramps lengthen playback: from a trigger to rest takes on_ramp_duration + play_duration + off_ramp_duration. During its off ramp, a channel counts as stopping: a trigger fades it in again from where it is, without restarting its waveform's cycle, and plays its play duration again. A channel stopped during its on ramp fades out from where it is, at the off ramp's rate. The output never jumps.

Param sync

"Param Sync", Pulse Pal's param sync trigger mode, lets the settings of the next trial be sent during the current one, and applied the instant it starts. Change settings in a SynthPalDevice.batch block: they are kept here, and sent all at once when it ends. While a trigger channel is in param sync mode, the device stores the whole set, and that channel's next rising edge loads it.

S.trigger_mode[2] = "Param Sync"   # sent at once
with S.batch():
    S.frequency = 880
    S.peak_to_peak[1] = 2
# Stored for trigger channel 2's next rising edge

See SynthPalDevice.trigger_mode for what the edge does.

License

This file is part of the Sanworks PulsePal repository. Copyright (C) Sanworks LLC, Rochester, New York, USA

This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3.

This program is distributed WITHOUT ANY WARRANTY and without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.

You should have received a copy of the GNU General Public License along with this program. If not, see http://www.gnu.org/licenses/.

   1"""
   2Python interface for Synth Pal, a waveform synthesizer for Pulse Pal 3.
   3
   4Synth Pal is alternative firmware for Pulse Pal 3 hardware. Each output
   5channel plays a sine, triangle, square or sawtooth wave or white noise, or
   6steps to a fixed voltage, when it is triggered: by a TTL pulse on a trigger
   7channel, from software, or from the thumb joystick. Each channel has its own
   8waveform, peak to peak voltage, mean voltage, resting voltage, play
   9duration, and on and off ramps, and one frequency, 1 Hz to 20 kHz in steps
  10of 0.01 Hz, applies to all four.
  11
  12Everything is accessed through `SynthPalDevice`. Import it, connect to
  13the device's serial port, set the waveforms, and trigger, e.g.
  14
  15```python
  16from pulsepal import SynthPalDevice
  17
  18with SynthPalDevice("COM3") as S:
  19    S.frequency = 440                  # Hz, all channels
  20    S.waveform[1] = "Sine"
  21    S.peak_to_peak[1] = 4              # volts: swings from -2 V to 2 V
  22    S.play_duration[1] = 0.5           # seconds
  23    S.trigger(1)
  24```
  25
  26The device needs Synth Pal firmware, which is in
  27[/Firmware/SynthPal](https://github.com/sanworks/PulsePal/tree/develop/Firmware/SynthPal) in the
  28Pulse Pal repository. Its USB protocol is documented in
  29[PROTOCOL.md](https://github.com/sanworks/PulsePal/blob/develop/Firmware/SynthPal/PROTOCOL.md).
  30
  31## Channel settings
  32
  33Settings that apply to one output channel, such as
  34`SynthPalDevice.peak_to_peak`, are lists indexed by channel number: index
  350 is unused and holds `None`, and indices 1 to 4 hold the settings of
  36output channels 1-4, as in `pulsepal.PulsePalDevice`. Setting an element
  37or a slice programs the device at once. Assigning a whole list sets all
  38four channels, and takes one value per channel: a single value raises an
  39error, because it does not say which channels it is meant for.
  40
  41```python
  42S.peak_to_peak[2] = 5              # channel 2 only
  43S.play_duration[1:5] = [1, 2, 3, 4]
  44S.waveform = ["Square"] * 4        # all four channels
  45```
  46
  47`SynthPalDevice.trigger_mode` is indexed the same way by trigger
  48channel number, 1 or 2.
  49
  50To set several of one channel's settings at once, use
  51`SynthPalDevice.configure`:
  52
  53```python
  54S.configure(2, waveform="Triangle", peak_to_peak=20, mean_voltage=0)
  55```
  56
  57## Units and levels
  58
  59Voltages are in volts, times in seconds, and frequencies in Hz. A periodic
  60waveform, or white noise, swings `peak_to_peak / 2` above and below its
  61`mean_voltage`, and must stay within -10 V to 10 V:
  62`abs(mean_voltage) + peak_to_peak / 2 <= 10`. A `"Fixed Voltage"` channel
  63steps to its `fixed_voltage`, -10 V to 10 V, for its play duration. Between
  64playbacks, a channel outputs its `resting_voltage`, and its
  65`on_ramp_duration` and `off_ramp_duration` fade it in from there and back to
  66it (see "Ramps" below).
  67
  68## Ramps
  69
  70A channel's on ramp follows each trigger, and fades its waveform in from
  71the resting voltage: its peak to peak voltage rises linearly from 0 to
  72its full value, and its mean from the resting voltage to the mean voltage
  73(a fixed voltage ramps from the resting voltage to its voltage). The play
  74duration follows at full amplitude, then the off ramp fades back to the
  75resting voltage. The off ramp also follows a stop: `stop()`, a toggle or
  76gated trigger, or the joystick. So the ramps lengthen playback: from a
  77trigger to rest takes `on_ramp_duration + play_duration +
  78off_ramp_duration`. During its off ramp, a channel counts as stopping: a
  79trigger fades it in again from where it is, without restarting its
  80waveform's cycle, and plays its play duration again. A channel stopped
  81during its on ramp fades out from where it is, at the off ramp's rate.
  82The output never jumps.
  83
  84## Param sync
  85
  86`"Param Sync"`, Pulse Pal's param sync trigger mode, lets the settings of
  87the next trial be sent during the current one, and applied the instant it
  88starts. Change settings in a `SynthPalDevice.batch` block: they are kept
  89here, and sent all at once when it ends. While a trigger channel is in
  90param sync mode, the device stores the whole set, and that channel's next
  91rising edge loads it.
  92
  93```python
  94S.trigger_mode[2] = "Param Sync"   # sent at once
  95with S.batch():
  96    S.frequency = 880
  97    S.peak_to_peak[1] = 2
  98# Stored for trigger channel 2's next rising edge
  99```
 100
 101See `SynthPalDevice.trigger_mode` for what the edge does.
 102
 103## License
 104
 105This file is part of the Sanworks PulsePal repository.
 106Copyright (C) Sanworks LLC, Rochester, New York, USA
 107
 108This program is free software: you can redistribute it and/or modify
 109it under the terms of the GNU General Public License as published by
 110the Free Software Foundation, version 3.
 111
 112This program is distributed WITHOUT ANY WARRANTY and without even the
 113implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 114See the GNU General Public License for more details.
 115
 116You should have received a copy of the GNU General Public License
 117along with this program. If not, see <http://www.gnu.org/licenses/>.
 118"""
 119
 120from dataclasses import dataclass
 121import math
 122import numbers
 123import struct
 124import warnings
 125
 126import numpy as np
 127import serial
 128
 129from . import _common
 130from ._common import ChannelSettings, PulsePalError, to_bool, to_name
 131
 132__all__ = ["SynthPalDevice", "DeviceInfo", "DeviceStatus", "WAVEFORMS", "TRIGGER_MODES"]
 133__docformat__ = "google"
 134
 135WAVEFORMS = ("Sine", "Triangle", "Square", "Sawtooth", "Fixed Voltage", "White Noise")
 136"""Names of the waveforms, in order of their code on the device."""
 137
 138FIXED_VOLTAGE = "Fixed Voltage"
 139
 140TRIGGER_MODES = ("Normal", "Toggle", "Gated", "Param Sync")
 141"""Names of the trigger modes, in order of their code on the device. These
 142are Pulse Pal's trigger modes, with the same codes."""
 143
 144# The output ranges the device chooses from, in order of their index on the
 145# device, with their limits in volts. See "Output ranges" in
 146# /Firmware/SynthPal/PROTOCOL.md.
 147OUTPUT_RANGES = {
 148    "0V:5V": (0.0, 5.0),
 149    "0V:10V": (0.0, 10.0),
 150    "-5V:5V": (-5.0, 5.0),
 151    "-10V:10V": (-10.0, 10.0),
 152}
 153
 154_MAX_VOLTAGE_UV = 10_000_000  # Every output voltage stays within +/-10 V
 155
 156# The settings sync_to_device() sends, in the class's order
 157_CHANNEL_SETTINGS = ("waveform", "peak_to_peak", "fixed_voltage", "mean_voltage", "resting_voltage",
 158                     "play_duration", "on_ramp_duration", "off_ramp_duration", "trigger_mode",
 159                     "link_trigger_channel1", "link_trigger_channel2")
 160_SETTING_NAMES = ("frequency", *_CHANNEL_SETTINGS, "auto_sync")  # The names a script assigns
 161# The ops whose reply _write_command() does not track: those with no reply ('P', 'X', 81 and 89), and the handshake
 162# (72), whose reply read_handshake_reply() reads until the device is quiet
 163_OPS_WITHOUT_TRACKED_REPLY = frozenset({72, 81, 89, ord("P"), ord("X")})
 164
 165
 166def _samples_per_cycle(centihz):
 167    """Samples in one cycle at a frequency, as the device works it out (see SynthPalDevice.samples_per_cycle)."""
 168    return 4 * (2_500_000 // centihz)
 169
 170
 171class _Rejected(PulsePalError):
 172    """The device replied 0: it rejected a command, and changed nothing."""
 173
 174
 175def _is_valid_level(waveform, amplitude_uv, mean_uv):
 176    """Whether the device accepts an output channel's waveform, amplitude and mean voltage together, as
 177    isValidOutputLevel() in /Firmware/SynthPal/Settings.ino checks them. On the device, a channel has one
 178    amplitude: a fixed voltage's voltage, or a periodic waveform's peak to peak voltage."""
 179    if waveform == FIXED_VOLTAGE:
 180        return abs(amplitude_uv) <= _MAX_VOLTAGE_UV
 181    return 0 <= amplitude_uv and 2 * abs(mean_uv) + amplitude_uv <= 2 * _MAX_VOLTAGE_UV
 182
 183
 184def _microvolts(volts):
 185    return round(volts * 1e6)
 186
 187
 188@dataclass
 189class DeviceInfo:
 190    """Properties of the connected Synth Pal.
 191
 192    Populated when `SynthPalDevice` connects, and available as
 193    `SynthPalDevice.info`.
 194    """
 195
 196    firmware_version: int = None
 197    """Synth Pal firmware version running on the device."""
 198
 199    hardware_version: int = None
 200    """Pulse Pal hardware version, e.g. `3`."""
 201
 202    circuit_board_revision: int = None
 203    """Revision of the device's circuit board, 0-31.
 204
 205    The board encodes its revision on five pins, which the firmware reads
 206    at startup. A board that encodes none reports `0`. To record exactly
 207    which hardware ran an experiment, copy `hardware_version` and
 208    `circuit_board_revision` to your data.
 209    """
 210
 211    serial_number: str = None
 212    """USB serial number of the device, e.g. `"20345420"`: the Teensy's,
 213    unique to each Pulse Pal 3, and the same whichever firmware it runs.
 214
 215    Connect by it, wherever the device is plugged in, with
 216    `SynthPalDevice(serial_number="20345420")`. To record exactly which unit ran an
 217    experiment, copy it to your data.
 218    """
 219
 220    n_channels: int = None
 221    """Number of output channels."""
 222
 223    min_frequency: float = None
 224    """Lowest frequency, in Hz."""
 225
 226    max_frequency: float = None
 227    """Highest frequency, in Hz."""
 228
 229    max_sampling_rate: int = None
 230    """Highest sampling rate, in Hz. See `SynthPalDevice.sampling_rate`."""
 231
 232    timer_clock_hz: int = None
 233    """Clock that the sample clock counts, in Hz."""
 234
 235    max_play_duration: float = None
 236    """Longest play duration, in seconds. See
 237    `SynthPalDevice.play_duration`."""
 238
 239    waveforms: tuple = WAVEFORMS
 240    """Names of the waveforms, accepted by `SynthPalDevice.waveform`."""
 241
 242    trigger_modes: tuple = TRIGGER_MODES
 243    """Names of the trigger modes, accepted by
 244    `SynthPalDevice.trigger_mode`."""
 245
 246
 247@dataclass
 248class DeviceStatus:
 249    """A snapshot of the device's playback state, from
 250    `SynthPalDevice.status`."""
 251
 252    playing: list
 253    """Numbers of the output channels that are playing, e.g. `[1, 3]`.
 254
 255    A channel in its off ramp counts as playing until it reaches its
 256    resting voltage."""
 257
 258    samples_per_cycle: int
 259    """Samples in one cycle of the waveform. See
 260    `SynthPalDevice.samples_per_cycle`."""
 261
 262    output_ranges: list
 263    """The output range the device chose for each channel, indexed by
 264    channel number, with index 0 unused, e.g. `"-5V:5V"`.
 265
 266    Each channel uses the range with the finest voltage steps that holds
 267    its whole waveform and its resting voltage: `"0V:5V"` (76 uV steps),
 268    then `"0V:10V"` or `"-5V:5V"` (153 uV), then `"-10V:10V"` (305 uV).
 269    """
 270
 271    longest_interrupt_us: float
 272    """Longest run of the device's sample clock interrupt since the
 273    previous call to `SynthPalDevice.status`, in microseconds.
 274
 275    It must stay below the sample period, `1e6 / sampling_rate`.
 276    """
 277
 278    late_updates: int
 279    """Output updates since the previous call to `SynthPalDevice.status`
 280    that may have come later than their fixed time after a sample clock
 281    tick. It is normally 0; a change of frequency during playback can
 282    make one update late. See "Timing" in the
 283    [Synth Pal protocol](https://github.com/sanworks/PulsePal/blob/develop/Firmware/SynthPal/PROTOCOL.md#timing).
 284    """
 285
 286
 287class SynthPalDevice:
 288    """A class to control a Synth Pal on a USB serial port.
 289
 290    Creating an instance opens the serial port, checks that the device
 291    runs Synth Pal firmware, reads its properties into
 292    `SynthPalDevice.info`, shows "PYTHON Connected" on the device's
 293    screen, stops any playback and programs the default settings (see
 294    `SynthPalDevice.set_default_params`).
 295
 296    ```python
 297    from pulsepal import SynthPalDevice
 298
 299    S = SynthPalDevice("COM3")
 300    S.waveform[1] = "Triangle"
 301    S.trigger(1)
 302    S.close()
 303    ```
 304
 305    Replace "COM3" with the device's USB serial port name, which
 306    `SynthPalDevice.serialportlist` lists. `SynthPalDevice` is also a
 307    context manager, which closes the port on exit.
 308
 309    Closing the connection stops playback, as it does on Pulse Pal (each
 310    channel over its off ramp), and puts the device's own name back on its
 311    screen. The device keeps its settings, so TTL triggers still play the
 312    channels.
 313    """
 314
 315    port: "serial.Serial"
 316    """The open `serial.Serial` port connected to the device."""
 317
 318    info: DeviceInfo
 319    """Properties of the connected device. See `DeviceInfo`."""
 320
 321    _CURRENT_FIRMWARE_VERSION = 1
 322    _BAUD_RATE = 12000000  # USB serial ignores the baud rate
 323
 324    _OP_MENU_BYTE = _common.OP_MENU_BYTE
 325    _OP_HANDSHAKE = _common.OP_HANDSHAKE
 326    _OP_DISCONNECT = 81
 327    _OP_SET_CLIENT_NAME = 89
 328    _OP_SET_SCREEN_SAVER = 99
 329    _OP_HARDWARE_REVISION = 100
 330    _OP_HARDWARE_INFO = ord("N")
 331    _OP_SET_FREQUENCY = ord("F")
 332    _OP_SET_WAVEFORM = ord("W")
 333    _OP_SET_AMPLITUDE = ord("A")
 334    _OP_SET_RESTING_VOLTAGE = ord("V")
 335    _OP_SET_MEAN_VOLTAGE = ord("M")
 336    _OP_SET_PLAY_DURATION = ord("D")
 337    _OP_SET_ON_RAMP_DURATION = ord("B")
 338    _OP_SET_OFF_RAMP_DURATION = ord("E")
 339    _OP_SET_TRIGGER_LINKS = ord("I")
 340    _OP_SET_TRIGGER_MODE = ord("T")
 341    _OP_SET_ALL_SETTINGS = ord("U")
 342    _OP_GET_ALL_SETTINGS = ord("R")
 343    # Every setting, as ops 85 ('U') and 82 ('R') carry them: frequency (centiHz), waveform codes, amplitudes,
 344    # mean and resting voltages (uV), play, on ramp and off ramp durations (us), links, trigger modes
 345    _SETTINGS_FORMAT = "<I4B4i4i4i4I4I4I8B2B"
 346    _OP_TRIGGER = ord("P")
 347    _OP_STOP = ord("X")
 348    _OP_GET_STATUS = ord("G")
 349    _OP_GET_PLAYBACK_CHECKSUMS = ord("Z")
 350
 351    _HANDSHAKE_REPLY = 83  # 'S'
 352    _HARDWARE_INFO_FORMAT = "<BBIIIII"
 353    _STATUS_FORMAT = "<BI4BII"
 354    _ALL_CHANNELS = 0x0F
 355    # The ops that change settings: if the reply to one is lost, they are read back (_discard_lost_reply())
 356    _SETTING_OPS = frozenset(map(ord, "FWAVMDBEITU"))
 357
 358    _reply_pending = None  # The op code of a command whose reply has not been read in full yet (see _write_command())
 359    _unsent = frozenset()  # The settings assigned with auto_sync off since the settings were last sent or read
 360
 361    def __init__(self, port_name=None, *, serial_number=None, timeout=10):
 362        """Open a connection to a Synth Pal.
 363
 364        Args:
 365            port_name: USB serial port of the device, such as `COM3` on
 366                Windows or `/dev/ttyACM0` on Linux.
 367            serial_number: Instead of `port_name`, the device's USB serial
 368                number (`DeviceInfo.serial_number`), which finds its port
 369                wherever it is plugged in.
 370            timeout: Serial read timeout, in seconds.
 371
 372        Firmware newer than this module knows is used with a warning: new
 373        firmware only adds commands. Update the package to use what is new.
 374
 375        Raises:
 376            PulsePalError: If the device does not reply to the handshake, or
 377                runs other firmware.
 378            serial.SerialException: If the serial port cannot be opened,
 379                e.g. because another program has it open (on Linux and
 380                macOS, another session of these classes, which lock it).
 381        """
 382        self._closed = True
 383        self._auto_sync = True
 384        self.info = DeviceInfo()
 385        self._frequency = None
 386        self._samples_per_cycle = None
 387        # What the device holds for each output channel's waveform, amplitude (in microvolts: a periodic
 388        # waveform's peak to peak voltage, or a fixed voltage) and mean voltage (in microvolts), as far as this
 389        # object has programmed them. _send_levels() changes them in an order the device accepts.
 390        self._device_waveform = ["Sine"] * 4
 391        self._device_amplitude_uv = [5_000_000] * 4
 392        self._device_mean_uv = [0] * 4
 393        self._waveform = ChannelSettings(
 394            "waveform", ["Sine"] * 4, self, "_apply_waveform")
 395        self._peak_to_peak = ChannelSettings(
 396            "peak_to_peak", [5.0] * 4, self, "_apply_peak_to_peak")
 397        self._fixed_voltage = ChannelSettings(
 398            "fixed_voltage", [5.0] * 4, self, "_apply_fixed_voltage")
 399        self._mean_voltage = ChannelSettings(
 400            "mean_voltage", [0.0] * 4, self, "_apply_mean_voltage")
 401        self._resting_voltage = ChannelSettings(
 402            "resting_voltage", [0.0] * 4, self, "_apply_resting_voltage")
 403        self._play_duration = ChannelSettings(
 404            "play_duration", [1.0] * 4, self, "_apply_play_duration")
 405        self._on_ramp_duration = ChannelSettings(
 406            "on_ramp_duration", [0.0] * 4, self, "_apply_on_ramp_duration")
 407        self._off_ramp_duration = ChannelSettings(
 408            "off_ramp_duration", [0.0] * 4, self, "_apply_off_ramp_duration")
 409        self._trigger_mode = ChannelSettings(
 410            "trigger_mode", ["Normal"] * 2, self, "_apply_trigger_mode")
 411        self._link_trigger_channel1 = ChannelSettings(
 412            "link_trigger_channel1", [False] * 4, self,
 413            "_apply_trigger_channel1_links")
 414        self._link_trigger_channel2 = ChannelSettings(
 415            "link_trigger_channel2", [False] * 4, self,
 416            "_apply_trigger_channel2_links")
 417
 418        port_name = _common.port_name_from(port_name, serial_number, "SynthPalDevice")
 419        self.port = serial.Serial(
 420            port_name,
 421            self._BAUD_RATE,
 422            timeout=timeout,
 423            rtscts=True,
 424            # Windows opens a port for one program only. Linux and macOS let a second program open it too, and two
 425            # sessions' bytes would then interleave; exclusive locks it (flock), so a second session that also locks
 426            # it, such as another of these classes, gets an error instead
 427            exclusive=True,
 428        )
 429        self._closed = False
 430        try:
 431            # Discard anything left in the buffer by an earlier session
 432            self.port.reset_input_buffer()
 433            self._handshake()
 434            self._read_hardware_info()
 435            # Client name op + "PYTHON" in ASCII, shown as "PYTHON Connected"
 436            self._write_command(self._OP_SET_CLIENT_NAME, b"PYTHON")
 437            self.stop()
 438            self.set_default_params()
 439        except BaseException:
 440            # Op 81 means something else to other devices, so it is sent
 441            # only once the device has identified itself as a Synth Pal
 442            self._close(send_disconnect=self.info.firmware_version is not None)
 443            raise
 444
 445    @staticmethod
 446    def serialportlist(ports_to_list="available"):
 447        """Return the names of the USB serial ports on this computer.
 448
 449        Called on the class, without connecting to a device, to find the
 450        port name to pass to `SynthPalDevice`.
 451
 452        Args:
 453            ports_to_list: `available` to list only the ports that are
 454                not already in use, or `all` to list every USB serial
 455                port. Not case sensitive.
 456
 457        Returns:
 458            Sorted list of port names, such as `["COM3", "COM7"]`.
 459
 460        Raises:
 461            PulsePalError: If `ports_to_list` is not `available` or `all`.
 462        """
 463        return _common.serialportlist(ports_to_list)
 464
 465    # ------------------------------------------------------------------
 466    # Settings
 467    # ------------------------------------------------------------------
 468
 469    def set_default_params(self):
 470        """Program the default settings on the device.
 471
 472        The defaults are a frequency of 100 Hz, and on every output
 473        channel a sine wave of 5 V peak to peak around a mean voltage of
 474        0 V, resting at 0 V, played for 1 second with no ramps, and a
 475        fixed voltage of 5 V for when the waveform is "Fixed Voltage".
 476        Both trigger channels are in normal mode, and no output channel is
 477        linked to a trigger channel, so a TTL starts nothing until a script
 478        links it. They match the settings the device starts with. They are sent at once,
 479        also while `SynthPalDevice.auto_sync` is off.
 480        """
 481        auto_sync = self._auto_sync
 482        self._auto_sync = True
 483        try:
 484            self.frequency = 100
 485            # In this order, each is valid whatever the device holds: a mean of 0 V goes with any
 486            # amplitude, 5 V is then a valid amplitude for any waveform, and a sine wave is then valid
 487            self._send_levels_in_order((("M", [0] * 4), ("A", [5_000_000] * 4), ("W", ["Sine"] * 4)),
 488                                       force=True)
 489            for settings, value in ((self._waveform, "Sine"), (self._peak_to_peak, 5.0),
 490                                    (self._fixed_voltage, 5.0), (self._mean_voltage, 0.0)):
 491                settings._store([value] * 4)
 492            self.resting_voltage = [0] * 4
 493            self.play_duration = [1] * 4
 494            self.on_ramp_duration = [0] * 4
 495            self.off_ramp_duration = [0] * 4
 496            self.trigger_mode = ["Normal"] * 2
 497            self._set_trigger_links([False] * 4, [False] * 4)
 498            self._unsent = frozenset()  # Every setting has been sent
 499        finally:
 500            self._auto_sync = auto_sync
 501
 502    @property
 503    def frequency(self):
 504        """Frequency of all output channels, in Hz.
 505
 506        1 to 20000 Hz, rounded to 0.01 Hz. It can be changed during
 507        playback: playing channels carry on from the same point in their
 508        cycle, at the new frequency, and keep the time they have left to
 509        play. The frequency played is exact: the device's sample clock is a
 510        whole multiple of it (see `SynthPalDevice.sampling_rate`). For
 511        `"White Noise"` it sets only the sampling rate.
 512        """
 513        return self._frequency
 514
 515    @frequency.setter
 516    def frequency(self, value):
 517        if isinstance(value, (bool, np.bool_)) or not isinstance(value, numbers.Real) \
 518                or not math.isfinite(value):
 519            raise PulsePalError(
 520                f"frequency must be a number of Hz. Received {value!r}.")
 521        centihz = round(value * 100)
 522        low = round(self.info.min_frequency * 100)
 523        high = round(self.info.max_frequency * 100)
 524        if not low <= centihz <= high:
 525            raise PulsePalError(
 526                f"frequency must be {self.info.min_frequency:g} to "
 527                f"{self.info.max_frequency:g} Hz. Received {value!r}."
 528            )
 529        if self._auto_sync:
 530            self._write_command(self._OP_SET_FREQUENCY,
 531                                struct.pack("<I", centihz))
 532            confirmed, samples_per_cycle = struct.unpack(
 533                "<BI", self._read_raw(5))
 534            if confirmed != 1:
 535                raise PulsePalError(
 536                    f"Synth Pal rejected the frequency {value!r} Hz.")
 537        else:
 538            samples_per_cycle = _samples_per_cycle(centihz)
 539            self._unsent = self._unsent | {"frequency"}  # Until sync_to_device() sends it (see auto_sync)
 540        self._frequency = centihz / 100
 541        self._samples_per_cycle = samples_per_cycle
 542
 543    @property
 544    def auto_sync(self):
 545        """Whether assigning a setting programs the device at once.
 546
 547        `True` (the default): each assignment, such as
 548        `S.peak_to_peak[1] = 2`, programs the device at once, as the
 549        device's own op for that setting does, also while a trigger
 550        channel is in param sync mode.
 551
 552        `False`: assignments change only this object's copy of the settings,
 553        checking each value; `SynthPalDevice.sync_to_device` then sends all
 554        of them at once. Use it to store the next trial's settings for a
 555        param sync edge (see `SynthPalDevice.trigger_mode`), or to change
 556        several settings in one command. Turning it back on does not send
 557        changes made meanwhile, so it raises `pulsepal.PulsePalError` while
 558        there are any: call `SynthPalDevice.sync_to_device` first to send
 559        them, or `SynthPalDevice.sync_from_device` to discard them. A
 560        `SynthPalDevice.batch` block does it all for you. `close()` warns of
 561        changes never sent. `trigger`, `stop`, `status` and
 562        `set_default_params` act at once either way.
 563        """
 564        return self._auto_sync
 565
 566    @auto_sync.setter
 567    def auto_sync(self, value):
 568        value = to_bool(value, "auto_sync")
 569        if value and self._unsent:
 570            # Turned on, auto_sync would leave them unsent, and the device playing its old settings
 571            raise PulsePalError(
 572                f"Changes made with auto_sync off have not been sent to the device: {self._unsent_names()}. Call "
 573                "sync_to_device() to send them, or sync_from_device() to discard them, before turning auto_sync on."
 574            )
 575        self._auto_sync = value
 576
 577    def batch(self):
 578        """Change several settings, and send them in one command.
 579
 580        Returns a context manager. In its block, assignments change only
 581        this object's copy of the settings (as with
 582        `SynthPalDevice.auto_sync` off), and when the block ends,
 583        `SynthPalDevice.sync_to_device` sends all of them at once. If the
 584        block raises an error, nothing is sent, and the settings go back
 585        to what they were.
 586
 587        ```python
 588        with S.batch():
 589            S.frequency = 880
 590            S.waveform = ["Triangle"] * 4
 591            S.peak_to_peak = [2, 4, 6, 8]
 592        ```
 593
 594        In param sync mode, the device stores the settings for the next
 595        rising edge on the param sync channel (see
 596        `SynthPalDevice.trigger_mode`).
 597        """
 598        return _common.batch(self)
 599
 600    def sync_from_device(self):
 601        """Read every setting from the device into this object.
 602
 603        Use it after settings were changed with the device's joystick, for
 604        example. The device holds one amplitude per channel: a Fixed
 605        Voltage channel's goes to `SynthPalDevice.fixed_voltage`, and any
 606        other channel's to `SynthPalDevice.peak_to_peak`. This object keeps
 607        the other one, reduced if need be to suit the mean voltage read
 608        back. In param sync mode, it reads the settings the device plays
 609        now, not a set waiting for the next edge. Changes made with
 610        `SynthPalDevice.auto_sync` off and not yet sent are discarded.
 611
 612        Raises:
 613            PulsePalError: If the device does not reply.
 614        """
 615        self._write_command(self._OP_GET_ALL_SETTINGS)
 616        values = struct.unpack(self._SETTINGS_FORMAT, self._read_raw(struct.calcsize(self._SETTINGS_FORMAT)))
 617        centihz = values[0]
 618        waveforms = [WAVEFORMS[code] for code in values[1:5]]
 619        amplitudes, means = list(values[5:9]), list(values[9:13])
 620        peak_to_peak, fixed_voltage = list(self._peak_to_peak)[1:], list(self._fixed_voltage)[1:]
 621        for i, (name, amplitude, mean) in enumerate(zip(waveforms, amplitudes, means)):
 622            if name == FIXED_VOLTAGE:
 623                fixed_voltage[i] = amplitude / 1e6
 624                # This object's own peak to peak voltage must still suit the mean voltage (see _check_levels())
 625                peak_to_peak[i] = min(peak_to_peak[i], (2 * _MAX_VOLTAGE_UV - 2 * abs(mean)) / 1e6)
 626            else:
 627                peak_to_peak[i] = amplitude / 1e6
 628        self._frequency = centihz / 100
 629        self._samples_per_cycle = _samples_per_cycle(centihz)
 630        self._waveform._store(waveforms)
 631        self._peak_to_peak._store(peak_to_peak)
 632        self._fixed_voltage._store(fixed_voltage)
 633        self._mean_voltage._store([mean / 1e6 for mean in means])
 634        self._resting_voltage._store([rest / 1e6 for rest in values[13:17]])
 635        self._play_duration._store([d / 1e6 for d in values[17:21]])
 636        self._on_ramp_duration._store([d / 1e6 for d in values[21:25]])
 637        self._off_ramp_duration._store([d / 1e6 for d in values[25:29]])
 638        self._link_trigger_channel1._store([bool(link) for link in values[29:33]])
 639        self._link_trigger_channel2._store([bool(link) for link in values[33:37]])
 640        self._trigger_mode._store([TRIGGER_MODES[code] for code in values[37:39]])
 641        self._device_waveform, self._device_amplitude_uv, self._device_mean_uv = waveforms, amplitudes, means
 642        self._unsent = frozenset()  # Every setting is read
 643
 644    def export_params(self):
 645        """Return every setting, as a dict of plain values and lists.
 646
 647        Keyed by setting name: `"frequency"`, then the channel settings,
 648        each with one value per channel and no unused index 0. It holds
 649        only numbers, booleans and names, so it can be saved with `json`
 650        and logged with your data, to record exactly what the device
 651        played.
 652
 653        ```python
 654        import json
 655        with open("trial_settings.json", "w") as f:
 656            json.dump(S.export_params(), f)
 657        ```
 658        """
 659        params = {"frequency": self._frequency}
 660        params.update({name: list(getattr(self, name))[1:] for name in _CHANNEL_SETTINGS})
 661        return params
 662
 663    def import_params(self, params):
 664        """Program the device with settings exported by `SynthPalDevice.export_params`.
 665
 666        All of them are checked first, and then sent in one command, as
 667        at the end of a `SynthPalDevice.batch` block: in param sync mode,
 668        the device stores them for the next sync edge. The levels
 669        (`waveform`, `peak_to_peak`, `fixed_voltage`, `mean_voltage`) are
 670        checked together, so their order in `params` does not matter.
 671        Settings missing from `params` keep their values. A name that is
 672        not a setting is skipped with a warning, so that settings exported
 673        by a newer version of this package, which may have more, can still
 674        be imported.
 675
 676        ```python
 677        import json
 678        with open("trial_settings.json") as f:
 679            S.import_params(json.load(f))
 680        ```
 681
 682        Args:
 683            params: A dict of setting name to its value: a number for
 684                `"frequency"`, and one value per channel (a list, tuple or
 685                NumPy array) for the others, as `export_params` returns.
 686
 687        Raises:
 688            PulsePalError: If a value is invalid. Nothing is sent, and the
 689                settings keep the values they had.
 690        """
 691        names = {"frequency", *_CHANNEL_SETTINGS}
 692        unknown = [name for name in params if name not in names]
 693        if unknown:
 694            warnings.warn(f"import_params(): skipped {', '.join(map(repr, unknown))}, which this version of "
 695                          "SynthPalDevice does not have.", stacklevel=2)
 696        levels = ("waveform", "peak_to_peak", "fixed_voltage", "mean_voltage")
 697        with self.batch():
 698            for name, values in params.items():
 699                if name in levels:
 700                    # Each is checked alone here, and the levels together when the block ends (_check_sync()):
 701                    # an assignment would check each against the others' old values
 702                    getattr(self, name)._store(
 703                        self._normalize(name, _common.one_value_per_channel(name, values, 4)))
 704                elif name in names:
 705                    setattr(self, name, values)
 706
 707    def sync_to_device(self):
 708        """Send every setting to the device in one command.
 709
 710        While a trigger channel is in param sync mode, the device stores
 711        the set, replacing any set stored before, and that channel's next
 712        rising edge loads it (see `SynthPalDevice.trigger_mode`). Otherwise
 713        the device applies it at once. The settings sent are this object's:
 714        `frequency`, and for each channel `waveform`, `peak_to_peak` or
 715        `fixed_voltage`, `mean_voltage`, `resting_voltage`,
 716        `play_duration`, `on_ramp_duration`, `off_ramp_duration`,
 717        `link_trigger_channel1` and `link_trigger_channel2`, and
 718        `trigger_mode`. They read as sent, including while a set waits for
 719        its edge.
 720
 721        Raises:
 722            PulsePalError: If the device rejects the set.
 723        """
 724        self._check_sync()
 725        self._send_sync()
 726
 727    def configure(self, channels, *, waveform=None, peak_to_peak=None, fixed_voltage=None,
 728                  mean_voltage=None, resting_voltage=None, play_duration=None,
 729                  on_ramp_duration=None, off_ramp_duration=None):
 730        """Set several of the output channels' settings at once.
 731
 732        Settings left out keep their values. The new settings are checked
 733        together, so their order does not matter: for example, a channel
 734        whose sine wave swings 2 V around a mean voltage of 9 V can go
 735        straight to 20 V peak to peak around 0 V. With
 736        `SynthPalDevice.auto_sync` on, the class programs the device in an
 737        order it accepts at every step.
 738
 739        ```python
 740        S.configure(1, waveform="Sine", peak_to_peak=20, mean_voltage=0)
 741        S.configure([2, 3], waveform="Fixed Voltage", fixed_voltage=-2.5,
 742                    play_duration=0.1)
 743        ```
 744
 745        Args:
 746            channels: Output channel number 1-4, or several as a list,
 747                tuple or NumPy array.
 748            waveform, peak_to_peak, fixed_voltage, mean_voltage,
 749                resting_voltage, play_duration, on_ramp_duration,
 750                off_ramp_duration: New values, as for the settings of the
 751                same names: one value for every channel given, or a list
 752                with one value per channel given, in the same order.
 753
 754        Raises:
 755            PulsePalError: If a value is out of range, or the settings do
 756                not go together (nothing is sent), or the device rejects
 757                them.
 758        """
 759        channels = _common.channel_numbers(channels)
 760        if len(set(channels)) != len(channels):
 761            raise PulsePalError(f"configure(): channels must be distinct. Received {channels!r}.")
 762        requested = {"waveform": waveform, "peak_to_peak": peak_to_peak, "fixed_voltage": fixed_voltage,
 763                     "mean_voltage": mean_voltage, "resting_voltage": resting_voltage,
 764                     "play_duration": play_duration, "on_ramp_duration": on_ramp_duration,
 765                     "off_ramp_duration": off_ramp_duration}
 766        new_values = {}
 767        for name, value in requested.items():
 768            if value is None:
 769                continue
 770            values = list(value) if isinstance(value, (list, tuple, np.ndarray)) else [value]
 771            if len(values) == 1:
 772                values = values * len(channels)
 773            if len(values) != len(channels):
 774                raise PulsePalError(
 775                    f"configure(): {name} has {len(values)} values for {len(channels)} channels. Give one "
 776                    "value, or one value per channel."
 777                )
 778            settings = list(getattr(self, name))[1:]
 779            for channel, item in zip(channels, values):
 780                settings[channel - 1] = item
 781            new_values[name] = self._normalize(name, settings)
 782        levels = {name: new_values.get(name, list(getattr(self, name))[1:])
 783                  for name in ("waveform", "peak_to_peak", "fixed_voltage", "mean_voltage")}
 784        self._check_levels(levels["peak_to_peak"], levels["mean_voltage"], "configure")
 785        if self._auto_sync:
 786            self._send_levels(*self._device_levels(**levels))
 787        else:
 788            self._unsent = self._unsent | set(new_values)  # Until sync_to_device() sends them (see auto_sync)
 789        for name in levels:
 790            if name in new_values:
 791                getattr(self, name)._store(new_values[name])
 792        ops = {"resting_voltage": self._OP_SET_RESTING_VOLTAGE, "play_duration": self._OP_SET_PLAY_DURATION,
 793               "on_ramp_duration": self._OP_SET_ON_RAMP_DURATION,
 794               "off_ramp_duration": self._OP_SET_OFF_RAMP_DURATION}
 795        for name, op in ops.items():
 796            if name in new_values:
 797                if self._auto_sync:
 798                    self._send_setting(name, op, new_values[name])
 799                getattr(self, name)._store(new_values[name])
 800
 801    @property
 802    def samples_per_cycle(self):
 803        """Samples in one cycle of the waveform, at `frequency`.
 804
 805        The largest multiple of 4 whose sampling rate is at most
 806        `DeviceInfo.max_sampling_rate` (100 kHz), so that a sample falls
 807        on every edge, peak and trough of every waveform. For example,
 808        332 at 300 Hz and 4 at 20 kHz.
 809        """
 810        return self._samples_per_cycle
 811
 812    @property
 813    def sampling_rate(self):
 814        """The rate at which the device plays samples, in Hz.
 815
 816        `samples_per_cycle` times `frequency`: 100 kHz or just below it,
 817        and at least 50 kHz. For example, 100 kHz at 100 Hz, 99.6 kHz at
 818        300 Hz, and 80 kHz at 20 kHz. The device's timer cannot divide
 819        every rate exactly, so sample periods differ by up to one tick of
 820        its 24 MHz clock (42 ns), arranged so that the frequency is exact.
 821        """
 822        return self._samples_per_cycle * self._frequency
 823
 824    @property
 825    def waveform(self):
 826        """The waveform of each output channel.
 827
 828        Indexed by channel number (see "Channel settings" above). Each
 829        cycle starts at the trigger:
 830
 831        - `"Sine"` and `"Triangle"`: start at the mean voltage, rising.
 832        - `"Square"`: high for the first half of each cycle, then low.
 833        - `"Sawtooth"`: rises from its lowest voltage to its highest, then
 834          falls back at the end of the cycle.
 835        - `"Fixed Voltage"`: steps to the channel's
 836          `SynthPalDevice.fixed_voltage` for the play duration, and
 837          returns to the resting voltage. It is not periodic, so the
 838          frequency does not change it.
 839        - `"White Noise"`: a new random voltage on every sample, uniform
 840          over the peak to peak voltage around the mean voltage. It is
 841          rendered at the sampling rate the frequency sets
 842          (`SynthPalDevice.sampling_rate`), so no claim is made about its
 843          spectrum. Each channel has its own random sequence, which carries
 844          on from one playback to the next: every playback is new noise.
 845          The sequences start from the same point when the device powers
 846          up.
 847
 848        The periodic waveforms and white noise swing
 849        `SynthPalDevice.peak_to_peak` around `SynthPalDevice.mean_voltage`.
 850        Names are not case sensitive. A change applies to playback in
 851        progress.
 852        """
 853        return self._waveform
 854
 855    @waveform.setter
 856    def waveform(self, values):
 857        self._waveform._assign(values)
 858
 859    @property
 860    def peak_to_peak(self):
 861        """The peak to peak voltage of each output channel's waveform, in volts.
 862
 863        Indexed by channel number (see "Channel settings" above). 0 to
 864        20 V: the waveform swings `peak_to_peak / 2` above and below the
 865        channel's `SynthPalDevice.mean_voltage`, so it must stay within
 866        -10 V to 10 V: `abs(mean_voltage) + peak_to_peak / 2 <= 10`. To
 867        raise it beyond what the mean voltage allows, change the mean
 868        voltage first, or set both with `SynthPalDevice.configure`.
 869
 870        A sine wave of 4 V peak to peak swings from -2 V to 2 V around a
 871        mean voltage of 0 V: it is 2 sin(2 pi f t). White noise of 4 V peak
 872        to peak is uniform from -2 V to 2 V. A `"Fixed Voltage"` channel
 873        keeps its peak to peak voltage for when it plays a periodic waveform
 874        again.
 875
 876        Set to the nearest microvolt. A change applies to playback in
 877        progress.
 878        """
 879        return self._peak_to_peak
 880
 881    @peak_to_peak.setter
 882    def peak_to_peak(self, values):
 883        self._peak_to_peak._assign(values)
 884
 885    @property
 886    def fixed_voltage(self):
 887        """The voltage each output channel steps to when its waveform is
 888        `"Fixed Voltage"`, in volts.
 889
 890        Indexed by channel number (see "Channel settings" above). -10 to
 891        10 V, with any resting voltage and mean voltage. A channel playing
 892        another waveform keeps its fixed voltage for when its waveform is
 893        `"Fixed Voltage"` again.
 894
 895        Set to the nearest microvolt. A change applies to playback in
 896        progress.
 897        """
 898        return self._fixed_voltage
 899
 900    @fixed_voltage.setter
 901    def fixed_voltage(self, values):
 902        self._fixed_voltage._assign(values)
 903
 904    @property
 905    def mean_voltage(self):
 906        """The mean voltage of each output channel's waveform, in volts.
 907
 908        Indexed by channel number (see "Channel settings" above). -10 to
 909        10 V. A periodic waveform, or white noise, swings around it at full
 910        amplitude, and must stay within -10 V to 10 V with the
 911        `peak_to_peak` voltage: `abs(mean_voltage) + peak_to_peak / 2 <= 10`.
 912        It may differ from the `resting_voltage`, which the channel
 913        outputs between playbacks: the ramps move the mean between the two
 914        (see "Ramps" above). A `"Fixed Voltage"` ignores it. Set to the
 915        nearest microvolt. A change applies to playback in progress.
 916        """
 917        return self._mean_voltage
 918
 919    @mean_voltage.setter
 920    def mean_voltage(self, values):
 921        self._mean_voltage._assign(values)
 922
 923    @property
 924    def resting_voltage(self):
 925        """The resting voltage of each output channel, in volts.
 926
 927        Indexed by channel number (see "Channel settings" above). -10 to
 928        10 V, with any waveform. The channel outputs it while idle, and
 929        its ramps start and end there (see "Ramps" above). Set to the
 930        nearest microvolt. A change applies to playback in progress.
 931        """
 932        return self._resting_voltage
 933
 934    @resting_voltage.setter
 935    def resting_voltage(self, values):
 936        self._resting_voltage._assign(values)
 937
 938    @property
 939    def play_duration(self):
 940        """How long each output channel plays after a trigger, in seconds.
 941
 942        Indexed by channel number (see "Channel settings" above). Up to
 943        `DeviceInfo.max_play_duration` (3600 s). `0` plays until the
 944        channel is stopped. It counts at full amplitude, after the on
 945        ramp. The channel stops when the duration has elapsed, part way
 946        through a cycle if need be, and returns to its resting voltage,
 947        over its off ramp. Durations are counted in samples, rounded to
 948        the nearest one, and a nonzero duration lasts at least one sample.
 949        A change applies to playback in progress.
 950        """
 951        return self._play_duration
 952
 953    @play_duration.setter
 954    def play_duration(self, values):
 955        self._play_duration._assign(values)
 956
 957    @property
 958    def on_ramp_duration(self):
 959        """How long each output channel fades in after a trigger, in seconds.
 960
 961        Indexed by channel number (see "Channel settings" above). Up to
 962        `DeviceInfo.max_play_duration` (3600 s); `0` (the default) for no
 963        ramp. Over it, the peak to peak voltage rises linearly from 0 V to
 964        `peak_to_peak`, and the mean from the `resting_voltage` to the
 965        `mean_voltage`, before the play duration (see "Ramps" above).
 966        Counted in samples, as `play_duration` is. A change applies to
 967        playback in progress: a ramp under way keeps the level it has
 968        reached, and goes on at the new rate.
 969        """
 970        return self._on_ramp_duration
 971
 972    @on_ramp_duration.setter
 973    def on_ramp_duration(self, values):
 974        self._on_ramp_duration._assign(values)
 975
 976    @property
 977    def off_ramp_duration(self):
 978        """How long each output channel fades out when it stops, in seconds.
 979
 980        Indexed by channel number (see "Channel settings" above). Up to
 981        `DeviceInfo.max_play_duration` (3600 s); `0` (the default) for no
 982        ramp. Over it, the peak to peak voltage falls linearly to 0 V, and
 983        the mean to the `resting_voltage`, after the play duration or a
 984        stop (see "Ramps" above). Counted in samples, as `play_duration`
 985        is. A change applies to playback in progress.
 986        """
 987        return self._off_ramp_duration
 988
 989    @off_ramp_duration.setter
 990    def off_ramp_duration(self, values):
 991        self._off_ramp_duration._assign(values)
 992
 993    @property
 994    def trigger_mode(self):
 995        """How each trigger channel acts on the output channels linked to it.
 996
 997        Indexed by trigger channel number, 1 or 2 (index 0 is unused).
 998        These are Pulse Pal's trigger modes:
 999
1000        - `"Normal"`: a rising edge starts the linked channels. Channels
1001          that are playing ignore it.
1002        - `"Toggle"`: a rising edge starts the linked channels, or stops
1003          those that are playing.
1004        - `"Gated"`: a rising edge starts the linked channels, and a
1005          falling edge stops them, unless the other trigger channel is
1006          also gated, linked to them, and still high. With a play
1007          duration of 0, a channel plays for exactly as long as the TTL is
1008          high.
1009        - `"Param Sync"`: a rising edge starts and stops nothing (the
1010          channel's links are ignored). It loads the settings most recently
1011          sent by `SynthPalDevice.sync_to_device` (or at the end of a
1012          `SynthPalDevice.batch` block), if any. This is how the next
1013          trial's settings are sent during the current trial and applied
1014          the instant it starts.
1015
1016        At a param sync edge, the frequency and both trigger modes change
1017        at once: they are shared by all channels. An output channel at its
1018        resting voltage takes its new settings at once. One that is playing
1019        (off ramp included) finishes on the settings it started with, at
1020        the new frequency, and takes the new ones the moment it reaches its
1021        resting voltage, so the next trigger plays them. A trigger mode
1022        sent by `SynthPalDevice.sync_to_device` applies from the next
1023        edge.
1024
1025        Only `SynthPalDevice.sync_to_device` is held back for the edge:
1026        with `SynthPalDevice.auto_sync` on, every assignment programs the
1027        device at once, also in param sync mode. So leaving param sync mode
1028        means assigning `trigger_mode` with `auto_sync` on. When no trigger
1029        channel is left in the mode, the device discards a stored set.
1030
1031        To start channels on the same edge, wire the TTL to the other
1032        trigger channel too: the settings load first, so the channels it
1033        starts play the new settings. Connecting, and `set_default_params`,
1034        take both trigger channels out of param sync mode.
1035
1036        Names are not case sensitive.
1037        """
1038        return self._trigger_mode
1039
1040    @trigger_mode.setter
1041    def trigger_mode(self, values):
1042        self._trigger_mode._assign(values)
1043
1044    @property
1045    def link_trigger_channel1(self):
1046        """Whether each output channel is triggered by trigger channel 1.
1047
1048        Indexed by channel number (see "Channel settings" above). By
1049        default no output channel is linked to either trigger channel, so
1050        a TTL starts nothing until a script links it.
1051        """
1052        return self._link_trigger_channel1
1053
1054    @link_trigger_channel1.setter
1055    def link_trigger_channel1(self, values):
1056        self._link_trigger_channel1._assign(values)
1057
1058    @property
1059    def link_trigger_channel2(self):
1060        """Whether each output channel is triggered by trigger channel 2.
1061
1062        Indexed by channel number (see "Channel settings" above).
1063        """
1064        return self._link_trigger_channel2
1065
1066    @link_trigger_channel2.setter
1067    def link_trigger_channel2(self, values):
1068        self._link_trigger_channel2._assign(values)
1069
1070    def set_screen_saver(self, enabled, timeout=1800):
1071        """Switch the device's screen saver on or off, and set its timeout.
1072
1073        With the screen saver on, the device dims its screen once it has
1074        been left alone for `timeout` seconds: no command from a computer,
1075        no rising edge on a trigger channel, and no joystick click or
1076        push. The next of these brings the screen back. The device keeps
1077        both settings in its EEPROM, shared with Pulse Pal firmware, and
1078        saves them once no channel is playing. A new device starts with
1079        the screen saver on and 1800 s.
1080
1081        Args:
1082            enabled: `True` to switch the screen saver on, `False` to
1083                switch it off.
1084            timeout: Seconds without activity before the screen dims, 1 to
1085                65535.
1086
1087        Raises:
1088            PulsePalError: If a value is out of range.
1089        """
1090        state = to_bool(enabled, "enabled")
1091        if isinstance(timeout, (bool, np.bool_)) \
1092                or not isinstance(timeout, numbers.Integral) \
1093                or not 1 <= timeout <= 65535:
1094            raise PulsePalError(
1095                "timeout must be a whole number of seconds from 1 to 65535. "
1096                f"Received {timeout!r}."
1097            )
1098        self._write_command(self._OP_SET_SCREEN_SAVER,
1099                            struct.pack("<BH", int(state), int(timeout)))
1100        self._read_ack("set_screen_saver()")
1101
1102    # ------------------------------------------------------------------
1103    # Playback
1104    # ------------------------------------------------------------------
1105
1106    def trigger(self, channels):
1107        """Trigger output channels in software.
1108
1109        Channels that are idle start from the beginning of their waveform
1110        cycle, together. Channels that are playing ignore it, as they
1111        ignore a trigger channel in normal mode.
1112
1113        ```python
1114        S.trigger(1)
1115        S.trigger([2, 4])     # a list, tuple or NumPy array
1116        ```
1117
1118        Args:
1119            channels: Output channel number 1-4, or several as a list,
1120                tuple or NumPy array.
1121        """
1122        self._write_command(self._OP_TRIGGER, bytes([_common.channel_bits(channels)]))
1123
1124    def stop(self, channels=None):
1125        """Stop playback. The stopped channels return to their resting
1126        voltage, over their off ramps.
1127
1128        Args:
1129            channels: Output channel number 1-4, or several as a list,
1130                tuple or NumPy array. `None` stops all channels.
1131        """
1132        bits = self._ALL_CHANNELS if channels is None else _common.channel_bits(channels)
1133        self._write_command(self._OP_STOP, bytes([bits]))
1134
1135    def status(self):
1136        """Read the device's playback state.
1137
1138        Returns:
1139            A `DeviceStatus`, with the channels that are playing, the
1140            samples per cycle, each channel's output range, and timing
1141            figures.
1142        """
1143        self._write_command(self._OP_GET_STATUS)
1144        values = struct.unpack(
1145            self._STATUS_FORMAT,
1146            self._read_raw(struct.calcsize(self._STATUS_FORMAT)),
1147        )
1148        playing_bits = values[0]
1149        range_names = list(OUTPUT_RANGES)
1150        return DeviceStatus(
1151            playing=[ch for ch in range(1, 5)
1152                     if playing_bits & (1 << (ch - 1))],
1153            samples_per_cycle=values[1],
1154            output_ranges=[None, *(range_names[i] if i < len(range_names)
1155                                   else None for i in values[2:6])],
1156            longest_interrupt_us=values[6] / 1000,
1157            late_updates=values[7],
1158        )
1159
1160    def _playback_checksums(self):
1161        """For testing: what each channel played since it last started.
1162
1163        Returns two lists indexed by channel number, with index 0 unused:
1164        the number of samples played, and the sum of their DAC codes
1165        modulo 2**32. `tests/synthpal_hardware_test.py` compares them with
1166        the samples it expects, to check what the device played.
1167        """
1168        self._write_command(self._OP_GET_PLAYBACK_CHECKSUMS)
1169        values = struct.unpack("<8I", self._read_raw(32))
1170        return [None, *values[:4]], [None, *values[4:]]
1171
1172    # ------------------------------------------------------------------
1173    # Connection
1174    # ------------------------------------------------------------------
1175
1176    def close(self):
1177        """Close the connection to the device.
1178
1179        The device stops playback on all channels, each over its off ramp,
1180        and shows its own name on its screen again, in place of "PYTHON
1181        Connected". It keeps its settings, so TTL triggers still play the
1182        channels. Safe to call more than once. Called automatically when
1183        leaving a `with` block and when the object is garbage collected,
1184        so playback also stops when the last reference to the object
1185        goes.
1186
1187        """
1188        self._close(send_disconnect=True)
1189
1190    def _close(self, send_disconnect):
1191        """Close the connection. send_disconnect: tell the device first (op 81). False when the device may not
1192        run this firmware: op 81 means something else to other devices."""
1193        if getattr(self, "_closed", True):
1194            return
1195        self._closed = True
1196        if send_disconnect and self._unsent:
1197            _common.warn(f"SynthPalDevice closed with changes made with auto_sync off that were never sent to the "
1198                         f"device: {self._unsent_names()}.")
1199        self._reply_pending = None  # Nothing more is read, so a reply still to come needs no discarding
1200        try:
1201            if send_disconnect and self.port and self.port.is_open:
1202                self._write_command(self._OP_DISCONNECT)
1203        except Exception:
1204            pass  # The port may already be gone, e.g. the cable was unplugged
1205        finally:
1206            if self.port and self.port.is_open:
1207                self.port.close()
1208
1209    def __enter__(self):
1210        """Enter a `with` block, returning the connected device."""
1211        return self
1212
1213    def __exit__(self, exc_type, exc_value, traceback):
1214        """Close the port when leaving a `with` block.
1215
1216        Returns:
1217            `False`, so any exception raised in the block propagates.
1218        """
1219        self.close()
1220        return False
1221
1222    def __del__(self):
1223        """Close the port when the object is collected."""
1224        try:
1225            self.close()
1226        except Exception:
1227            # Destructors should not raise; the serial object may already be
1228            # gone during interpreter shutdown.
1229            pass
1230
1231    def __setattr__(self, name, value):
1232        """Refuse a public attribute that is not a setting, such as a misspelled one: it would program nothing (see
1233        _common.refuse_unknown_attribute())."""
1234        if name[0] != "_":
1235            _common.refuse_unknown_attribute(self, name, _SETTING_NAMES)
1236        super().__setattr__(name, value)
1237
1238    def __repr__(self):
1239        """Describe the device and its settings, e.g. with `print(S)`."""
1240        port = getattr(getattr(self, "port", None), "port", None)
1241        lines = [
1242            f"SynthPalDevice on {port} (Synth Pal firmware v{self.info.firmware_version})",
1243            f"frequency: {self._frequency} Hz ({self._samples_per_cycle} samples per cycle)",
1244            f"auto_sync: {self._auto_sync}",
1245        ]
1246        lines += [f"{name}: {list(getattr(self, name))}" for name in _CHANNEL_SETTINGS]
1247        return "\n".join(lines)
1248
1249    # ------------------------------------------------------------------
1250    # Internals: settings
1251    # ------------------------------------------------------------------
1252
1253    def _handshake(self):
1254        """Check that the device runs a supported Synth Pal firmware."""
1255        self._write_command(self._OP_HANDSHAKE)
1256        handshake = _common.read_handshake_reply(self.port)  # Skips a reply to an earlier session's command
1257        if handshake is None:
1258            raise PulsePalError(
1259                f"No reply from the device on {self.port.port}. Is it a "
1260                "Pulse Pal 3 running Synth Pal firmware?"
1261                f" {_common.NO_REPLY_HINT}"
1262            )
1263        reply, version = handshake
1264        if reply != self._HANDSHAKE_REPLY:
1265            if reply in _common.FIRMWARE_BY_HANDSHAKE_REPLY:
1266                raise _common.other_firmware_error(reply, version, self.port.port, "Synth Pal")
1267            raise PulsePalError(
1268                "Incorrect handshake returned. Expected "
1269                f"{self._HANDSHAKE_REPLY}, received {reply}."
1270            )
1271        if version > self._CURRENT_FIRMWARE_VERSION:
1272            # New firmware only adds commands (see PROTOCOL.md), so this class still works with it
1273            warnings.warn(
1274                f"Synth Pal firmware v{version} is newer than this pulsepal package knows "
1275                f"(v{self._CURRENT_FIRMWARE_VERSION}). It works with it, but update the package to use what is "
1276                "new.", stacklevel=3)
1277        self.info.firmware_version = version
1278
1279    def _read_hardware_info(self):
1280        self._write_command(self._OP_HARDWARE_INFO)
1281        (
1282            self.info.hardware_version,
1283            self.info.n_channels,
1284            min_centihz,
1285            max_centihz,
1286            self.info.max_sampling_rate,
1287            self.info.timer_clock_hz,
1288            max_duration_us,
1289        ) = struct.unpack(
1290            self._HARDWARE_INFO_FORMAT,
1291            self._read_raw(struct.calcsize(self._HARDWARE_INFO_FORMAT)),
1292        )
1293        self.info.min_frequency = min_centihz / 100
1294        self.info.max_frequency = max_centihz / 100
1295        self.info.max_play_duration = max_duration_us / 1e6
1296        self._write_command(self._OP_HARDWARE_REVISION)
1297        self.info.circuit_board_revision = self._read_raw(1)[0]
1298        self._write_command(_common.OP_SERIAL_NUMBER)
1299        self.info.serial_number = _common.read_serial_number(self._read_raw)
1300
1301    def _normalize(self, name, values):
1302        """Check new values for all four channels of a setting, and return them as its list holds them."""
1303        if name == "waveform":
1304            return [to_name(value, WAVEFORMS, "waveform") for value in values]
1305        if name == "peak_to_peak":
1306            return [self._to_volts(value, name, 0, 20, " peak to peak") for value in values]
1307        if name in ("fixed_voltage", "mean_voltage", "resting_voltage"):
1308            return [self._to_volts(value, name, -10, 10) for value in values]
1309        zero_meaning = "0 (play until stopped)" if name == "play_duration" else "0 (no ramp)"
1310        return [self._to_seconds(value, name, zero_meaning) for value in values]
1311
1312    def _check_levels(self, peak_to_peak, mean_voltage, setting):
1313        """Check that each channel's periodic waveform stays within -10 V to 10 V, as the device does.
1314
1315        Checked on every channel, also one playing a fixed voltage, so that a change of waveform always
1316        suits the levels it finds. `setting` is the one being changed, for the advice in the message.
1317        """
1318        for channel, (pp, mean) in enumerate(zip(peak_to_peak, mean_voltage), start=1):
1319            pp_uv, mean_uv = _microvolts(pp), _microvolts(mean)
1320            if 2 * abs(mean_uv) + pp_uv > 2 * _MAX_VOLTAGE_UV:
1321                advice = {
1322                    "peak_to_peak": "Change mean_voltage first, set both with configure(), or choose a "
1323                                    "smaller peak_to_peak.",
1324                    "mean_voltage": "Change peak_to_peak first, set both with configure(), or choose a "
1325                                    "smaller mean_voltage.",
1326                }.get(setting, "Change peak_to_peak or mean_voltage.")
1327                raise PulsePalError(
1328                    f"On channel {channel}, a mean voltage of {mean:g} V and a peak to peak voltage of "
1329                    f"{pp:g} V would reach {(abs(mean_uv) + pp_uv / 2) / 1e6:g} V. The waveform must "
1330                    f"stay within -10 V to 10 V. {advice}"
1331                )
1332
1333    def _device_levels(self, waveform, peak_to_peak, fixed_voltage, mean_voltage):
1334        """What the device holds for these settings: waveforms, amplitudes in microvolts (a fixed voltage, or a
1335        periodic waveform's peak to peak voltage) and mean voltages in microvolts."""
1336        amplitudes = [_microvolts(fixed if name == FIXED_VOLTAGE else pp)
1337                      for name, pp, fixed in zip(waveform, peak_to_peak, fixed_voltage)]
1338        return list(waveform), amplitudes, [_microvolts(mean) for mean in mean_voltage]
1339
1340    def _apply_levels(self, name, values):
1341        """Apply new values of waveform, peak_to_peak, fixed_voltage or mean_voltage, for all four channels."""
1342        values = self._normalize(name, values)
1343        levels = {level: list(getattr(self, level))[1:]
1344                  for level in ("waveform", "peak_to_peak", "fixed_voltage", "mean_voltage")}
1345        levels[name] = values
1346        self._check_levels(levels["peak_to_peak"], levels["mean_voltage"], name)
1347        if self._auto_sync:
1348            self._send_levels(*self._device_levels(**levels))
1349        else:
1350            self._unsent = self._unsent | {name}  # Until sync_to_device() sends it (see auto_sync)
1351        return values
1352
1353    def _apply_waveform(self, values):
1354        return self._apply_levels("waveform", values)
1355
1356    def _apply_peak_to_peak(self, values):
1357        return self._apply_levels("peak_to_peak", values)
1358
1359    def _apply_fixed_voltage(self, values):
1360        return self._apply_levels("fixed_voltage", values)
1361
1362    def _apply_mean_voltage(self, values):
1363        return self._apply_levels("mean_voltage", values)
1364
1365    def _send_levels(self, waveforms, amplitudes_uv, means_uv):
1366        """Program the device's waveforms, amplitudes and mean voltages, in an order it accepts at every step.
1367
1368        The device checks each op against what it holds for the other two (see _is_valid_level()), and one
1369        order does not suit every change: a sine wave of 20 V peak to peak cannot become a fixed voltage of
1370        -5 V by either op first. So each channel first takes an amplitude that suits both its current and
1371        its new waveform and mean voltage: its current amplitude if it can, else its new one, else 0 V, which
1372        suits any. Then the waveforms, the mean voltages and the new amplitudes follow. If the device
1373        refuses a step, which means this object's record of what it holds is out of date (e.g. a param sync
1374        edge loaded a stored set), the change is made from 0 V, which works from any state.
1375        """
1376        current = (self._device_waveform, self._device_amplitude_uv, self._device_mean_uv)
1377        if (waveforms, amplitudes_uv, means_uv) == tuple(map(list, current)):
1378            return
1379        first_amplitudes = []
1380        for w0, a0, m0, w1, a1, m1 in zip(*current, waveforms, amplitudes_uv, means_uv):
1381            for candidate in (a0, a1, 0):
1382                if all(_is_valid_level(w, candidate, m) for w, m in ((w0, m0), (w1, m0), (w1, m1))):
1383                    first_amplitudes.append(candidate)
1384                    break
1385        steps = (("A", first_amplitudes), ("W", waveforms), ("M", means_uv), ("A", amplitudes_uv))
1386        try:
1387            self._send_levels_in_order(steps)
1388        except _Rejected:
1389            self._send_levels_in_order((("A", [0] * 4), ("W", waveforms), ("M", means_uv),
1390                                        ("A", amplitudes_uv)), force=True)
1391
1392    def _send_levels_in_order(self, steps, force=False):
1393        """Send ops 'A' (amplitudes, uV), 'W' (waveform names) and 'M' (mean voltages, uV), in order, and keep
1394        the record of what the device holds. Unless `force`, an op that would not change what the record says
1395        the device holds is skipped."""
1396        for op, values in steps:
1397            if op == "W":
1398                if values == self._device_waveform and not force:
1399                    continue
1400                self._write_command(self._OP_SET_WAVEFORM, bytes(WAVEFORMS.index(name) for name in values))
1401                self._read_ack("setting waveform")
1402                self._device_waveform = list(values)
1403            elif op == "A":
1404                if values == self._device_amplitude_uv and not force:
1405                    continue
1406                self._write_command(self._OP_SET_AMPLITUDE, struct.pack("<4i", *values))
1407                self._read_ack("setting the amplitude")
1408                self._device_amplitude_uv = list(values)
1409            else:
1410                if values == self._device_mean_uv and not force:
1411                    continue
1412                self._write_command(self._OP_SET_MEAN_VOLTAGE, struct.pack("<4i", *values))
1413                self._read_ack("setting mean_voltage")
1414                self._device_mean_uv = list(values)
1415
1416    def _apply_resting_voltage(self, values):
1417        # Any resting voltage within -10 V to 10 V goes with any waveform
1418        values = self._normalize("resting_voltage", values)
1419        if self._auto_sync:
1420            self._send_setting("resting_voltage", self._OP_SET_RESTING_VOLTAGE, values)
1421        else:
1422            self._unsent = self._unsent | {"resting_voltage"}
1423        return values
1424
1425    def _apply_play_duration(self, values):
1426        return self._apply_duration("play_duration", self._OP_SET_PLAY_DURATION, values)
1427
1428    def _apply_on_ramp_duration(self, values):
1429        return self._apply_duration("on_ramp_duration", self._OP_SET_ON_RAMP_DURATION, values)
1430
1431    def _apply_off_ramp_duration(self, values):
1432        return self._apply_duration("off_ramp_duration", self._OP_SET_OFF_RAMP_DURATION, values)
1433
1434    def _apply_duration(self, name, op, values):
1435        values = self._normalize(name, values)
1436        if self._auto_sync:
1437            self._send_setting(name, op, values)
1438        else:
1439            self._unsent = self._unsent | {name}
1440        return values
1441
1442    def _send_setting(self, name, op, values):
1443        """Send the resting voltages (int32 microvolts) or a duration (uint32 microseconds), for all four
1444        channels."""
1445        if name == "resting_voltage":
1446            data = struct.pack("<4i", *(_microvolts(v) for v in values))
1447        else:
1448            data = struct.pack("<4I", *(round(d * 1e6) for d in values))
1449        self._write_command(op, data)
1450        self._read_ack(f"setting {name}")
1451
1452    def _apply_trigger_mode(self, values):
1453        names = [to_name(value, TRIGGER_MODES, "trigger mode") for value in values]
1454        if self._auto_sync:
1455            self._write_command(
1456                self._OP_SET_TRIGGER_MODE,
1457                bytes(TRIGGER_MODES.index(name) for name in names),
1458            )
1459            self._read_ack("setting trigger_mode")
1460        else:
1461            self._unsent = self._unsent | {"trigger_mode"}
1462        return names
1463
1464    # One op programs both trigger channels' links, so each list is sent
1465    # with the other's current values
1466    def _apply_trigger_channel1_links(self, values):
1467        links1 = [to_bool(v, "link_trigger_channel1") for v in values]
1468        self._send_trigger_links(links1, self._link_trigger_channel2[1:])
1469        if not self._auto_sync:
1470            self._unsent = self._unsent | {"link_trigger_channel1"}
1471        return links1
1472
1473    def _apply_trigger_channel2_links(self, values):
1474        links2 = [to_bool(v, "link_trigger_channel2") for v in values]
1475        self._send_trigger_links(self._link_trigger_channel1[1:], links2)
1476        if not self._auto_sync:
1477            self._unsent = self._unsent | {"link_trigger_channel2"}
1478        return links2
1479
1480    def _set_trigger_links(self, links1, links2):
1481        """Program both trigger channels' links with one command."""
1482        self._send_trigger_links(links1, links2)
1483        self._link_trigger_channel1._store(links1)
1484        self._link_trigger_channel2._store(links2)
1485
1486    def _send_trigger_links(self, links1, links2):
1487        if self._auto_sync:
1488            self._write_command(self._OP_SET_TRIGGER_LINKS,
1489                                bytes([*map(int, links1), *map(int, links2)]))
1490            self._read_ack("setting the trigger channel links")
1491
1492    def _snapshot(self):
1493        """All settings, and which are unsent, for batch() to restore if its block fails."""
1494        settings = {name: list(getattr(self, name))[1:] for name in _CHANNEL_SETTINGS}
1495        return settings, self._frequency, self._samples_per_cycle, self._unsent
1496
1497    def _restore(self, snapshot):
1498        settings, self._frequency, self._samples_per_cycle, self._unsent = snapshot
1499        for name, values in settings.items():
1500            getattr(self, name)._store(values)
1501
1502    def _unsent_names(self):
1503        """The settings assigned with auto_sync off and not yet sent, for messages, in the class's order."""
1504        return ", ".join(name for name in _SETTING_NAMES if name in self._unsent)
1505
1506    def _check_sync(self):
1507        """Check the whole set before sync_to_device() sends it."""
1508        self._check_levels(self._peak_to_peak[1:], self._mean_voltage[1:], "sync")
1509
1510    def _send_sync(self):
1511        """Send every setting with op 85 ('U'). The device checks the whole set, so no order matters."""
1512        self._unsent = frozenset()  # All are sent below
1513        waveforms, amplitudes, means = self._device_levels(
1514            self._waveform[1:], self._peak_to_peak[1:], self._fixed_voltage[1:], self._mean_voltage[1:])
1515        links = [int(link) for link in (*self._link_trigger_channel1[1:],
1516                                        *self._link_trigger_channel2[1:])]
1517        payload = struct.pack(
1518            self._SETTINGS_FORMAT,
1519            round(self._frequency * 100),
1520            *(WAVEFORMS.index(name) for name in waveforms),
1521            *amplitudes, *means, *(_microvolts(v) for v in self._resting_voltage[1:]),
1522            *(round(d * 1e6) for d in self._play_duration[1:]),
1523            *(round(d * 1e6) for d in self._on_ramp_duration[1:]),
1524            *(round(d * 1e6) for d in self._off_ramp_duration[1:]),
1525            *links,
1526            *(TRIGGER_MODES.index(name) for name in self._trigger_mode[1:]),
1527        )
1528        self._write_command(self._OP_SET_ALL_SETTINGS, payload)
1529        self._read_ack("sync_to_device()")
1530        # Applied now, or, in param sync mode, at the next edge. Either way these are the levels the next
1531        # assignments build on; if a stored set has not loaded yet, _send_levels() finds out and recovers.
1532        self._device_waveform, self._device_amplitude_uv, self._device_mean_uv = waveforms, amplitudes, means
1533
1534    @staticmethod
1535    def _to_volts(value, name, low, high, note=""):
1536        if isinstance(value, (bool, np.bool_)) or not isinstance(value, numbers.Real) \
1537                or not math.isfinite(value) or not low <= value <= high:
1538            raise PulsePalError(
1539                f"{name} values must be numbers of volts from {low} to "
1540                f"{high}{note}. Received {value!r}."
1541            )
1542        return float(value)
1543
1544    def _to_seconds(self, value, name, zero_meaning):
1545        if isinstance(value, (bool, np.bool_)) or not isinstance(value, numbers.Real):
1546            raise PulsePalError(
1547                f"{name} must be in seconds. Received {value!r}."
1548            )
1549        duration = float(value)
1550        if not math.isfinite(duration) or not \
1551                0 <= duration <= self.info.max_play_duration:
1552            raise PulsePalError(
1553                f"{name} must be {zero_meaning} or a positive number "
1554                f"of seconds up to {self.info.max_play_duration:g}. "
1555                f"Received {value!r}."
1556            )
1557        return duration
1558
1559    # ------------------------------------------------------------------
1560    # Internals: serial port
1561    # ------------------------------------------------------------------
1562
1563    def _write_command(self, op_code, data=b""):
1564        """Send one command, with its framing byte, in a single write.
1565
1566        A command whose reply was not read in full, because the read timed out or was interrupted (Ctrl-C), leaves
1567        that reply, or the rest of it, to arrive later. Read as the reply to the next command, it would put every
1568        reply after it out of step. So it is discarded first (_discard_lost_reply()), and this command's reply, if it
1569        has one, is pending until _read_raw() has read it, as in PulsePalDevice.
1570        """
1571        if self._reply_pending is not None:
1572            self._discard_lost_reply()
1573        if op_code not in _OPS_WITHOUT_TRACKED_REPLY:
1574            self._reply_pending = op_code  # Before the write: a Ctrl-C can come as soon as it returns
1575        message = bytes([self._OP_MENU_BYTE, op_code]) + data
1576        bytes_written = self.port.write(message)
1577        if bytes_written != len(message):
1578            raise PulsePalError(
1579                f"Wrote {bytes_written} byte(s), expected to write "
1580                f"{len(message)} byte(s)."
1581            )
1582
1583    def _discard_lost_reply(self):
1584        """Discard what arrives of a reply that was not read in full (see _write_command()), and bring this object
1585        back in step with the device.
1586
1587        The command may have changed settings, unknown to this object: an assignment interrupted after its write,
1588        for one, which the device took and this object did not. So after one of _SETTING_OPS, they are read back
1589        (op 'R'), outside param sync mode, where op 'R' returns the settings playing rather than a set stored for the
1590        next sync edge.
1591        """
1592        op = self._reply_pending
1593        self._reply_pending = None
1594        n_late = len(_common.read_until_quiet(self.port))
1595        read_back = op in self._SETTING_OPS and "Param Sync" not in list(self._trigger_mode)[1:]
1596        _common.warn_lost_reply(f"op {chr(op)!r}", n_late,
1597                                ", and the settings were read back from the device" if read_back else "")
1598        if read_back:
1599            try:
1600                self.sync_from_device()
1601            except PulsePalError as exc:
1602                raise PulsePalError(
1603                    f"Synth Pal did not send its settings, read back after the reply to an earlier command was lost. "
1604                    f"{_common.NO_REPLY_HINT}"
1605                ) from exc
1606
1607    def _read_raw(self, n_bytes):
1608        """Read exactly n_bytes from the serial port: the whole reply to the command last sent, which is then no
1609        longer pending (see _write_command()). Only connecting reads a reply in more than one call."""
1610        message = self.port.read(n_bytes)
1611        if len(message) < n_bytes:
1612            raise PulsePalError(
1613                f"Synth Pal sent {len(message)} of the {n_bytes} byte(s) of its reply within "
1614                f"{self.port.timeout} s. {_common.NO_REPLY_HINT}"
1615            )
1616        self._reply_pending = None
1617        return message
1618
1619    def _read_ack(self, context):
1620        """Read a one-byte confirmation: 1 if the device executed the
1621        command, 0 if it rejected it."""
1622        try:
1623            reply = self._read_raw(1)[0]
1624        except PulsePalError as exc:
1625            raise PulsePalError(
1626                f"Synth Pal did not confirm {context}: no reply arrived within {self.port.timeout} s. "
1627                f"{_common.NO_REPLY_HINT}"
1628            ) from exc
1629        if reply != 1:
1630            raise _Rejected(
1631                f"Synth Pal rejected {context}. A value was out of range."
1632            )
class SynthPalDevice:
 288class SynthPalDevice:
 289    """A class to control a Synth Pal on a USB serial port.
 290
 291    Creating an instance opens the serial port, checks that the device
 292    runs Synth Pal firmware, reads its properties into
 293    `SynthPalDevice.info`, shows "PYTHON Connected" on the device's
 294    screen, stops any playback and programs the default settings (see
 295    `SynthPalDevice.set_default_params`).
 296
 297    ```python
 298    from pulsepal import SynthPalDevice
 299
 300    S = SynthPalDevice("COM3")
 301    S.waveform[1] = "Triangle"
 302    S.trigger(1)
 303    S.close()
 304    ```
 305
 306    Replace "COM3" with the device's USB serial port name, which
 307    `SynthPalDevice.serialportlist` lists. `SynthPalDevice` is also a
 308    context manager, which closes the port on exit.
 309
 310    Closing the connection stops playback, as it does on Pulse Pal (each
 311    channel over its off ramp), and puts the device's own name back on its
 312    screen. The device keeps its settings, so TTL triggers still play the
 313    channels.
 314    """
 315
 316    port: "serial.Serial"
 317    """The open `serial.Serial` port connected to the device."""
 318
 319    info: DeviceInfo
 320    """Properties of the connected device. See `DeviceInfo`."""
 321
 322    _CURRENT_FIRMWARE_VERSION = 1
 323    _BAUD_RATE = 12000000  # USB serial ignores the baud rate
 324
 325    _OP_MENU_BYTE = _common.OP_MENU_BYTE
 326    _OP_HANDSHAKE = _common.OP_HANDSHAKE
 327    _OP_DISCONNECT = 81
 328    _OP_SET_CLIENT_NAME = 89
 329    _OP_SET_SCREEN_SAVER = 99
 330    _OP_HARDWARE_REVISION = 100
 331    _OP_HARDWARE_INFO = ord("N")
 332    _OP_SET_FREQUENCY = ord("F")
 333    _OP_SET_WAVEFORM = ord("W")
 334    _OP_SET_AMPLITUDE = ord("A")
 335    _OP_SET_RESTING_VOLTAGE = ord("V")
 336    _OP_SET_MEAN_VOLTAGE = ord("M")
 337    _OP_SET_PLAY_DURATION = ord("D")
 338    _OP_SET_ON_RAMP_DURATION = ord("B")
 339    _OP_SET_OFF_RAMP_DURATION = ord("E")
 340    _OP_SET_TRIGGER_LINKS = ord("I")
 341    _OP_SET_TRIGGER_MODE = ord("T")
 342    _OP_SET_ALL_SETTINGS = ord("U")
 343    _OP_GET_ALL_SETTINGS = ord("R")
 344    # Every setting, as ops 85 ('U') and 82 ('R') carry them: frequency (centiHz), waveform codes, amplitudes,
 345    # mean and resting voltages (uV), play, on ramp and off ramp durations (us), links, trigger modes
 346    _SETTINGS_FORMAT = "<I4B4i4i4i4I4I4I8B2B"
 347    _OP_TRIGGER = ord("P")
 348    _OP_STOP = ord("X")
 349    _OP_GET_STATUS = ord("G")
 350    _OP_GET_PLAYBACK_CHECKSUMS = ord("Z")
 351
 352    _HANDSHAKE_REPLY = 83  # 'S'
 353    _HARDWARE_INFO_FORMAT = "<BBIIIII"
 354    _STATUS_FORMAT = "<BI4BII"
 355    _ALL_CHANNELS = 0x0F
 356    # The ops that change settings: if the reply to one is lost, they are read back (_discard_lost_reply())
 357    _SETTING_OPS = frozenset(map(ord, "FWAVMDBEITU"))
 358
 359    _reply_pending = None  # The op code of a command whose reply has not been read in full yet (see _write_command())
 360    _unsent = frozenset()  # The settings assigned with auto_sync off since the settings were last sent or read
 361
 362    def __init__(self, port_name=None, *, serial_number=None, timeout=10):
 363        """Open a connection to a Synth Pal.
 364
 365        Args:
 366            port_name: USB serial port of the device, such as `COM3` on
 367                Windows or `/dev/ttyACM0` on Linux.
 368            serial_number: Instead of `port_name`, the device's USB serial
 369                number (`DeviceInfo.serial_number`), which finds its port
 370                wherever it is plugged in.
 371            timeout: Serial read timeout, in seconds.
 372
 373        Firmware newer than this module knows is used with a warning: new
 374        firmware only adds commands. Update the package to use what is new.
 375
 376        Raises:
 377            PulsePalError: If the device does not reply to the handshake, or
 378                runs other firmware.
 379            serial.SerialException: If the serial port cannot be opened,
 380                e.g. because another program has it open (on Linux and
 381                macOS, another session of these classes, which lock it).
 382        """
 383        self._closed = True
 384        self._auto_sync = True
 385        self.info = DeviceInfo()
 386        self._frequency = None
 387        self._samples_per_cycle = None
 388        # What the device holds for each output channel's waveform, amplitude (in microvolts: a periodic
 389        # waveform's peak to peak voltage, or a fixed voltage) and mean voltage (in microvolts), as far as this
 390        # object has programmed them. _send_levels() changes them in an order the device accepts.
 391        self._device_waveform = ["Sine"] * 4
 392        self._device_amplitude_uv = [5_000_000] * 4
 393        self._device_mean_uv = [0] * 4
 394        self._waveform = ChannelSettings(
 395            "waveform", ["Sine"] * 4, self, "_apply_waveform")
 396        self._peak_to_peak = ChannelSettings(
 397            "peak_to_peak", [5.0] * 4, self, "_apply_peak_to_peak")
 398        self._fixed_voltage = ChannelSettings(
 399            "fixed_voltage", [5.0] * 4, self, "_apply_fixed_voltage")
 400        self._mean_voltage = ChannelSettings(
 401            "mean_voltage", [0.0] * 4, self, "_apply_mean_voltage")
 402        self._resting_voltage = ChannelSettings(
 403            "resting_voltage", [0.0] * 4, self, "_apply_resting_voltage")
 404        self._play_duration = ChannelSettings(
 405            "play_duration", [1.0] * 4, self, "_apply_play_duration")
 406        self._on_ramp_duration = ChannelSettings(
 407            "on_ramp_duration", [0.0] * 4, self, "_apply_on_ramp_duration")
 408        self._off_ramp_duration = ChannelSettings(
 409            "off_ramp_duration", [0.0] * 4, self, "_apply_off_ramp_duration")
 410        self._trigger_mode = ChannelSettings(
 411            "trigger_mode", ["Normal"] * 2, self, "_apply_trigger_mode")
 412        self._link_trigger_channel1 = ChannelSettings(
 413            "link_trigger_channel1", [False] * 4, self,
 414            "_apply_trigger_channel1_links")
 415        self._link_trigger_channel2 = ChannelSettings(
 416            "link_trigger_channel2", [False] * 4, self,
 417            "_apply_trigger_channel2_links")
 418
 419        port_name = _common.port_name_from(port_name, serial_number, "SynthPalDevice")
 420        self.port = serial.Serial(
 421            port_name,
 422            self._BAUD_RATE,
 423            timeout=timeout,
 424            rtscts=True,
 425            # Windows opens a port for one program only. Linux and macOS let a second program open it too, and two
 426            # sessions' bytes would then interleave; exclusive locks it (flock), so a second session that also locks
 427            # it, such as another of these classes, gets an error instead
 428            exclusive=True,
 429        )
 430        self._closed = False
 431        try:
 432            # Discard anything left in the buffer by an earlier session
 433            self.port.reset_input_buffer()
 434            self._handshake()
 435            self._read_hardware_info()
 436            # Client name op + "PYTHON" in ASCII, shown as "PYTHON Connected"
 437            self._write_command(self._OP_SET_CLIENT_NAME, b"PYTHON")
 438            self.stop()
 439            self.set_default_params()
 440        except BaseException:
 441            # Op 81 means something else to other devices, so it is sent
 442            # only once the device has identified itself as a Synth Pal
 443            self._close(send_disconnect=self.info.firmware_version is not None)
 444            raise
 445
 446    @staticmethod
 447    def serialportlist(ports_to_list="available"):
 448        """Return the names of the USB serial ports on this computer.
 449
 450        Called on the class, without connecting to a device, to find the
 451        port name to pass to `SynthPalDevice`.
 452
 453        Args:
 454            ports_to_list: `available` to list only the ports that are
 455                not already in use, or `all` to list every USB serial
 456                port. Not case sensitive.
 457
 458        Returns:
 459            Sorted list of port names, such as `["COM3", "COM7"]`.
 460
 461        Raises:
 462            PulsePalError: If `ports_to_list` is not `available` or `all`.
 463        """
 464        return _common.serialportlist(ports_to_list)
 465
 466    # ------------------------------------------------------------------
 467    # Settings
 468    # ------------------------------------------------------------------
 469
 470    def set_default_params(self):
 471        """Program the default settings on the device.
 472
 473        The defaults are a frequency of 100 Hz, and on every output
 474        channel a sine wave of 5 V peak to peak around a mean voltage of
 475        0 V, resting at 0 V, played for 1 second with no ramps, and a
 476        fixed voltage of 5 V for when the waveform is "Fixed Voltage".
 477        Both trigger channels are in normal mode, and no output channel is
 478        linked to a trigger channel, so a TTL starts nothing until a script
 479        links it. They match the settings the device starts with. They are sent at once,
 480        also while `SynthPalDevice.auto_sync` is off.
 481        """
 482        auto_sync = self._auto_sync
 483        self._auto_sync = True
 484        try:
 485            self.frequency = 100
 486            # In this order, each is valid whatever the device holds: a mean of 0 V goes with any
 487            # amplitude, 5 V is then a valid amplitude for any waveform, and a sine wave is then valid
 488            self._send_levels_in_order((("M", [0] * 4), ("A", [5_000_000] * 4), ("W", ["Sine"] * 4)),
 489                                       force=True)
 490            for settings, value in ((self._waveform, "Sine"), (self._peak_to_peak, 5.0),
 491                                    (self._fixed_voltage, 5.0), (self._mean_voltage, 0.0)):
 492                settings._store([value] * 4)
 493            self.resting_voltage = [0] * 4
 494            self.play_duration = [1] * 4
 495            self.on_ramp_duration = [0] * 4
 496            self.off_ramp_duration = [0] * 4
 497            self.trigger_mode = ["Normal"] * 2
 498            self._set_trigger_links([False] * 4, [False] * 4)
 499            self._unsent = frozenset()  # Every setting has been sent
 500        finally:
 501            self._auto_sync = auto_sync
 502
 503    @property
 504    def frequency(self):
 505        """Frequency of all output channels, in Hz.
 506
 507        1 to 20000 Hz, rounded to 0.01 Hz. It can be changed during
 508        playback: playing channels carry on from the same point in their
 509        cycle, at the new frequency, and keep the time they have left to
 510        play. The frequency played is exact: the device's sample clock is a
 511        whole multiple of it (see `SynthPalDevice.sampling_rate`). For
 512        `"White Noise"` it sets only the sampling rate.
 513        """
 514        return self._frequency
 515
 516    @frequency.setter
 517    def frequency(self, value):
 518        if isinstance(value, (bool, np.bool_)) or not isinstance(value, numbers.Real) \
 519                or not math.isfinite(value):
 520            raise PulsePalError(
 521                f"frequency must be a number of Hz. Received {value!r}.")
 522        centihz = round(value * 100)
 523        low = round(self.info.min_frequency * 100)
 524        high = round(self.info.max_frequency * 100)
 525        if not low <= centihz <= high:
 526            raise PulsePalError(
 527                f"frequency must be {self.info.min_frequency:g} to "
 528                f"{self.info.max_frequency:g} Hz. Received {value!r}."
 529            )
 530        if self._auto_sync:
 531            self._write_command(self._OP_SET_FREQUENCY,
 532                                struct.pack("<I", centihz))
 533            confirmed, samples_per_cycle = struct.unpack(
 534                "<BI", self._read_raw(5))
 535            if confirmed != 1:
 536                raise PulsePalError(
 537                    f"Synth Pal rejected the frequency {value!r} Hz.")
 538        else:
 539            samples_per_cycle = _samples_per_cycle(centihz)
 540            self._unsent = self._unsent | {"frequency"}  # Until sync_to_device() sends it (see auto_sync)
 541        self._frequency = centihz / 100
 542        self._samples_per_cycle = samples_per_cycle
 543
 544    @property
 545    def auto_sync(self):
 546        """Whether assigning a setting programs the device at once.
 547
 548        `True` (the default): each assignment, such as
 549        `S.peak_to_peak[1] = 2`, programs the device at once, as the
 550        device's own op for that setting does, also while a trigger
 551        channel is in param sync mode.
 552
 553        `False`: assignments change only this object's copy of the settings,
 554        checking each value; `SynthPalDevice.sync_to_device` then sends all
 555        of them at once. Use it to store the next trial's settings for a
 556        param sync edge (see `SynthPalDevice.trigger_mode`), or to change
 557        several settings in one command. Turning it back on does not send
 558        changes made meanwhile, so it raises `pulsepal.PulsePalError` while
 559        there are any: call `SynthPalDevice.sync_to_device` first to send
 560        them, or `SynthPalDevice.sync_from_device` to discard them. A
 561        `SynthPalDevice.batch` block does it all for you. `close()` warns of
 562        changes never sent. `trigger`, `stop`, `status` and
 563        `set_default_params` act at once either way.
 564        """
 565        return self._auto_sync
 566
 567    @auto_sync.setter
 568    def auto_sync(self, value):
 569        value = to_bool(value, "auto_sync")
 570        if value and self._unsent:
 571            # Turned on, auto_sync would leave them unsent, and the device playing its old settings
 572            raise PulsePalError(
 573                f"Changes made with auto_sync off have not been sent to the device: {self._unsent_names()}. Call "
 574                "sync_to_device() to send them, or sync_from_device() to discard them, before turning auto_sync on."
 575            )
 576        self._auto_sync = value
 577
 578    def batch(self):
 579        """Change several settings, and send them in one command.
 580
 581        Returns a context manager. In its block, assignments change only
 582        this object's copy of the settings (as with
 583        `SynthPalDevice.auto_sync` off), and when the block ends,
 584        `SynthPalDevice.sync_to_device` sends all of them at once. If the
 585        block raises an error, nothing is sent, and the settings go back
 586        to what they were.
 587
 588        ```python
 589        with S.batch():
 590            S.frequency = 880
 591            S.waveform = ["Triangle"] * 4
 592            S.peak_to_peak = [2, 4, 6, 8]
 593        ```
 594
 595        In param sync mode, the device stores the settings for the next
 596        rising edge on the param sync channel (see
 597        `SynthPalDevice.trigger_mode`).
 598        """
 599        return _common.batch(self)
 600
 601    def sync_from_device(self):
 602        """Read every setting from the device into this object.
 603
 604        Use it after settings were changed with the device's joystick, for
 605        example. The device holds one amplitude per channel: a Fixed
 606        Voltage channel's goes to `SynthPalDevice.fixed_voltage`, and any
 607        other channel's to `SynthPalDevice.peak_to_peak`. This object keeps
 608        the other one, reduced if need be to suit the mean voltage read
 609        back. In param sync mode, it reads the settings the device plays
 610        now, not a set waiting for the next edge. Changes made with
 611        `SynthPalDevice.auto_sync` off and not yet sent are discarded.
 612
 613        Raises:
 614            PulsePalError: If the device does not reply.
 615        """
 616        self._write_command(self._OP_GET_ALL_SETTINGS)
 617        values = struct.unpack(self._SETTINGS_FORMAT, self._read_raw(struct.calcsize(self._SETTINGS_FORMAT)))
 618        centihz = values[0]
 619        waveforms = [WAVEFORMS[code] for code in values[1:5]]
 620        amplitudes, means = list(values[5:9]), list(values[9:13])
 621        peak_to_peak, fixed_voltage = list(self._peak_to_peak)[1:], list(self._fixed_voltage)[1:]
 622        for i, (name, amplitude, mean) in enumerate(zip(waveforms, amplitudes, means)):
 623            if name == FIXED_VOLTAGE:
 624                fixed_voltage[i] = amplitude / 1e6
 625                # This object's own peak to peak voltage must still suit the mean voltage (see _check_levels())
 626                peak_to_peak[i] = min(peak_to_peak[i], (2 * _MAX_VOLTAGE_UV - 2 * abs(mean)) / 1e6)
 627            else:
 628                peak_to_peak[i] = amplitude / 1e6
 629        self._frequency = centihz / 100
 630        self._samples_per_cycle = _samples_per_cycle(centihz)
 631        self._waveform._store(waveforms)
 632        self._peak_to_peak._store(peak_to_peak)
 633        self._fixed_voltage._store(fixed_voltage)
 634        self._mean_voltage._store([mean / 1e6 for mean in means])
 635        self._resting_voltage._store([rest / 1e6 for rest in values[13:17]])
 636        self._play_duration._store([d / 1e6 for d in values[17:21]])
 637        self._on_ramp_duration._store([d / 1e6 for d in values[21:25]])
 638        self._off_ramp_duration._store([d / 1e6 for d in values[25:29]])
 639        self._link_trigger_channel1._store([bool(link) for link in values[29:33]])
 640        self._link_trigger_channel2._store([bool(link) for link in values[33:37]])
 641        self._trigger_mode._store([TRIGGER_MODES[code] for code in values[37:39]])
 642        self._device_waveform, self._device_amplitude_uv, self._device_mean_uv = waveforms, amplitudes, means
 643        self._unsent = frozenset()  # Every setting is read
 644
 645    def export_params(self):
 646        """Return every setting, as a dict of plain values and lists.
 647
 648        Keyed by setting name: `"frequency"`, then the channel settings,
 649        each with one value per channel and no unused index 0. It holds
 650        only numbers, booleans and names, so it can be saved with `json`
 651        and logged with your data, to record exactly what the device
 652        played.
 653
 654        ```python
 655        import json
 656        with open("trial_settings.json", "w") as f:
 657            json.dump(S.export_params(), f)
 658        ```
 659        """
 660        params = {"frequency": self._frequency}
 661        params.update({name: list(getattr(self, name))[1:] for name in _CHANNEL_SETTINGS})
 662        return params
 663
 664    def import_params(self, params):
 665        """Program the device with settings exported by `SynthPalDevice.export_params`.
 666
 667        All of them are checked first, and then sent in one command, as
 668        at the end of a `SynthPalDevice.batch` block: in param sync mode,
 669        the device stores them for the next sync edge. The levels
 670        (`waveform`, `peak_to_peak`, `fixed_voltage`, `mean_voltage`) are
 671        checked together, so their order in `params` does not matter.
 672        Settings missing from `params` keep their values. A name that is
 673        not a setting is skipped with a warning, so that settings exported
 674        by a newer version of this package, which may have more, can still
 675        be imported.
 676
 677        ```python
 678        import json
 679        with open("trial_settings.json") as f:
 680            S.import_params(json.load(f))
 681        ```
 682
 683        Args:
 684            params: A dict of setting name to its value: a number for
 685                `"frequency"`, and one value per channel (a list, tuple or
 686                NumPy array) for the others, as `export_params` returns.
 687
 688        Raises:
 689            PulsePalError: If a value is invalid. Nothing is sent, and the
 690                settings keep the values they had.
 691        """
 692        names = {"frequency", *_CHANNEL_SETTINGS}
 693        unknown = [name for name in params if name not in names]
 694        if unknown:
 695            warnings.warn(f"import_params(): skipped {', '.join(map(repr, unknown))}, which this version of "
 696                          "SynthPalDevice does not have.", stacklevel=2)
 697        levels = ("waveform", "peak_to_peak", "fixed_voltage", "mean_voltage")
 698        with self.batch():
 699            for name, values in params.items():
 700                if name in levels:
 701                    # Each is checked alone here, and the levels together when the block ends (_check_sync()):
 702                    # an assignment would check each against the others' old values
 703                    getattr(self, name)._store(
 704                        self._normalize(name, _common.one_value_per_channel(name, values, 4)))
 705                elif name in names:
 706                    setattr(self, name, values)
 707
 708    def sync_to_device(self):
 709        """Send every setting to the device in one command.
 710
 711        While a trigger channel is in param sync mode, the device stores
 712        the set, replacing any set stored before, and that channel's next
 713        rising edge loads it (see `SynthPalDevice.trigger_mode`). Otherwise
 714        the device applies it at once. The settings sent are this object's:
 715        `frequency`, and for each channel `waveform`, `peak_to_peak` or
 716        `fixed_voltage`, `mean_voltage`, `resting_voltage`,
 717        `play_duration`, `on_ramp_duration`, `off_ramp_duration`,
 718        `link_trigger_channel1` and `link_trigger_channel2`, and
 719        `trigger_mode`. They read as sent, including while a set waits for
 720        its edge.
 721
 722        Raises:
 723            PulsePalError: If the device rejects the set.
 724        """
 725        self._check_sync()
 726        self._send_sync()
 727
 728    def configure(self, channels, *, waveform=None, peak_to_peak=None, fixed_voltage=None,
 729                  mean_voltage=None, resting_voltage=None, play_duration=None,
 730                  on_ramp_duration=None, off_ramp_duration=None):
 731        """Set several of the output channels' settings at once.
 732
 733        Settings left out keep their values. The new settings are checked
 734        together, so their order does not matter: for example, a channel
 735        whose sine wave swings 2 V around a mean voltage of 9 V can go
 736        straight to 20 V peak to peak around 0 V. With
 737        `SynthPalDevice.auto_sync` on, the class programs the device in an
 738        order it accepts at every step.
 739
 740        ```python
 741        S.configure(1, waveform="Sine", peak_to_peak=20, mean_voltage=0)
 742        S.configure([2, 3], waveform="Fixed Voltage", fixed_voltage=-2.5,
 743                    play_duration=0.1)
 744        ```
 745
 746        Args:
 747            channels: Output channel number 1-4, or several as a list,
 748                tuple or NumPy array.
 749            waveform, peak_to_peak, fixed_voltage, mean_voltage,
 750                resting_voltage, play_duration, on_ramp_duration,
 751                off_ramp_duration: New values, as for the settings of the
 752                same names: one value for every channel given, or a list
 753                with one value per channel given, in the same order.
 754
 755        Raises:
 756            PulsePalError: If a value is out of range, or the settings do
 757                not go together (nothing is sent), or the device rejects
 758                them.
 759        """
 760        channels = _common.channel_numbers(channels)
 761        if len(set(channels)) != len(channels):
 762            raise PulsePalError(f"configure(): channels must be distinct. Received {channels!r}.")
 763        requested = {"waveform": waveform, "peak_to_peak": peak_to_peak, "fixed_voltage": fixed_voltage,
 764                     "mean_voltage": mean_voltage, "resting_voltage": resting_voltage,
 765                     "play_duration": play_duration, "on_ramp_duration": on_ramp_duration,
 766                     "off_ramp_duration": off_ramp_duration}
 767        new_values = {}
 768        for name, value in requested.items():
 769            if value is None:
 770                continue
 771            values = list(value) if isinstance(value, (list, tuple, np.ndarray)) else [value]
 772            if len(values) == 1:
 773                values = values * len(channels)
 774            if len(values) != len(channels):
 775                raise PulsePalError(
 776                    f"configure(): {name} has {len(values)} values for {len(channels)} channels. Give one "
 777                    "value, or one value per channel."
 778                )
 779            settings = list(getattr(self, name))[1:]
 780            for channel, item in zip(channels, values):
 781                settings[channel - 1] = item
 782            new_values[name] = self._normalize(name, settings)
 783        levels = {name: new_values.get(name, list(getattr(self, name))[1:])
 784                  for name in ("waveform", "peak_to_peak", "fixed_voltage", "mean_voltage")}
 785        self._check_levels(levels["peak_to_peak"], levels["mean_voltage"], "configure")
 786        if self._auto_sync:
 787            self._send_levels(*self._device_levels(**levels))
 788        else:
 789            self._unsent = self._unsent | set(new_values)  # Until sync_to_device() sends them (see auto_sync)
 790        for name in levels:
 791            if name in new_values:
 792                getattr(self, name)._store(new_values[name])
 793        ops = {"resting_voltage": self._OP_SET_RESTING_VOLTAGE, "play_duration": self._OP_SET_PLAY_DURATION,
 794               "on_ramp_duration": self._OP_SET_ON_RAMP_DURATION,
 795               "off_ramp_duration": self._OP_SET_OFF_RAMP_DURATION}
 796        for name, op in ops.items():
 797            if name in new_values:
 798                if self._auto_sync:
 799                    self._send_setting(name, op, new_values[name])
 800                getattr(self, name)._store(new_values[name])
 801
 802    @property
 803    def samples_per_cycle(self):
 804        """Samples in one cycle of the waveform, at `frequency`.
 805
 806        The largest multiple of 4 whose sampling rate is at most
 807        `DeviceInfo.max_sampling_rate` (100 kHz), so that a sample falls
 808        on every edge, peak and trough of every waveform. For example,
 809        332 at 300 Hz and 4 at 20 kHz.
 810        """
 811        return self._samples_per_cycle
 812
 813    @property
 814    def sampling_rate(self):
 815        """The rate at which the device plays samples, in Hz.
 816
 817        `samples_per_cycle` times `frequency`: 100 kHz or just below it,
 818        and at least 50 kHz. For example, 100 kHz at 100 Hz, 99.6 kHz at
 819        300 Hz, and 80 kHz at 20 kHz. The device's timer cannot divide
 820        every rate exactly, so sample periods differ by up to one tick of
 821        its 24 MHz clock (42 ns), arranged so that the frequency is exact.
 822        """
 823        return self._samples_per_cycle * self._frequency
 824
 825    @property
 826    def waveform(self):
 827        """The waveform of each output channel.
 828
 829        Indexed by channel number (see "Channel settings" above). Each
 830        cycle starts at the trigger:
 831
 832        - `"Sine"` and `"Triangle"`: start at the mean voltage, rising.
 833        - `"Square"`: high for the first half of each cycle, then low.
 834        - `"Sawtooth"`: rises from its lowest voltage to its highest, then
 835          falls back at the end of the cycle.
 836        - `"Fixed Voltage"`: steps to the channel's
 837          `SynthPalDevice.fixed_voltage` for the play duration, and
 838          returns to the resting voltage. It is not periodic, so the
 839          frequency does not change it.
 840        - `"White Noise"`: a new random voltage on every sample, uniform
 841          over the peak to peak voltage around the mean voltage. It is
 842          rendered at the sampling rate the frequency sets
 843          (`SynthPalDevice.sampling_rate`), so no claim is made about its
 844          spectrum. Each channel has its own random sequence, which carries
 845          on from one playback to the next: every playback is new noise.
 846          The sequences start from the same point when the device powers
 847          up.
 848
 849        The periodic waveforms and white noise swing
 850        `SynthPalDevice.peak_to_peak` around `SynthPalDevice.mean_voltage`.
 851        Names are not case sensitive. A change applies to playback in
 852        progress.
 853        """
 854        return self._waveform
 855
 856    @waveform.setter
 857    def waveform(self, values):
 858        self._waveform._assign(values)
 859
 860    @property
 861    def peak_to_peak(self):
 862        """The peak to peak voltage of each output channel's waveform, in volts.
 863
 864        Indexed by channel number (see "Channel settings" above). 0 to
 865        20 V: the waveform swings `peak_to_peak / 2` above and below the
 866        channel's `SynthPalDevice.mean_voltage`, so it must stay within
 867        -10 V to 10 V: `abs(mean_voltage) + peak_to_peak / 2 <= 10`. To
 868        raise it beyond what the mean voltage allows, change the mean
 869        voltage first, or set both with `SynthPalDevice.configure`.
 870
 871        A sine wave of 4 V peak to peak swings from -2 V to 2 V around a
 872        mean voltage of 0 V: it is 2 sin(2 pi f t). White noise of 4 V peak
 873        to peak is uniform from -2 V to 2 V. A `"Fixed Voltage"` channel
 874        keeps its peak to peak voltage for when it plays a periodic waveform
 875        again.
 876
 877        Set to the nearest microvolt. A change applies to playback in
 878        progress.
 879        """
 880        return self._peak_to_peak
 881
 882    @peak_to_peak.setter
 883    def peak_to_peak(self, values):
 884        self._peak_to_peak._assign(values)
 885
 886    @property
 887    def fixed_voltage(self):
 888        """The voltage each output channel steps to when its waveform is
 889        `"Fixed Voltage"`, in volts.
 890
 891        Indexed by channel number (see "Channel settings" above). -10 to
 892        10 V, with any resting voltage and mean voltage. A channel playing
 893        another waveform keeps its fixed voltage for when its waveform is
 894        `"Fixed Voltage"` again.
 895
 896        Set to the nearest microvolt. A change applies to playback in
 897        progress.
 898        """
 899        return self._fixed_voltage
 900
 901    @fixed_voltage.setter
 902    def fixed_voltage(self, values):
 903        self._fixed_voltage._assign(values)
 904
 905    @property
 906    def mean_voltage(self):
 907        """The mean voltage of each output channel's waveform, in volts.
 908
 909        Indexed by channel number (see "Channel settings" above). -10 to
 910        10 V. A periodic waveform, or white noise, swings around it at full
 911        amplitude, and must stay within -10 V to 10 V with the
 912        `peak_to_peak` voltage: `abs(mean_voltage) + peak_to_peak / 2 <= 10`.
 913        It may differ from the `resting_voltage`, which the channel
 914        outputs between playbacks: the ramps move the mean between the two
 915        (see "Ramps" above). A `"Fixed Voltage"` ignores it. Set to the
 916        nearest microvolt. A change applies to playback in progress.
 917        """
 918        return self._mean_voltage
 919
 920    @mean_voltage.setter
 921    def mean_voltage(self, values):
 922        self._mean_voltage._assign(values)
 923
 924    @property
 925    def resting_voltage(self):
 926        """The resting voltage of each output channel, in volts.
 927
 928        Indexed by channel number (see "Channel settings" above). -10 to
 929        10 V, with any waveform. The channel outputs it while idle, and
 930        its ramps start and end there (see "Ramps" above). Set to the
 931        nearest microvolt. A change applies to playback in progress.
 932        """
 933        return self._resting_voltage
 934
 935    @resting_voltage.setter
 936    def resting_voltage(self, values):
 937        self._resting_voltage._assign(values)
 938
 939    @property
 940    def play_duration(self):
 941        """How long each output channel plays after a trigger, in seconds.
 942
 943        Indexed by channel number (see "Channel settings" above). Up to
 944        `DeviceInfo.max_play_duration` (3600 s). `0` plays until the
 945        channel is stopped. It counts at full amplitude, after the on
 946        ramp. The channel stops when the duration has elapsed, part way
 947        through a cycle if need be, and returns to its resting voltage,
 948        over its off ramp. Durations are counted in samples, rounded to
 949        the nearest one, and a nonzero duration lasts at least one sample.
 950        A change applies to playback in progress.
 951        """
 952        return self._play_duration
 953
 954    @play_duration.setter
 955    def play_duration(self, values):
 956        self._play_duration._assign(values)
 957
 958    @property
 959    def on_ramp_duration(self):
 960        """How long each output channel fades in after a trigger, in seconds.
 961
 962        Indexed by channel number (see "Channel settings" above). Up to
 963        `DeviceInfo.max_play_duration` (3600 s); `0` (the default) for no
 964        ramp. Over it, the peak to peak voltage rises linearly from 0 V to
 965        `peak_to_peak`, and the mean from the `resting_voltage` to the
 966        `mean_voltage`, before the play duration (see "Ramps" above).
 967        Counted in samples, as `play_duration` is. A change applies to
 968        playback in progress: a ramp under way keeps the level it has
 969        reached, and goes on at the new rate.
 970        """
 971        return self._on_ramp_duration
 972
 973    @on_ramp_duration.setter
 974    def on_ramp_duration(self, values):
 975        self._on_ramp_duration._assign(values)
 976
 977    @property
 978    def off_ramp_duration(self):
 979        """How long each output channel fades out when it stops, in seconds.
 980
 981        Indexed by channel number (see "Channel settings" above). Up to
 982        `DeviceInfo.max_play_duration` (3600 s); `0` (the default) for no
 983        ramp. Over it, the peak to peak voltage falls linearly to 0 V, and
 984        the mean to the `resting_voltage`, after the play duration or a
 985        stop (see "Ramps" above). Counted in samples, as `play_duration`
 986        is. A change applies to playback in progress.
 987        """
 988        return self._off_ramp_duration
 989
 990    @off_ramp_duration.setter
 991    def off_ramp_duration(self, values):
 992        self._off_ramp_duration._assign(values)
 993
 994    @property
 995    def trigger_mode(self):
 996        """How each trigger channel acts on the output channels linked to it.
 997
 998        Indexed by trigger channel number, 1 or 2 (index 0 is unused).
 999        These are Pulse Pal's trigger modes:
1000
1001        - `"Normal"`: a rising edge starts the linked channels. Channels
1002          that are playing ignore it.
1003        - `"Toggle"`: a rising edge starts the linked channels, or stops
1004          those that are playing.
1005        - `"Gated"`: a rising edge starts the linked channels, and a
1006          falling edge stops them, unless the other trigger channel is
1007          also gated, linked to them, and still high. With a play
1008          duration of 0, a channel plays for exactly as long as the TTL is
1009          high.
1010        - `"Param Sync"`: a rising edge starts and stops nothing (the
1011          channel's links are ignored). It loads the settings most recently
1012          sent by `SynthPalDevice.sync_to_device` (or at the end of a
1013          `SynthPalDevice.batch` block), if any. This is how the next
1014          trial's settings are sent during the current trial and applied
1015          the instant it starts.
1016
1017        At a param sync edge, the frequency and both trigger modes change
1018        at once: they are shared by all channels. An output channel at its
1019        resting voltage takes its new settings at once. One that is playing
1020        (off ramp included) finishes on the settings it started with, at
1021        the new frequency, and takes the new ones the moment it reaches its
1022        resting voltage, so the next trigger plays them. A trigger mode
1023        sent by `SynthPalDevice.sync_to_device` applies from the next
1024        edge.
1025
1026        Only `SynthPalDevice.sync_to_device` is held back for the edge:
1027        with `SynthPalDevice.auto_sync` on, every assignment programs the
1028        device at once, also in param sync mode. So leaving param sync mode
1029        means assigning `trigger_mode` with `auto_sync` on. When no trigger
1030        channel is left in the mode, the device discards a stored set.
1031
1032        To start channels on the same edge, wire the TTL to the other
1033        trigger channel too: the settings load first, so the channels it
1034        starts play the new settings. Connecting, and `set_default_params`,
1035        take both trigger channels out of param sync mode.
1036
1037        Names are not case sensitive.
1038        """
1039        return self._trigger_mode
1040
1041    @trigger_mode.setter
1042    def trigger_mode(self, values):
1043        self._trigger_mode._assign(values)
1044
1045    @property
1046    def link_trigger_channel1(self):
1047        """Whether each output channel is triggered by trigger channel 1.
1048
1049        Indexed by channel number (see "Channel settings" above). By
1050        default no output channel is linked to either trigger channel, so
1051        a TTL starts nothing until a script links it.
1052        """
1053        return self._link_trigger_channel1
1054
1055    @link_trigger_channel1.setter
1056    def link_trigger_channel1(self, values):
1057        self._link_trigger_channel1._assign(values)
1058
1059    @property
1060    def link_trigger_channel2(self):
1061        """Whether each output channel is triggered by trigger channel 2.
1062
1063        Indexed by channel number (see "Channel settings" above).
1064        """
1065        return self._link_trigger_channel2
1066
1067    @link_trigger_channel2.setter
1068    def link_trigger_channel2(self, values):
1069        self._link_trigger_channel2._assign(values)
1070
1071    def set_screen_saver(self, enabled, timeout=1800):
1072        """Switch the device's screen saver on or off, and set its timeout.
1073
1074        With the screen saver on, the device dims its screen once it has
1075        been left alone for `timeout` seconds: no command from a computer,
1076        no rising edge on a trigger channel, and no joystick click or
1077        push. The next of these brings the screen back. The device keeps
1078        both settings in its EEPROM, shared with Pulse Pal firmware, and
1079        saves them once no channel is playing. A new device starts with
1080        the screen saver on and 1800 s.
1081
1082        Args:
1083            enabled: `True` to switch the screen saver on, `False` to
1084                switch it off.
1085            timeout: Seconds without activity before the screen dims, 1 to
1086                65535.
1087
1088        Raises:
1089            PulsePalError: If a value is out of range.
1090        """
1091        state = to_bool(enabled, "enabled")
1092        if isinstance(timeout, (bool, np.bool_)) \
1093                or not isinstance(timeout, numbers.Integral) \
1094                or not 1 <= timeout <= 65535:
1095            raise PulsePalError(
1096                "timeout must be a whole number of seconds from 1 to 65535. "
1097                f"Received {timeout!r}."
1098            )
1099        self._write_command(self._OP_SET_SCREEN_SAVER,
1100                            struct.pack("<BH", int(state), int(timeout)))
1101        self._read_ack("set_screen_saver()")
1102
1103    # ------------------------------------------------------------------
1104    # Playback
1105    # ------------------------------------------------------------------
1106
1107    def trigger(self, channels):
1108        """Trigger output channels in software.
1109
1110        Channels that are idle start from the beginning of their waveform
1111        cycle, together. Channels that are playing ignore it, as they
1112        ignore a trigger channel in normal mode.
1113
1114        ```python
1115        S.trigger(1)
1116        S.trigger([2, 4])     # a list, tuple or NumPy array
1117        ```
1118
1119        Args:
1120            channels: Output channel number 1-4, or several as a list,
1121                tuple or NumPy array.
1122        """
1123        self._write_command(self._OP_TRIGGER, bytes([_common.channel_bits(channels)]))
1124
1125    def stop(self, channels=None):
1126        """Stop playback. The stopped channels return to their resting
1127        voltage, over their off ramps.
1128
1129        Args:
1130            channels: Output channel number 1-4, or several as a list,
1131                tuple or NumPy array. `None` stops all channels.
1132        """
1133        bits = self._ALL_CHANNELS if channels is None else _common.channel_bits(channels)
1134        self._write_command(self._OP_STOP, bytes([bits]))
1135
1136    def status(self):
1137        """Read the device's playback state.
1138
1139        Returns:
1140            A `DeviceStatus`, with the channels that are playing, the
1141            samples per cycle, each channel's output range, and timing
1142            figures.
1143        """
1144        self._write_command(self._OP_GET_STATUS)
1145        values = struct.unpack(
1146            self._STATUS_FORMAT,
1147            self._read_raw(struct.calcsize(self._STATUS_FORMAT)),
1148        )
1149        playing_bits = values[0]
1150        range_names = list(OUTPUT_RANGES)
1151        return DeviceStatus(
1152            playing=[ch for ch in range(1, 5)
1153                     if playing_bits & (1 << (ch - 1))],
1154            samples_per_cycle=values[1],
1155            output_ranges=[None, *(range_names[i] if i < len(range_names)
1156                                   else None for i in values[2:6])],
1157            longest_interrupt_us=values[6] / 1000,
1158            late_updates=values[7],
1159        )
1160
1161    def _playback_checksums(self):
1162        """For testing: what each channel played since it last started.
1163
1164        Returns two lists indexed by channel number, with index 0 unused:
1165        the number of samples played, and the sum of their DAC codes
1166        modulo 2**32. `tests/synthpal_hardware_test.py` compares them with
1167        the samples it expects, to check what the device played.
1168        """
1169        self._write_command(self._OP_GET_PLAYBACK_CHECKSUMS)
1170        values = struct.unpack("<8I", self._read_raw(32))
1171        return [None, *values[:4]], [None, *values[4:]]
1172
1173    # ------------------------------------------------------------------
1174    # Connection
1175    # ------------------------------------------------------------------
1176
1177    def close(self):
1178        """Close the connection to the device.
1179
1180        The device stops playback on all channels, each over its off ramp,
1181        and shows its own name on its screen again, in place of "PYTHON
1182        Connected". It keeps its settings, so TTL triggers still play the
1183        channels. Safe to call more than once. Called automatically when
1184        leaving a `with` block and when the object is garbage collected,
1185        so playback also stops when the last reference to the object
1186        goes.
1187
1188        """
1189        self._close(send_disconnect=True)
1190
1191    def _close(self, send_disconnect):
1192        """Close the connection. send_disconnect: tell the device first (op 81). False when the device may not
1193        run this firmware: op 81 means something else to other devices."""
1194        if getattr(self, "_closed", True):
1195            return
1196        self._closed = True
1197        if send_disconnect and self._unsent:
1198            _common.warn(f"SynthPalDevice closed with changes made with auto_sync off that were never sent to the "
1199                         f"device: {self._unsent_names()}.")
1200        self._reply_pending = None  # Nothing more is read, so a reply still to come needs no discarding
1201        try:
1202            if send_disconnect and self.port and self.port.is_open:
1203                self._write_command(self._OP_DISCONNECT)
1204        except Exception:
1205            pass  # The port may already be gone, e.g. the cable was unplugged
1206        finally:
1207            if self.port and self.port.is_open:
1208                self.port.close()
1209
1210    def __enter__(self):
1211        """Enter a `with` block, returning the connected device."""
1212        return self
1213
1214    def __exit__(self, exc_type, exc_value, traceback):
1215        """Close the port when leaving a `with` block.
1216
1217        Returns:
1218            `False`, so any exception raised in the block propagates.
1219        """
1220        self.close()
1221        return False
1222
1223    def __del__(self):
1224        """Close the port when the object is collected."""
1225        try:
1226            self.close()
1227        except Exception:
1228            # Destructors should not raise; the serial object may already be
1229            # gone during interpreter shutdown.
1230            pass
1231
1232    def __setattr__(self, name, value):
1233        """Refuse a public attribute that is not a setting, such as a misspelled one: it would program nothing (see
1234        _common.refuse_unknown_attribute())."""
1235        if name[0] != "_":
1236            _common.refuse_unknown_attribute(self, name, _SETTING_NAMES)
1237        super().__setattr__(name, value)
1238
1239    def __repr__(self):
1240        """Describe the device and its settings, e.g. with `print(S)`."""
1241        port = getattr(getattr(self, "port", None), "port", None)
1242        lines = [
1243            f"SynthPalDevice on {port} (Synth Pal firmware v{self.info.firmware_version})",
1244            f"frequency: {self._frequency} Hz ({self._samples_per_cycle} samples per cycle)",
1245            f"auto_sync: {self._auto_sync}",
1246        ]
1247        lines += [f"{name}: {list(getattr(self, name))}" for name in _CHANNEL_SETTINGS]
1248        return "\n".join(lines)
1249
1250    # ------------------------------------------------------------------
1251    # Internals: settings
1252    # ------------------------------------------------------------------
1253
1254    def _handshake(self):
1255        """Check that the device runs a supported Synth Pal firmware."""
1256        self._write_command(self._OP_HANDSHAKE)
1257        handshake = _common.read_handshake_reply(self.port)  # Skips a reply to an earlier session's command
1258        if handshake is None:
1259            raise PulsePalError(
1260                f"No reply from the device on {self.port.port}. Is it a "
1261                "Pulse Pal 3 running Synth Pal firmware?"
1262                f" {_common.NO_REPLY_HINT}"
1263            )
1264        reply, version = handshake
1265        if reply != self._HANDSHAKE_REPLY:
1266            if reply in _common.FIRMWARE_BY_HANDSHAKE_REPLY:
1267                raise _common.other_firmware_error(reply, version, self.port.port, "Synth Pal")
1268            raise PulsePalError(
1269                "Incorrect handshake returned. Expected "
1270                f"{self._HANDSHAKE_REPLY}, received {reply}."
1271            )
1272        if version > self._CURRENT_FIRMWARE_VERSION:
1273            # New firmware only adds commands (see PROTOCOL.md), so this class still works with it
1274            warnings.warn(
1275                f"Synth Pal firmware v{version} is newer than this pulsepal package knows "
1276                f"(v{self._CURRENT_FIRMWARE_VERSION}). It works with it, but update the package to use what is "
1277                "new.", stacklevel=3)
1278        self.info.firmware_version = version
1279
1280    def _read_hardware_info(self):
1281        self._write_command(self._OP_HARDWARE_INFO)
1282        (
1283            self.info.hardware_version,
1284            self.info.n_channels,
1285            min_centihz,
1286            max_centihz,
1287            self.info.max_sampling_rate,
1288            self.info.timer_clock_hz,
1289            max_duration_us,
1290        ) = struct.unpack(
1291            self._HARDWARE_INFO_FORMAT,
1292            self._read_raw(struct.calcsize(self._HARDWARE_INFO_FORMAT)),
1293        )
1294        self.info.min_frequency = min_centihz / 100
1295        self.info.max_frequency = max_centihz / 100
1296        self.info.max_play_duration = max_duration_us / 1e6
1297        self._write_command(self._OP_HARDWARE_REVISION)
1298        self.info.circuit_board_revision = self._read_raw(1)[0]
1299        self._write_command(_common.OP_SERIAL_NUMBER)
1300        self.info.serial_number = _common.read_serial_number(self._read_raw)
1301
1302    def _normalize(self, name, values):
1303        """Check new values for all four channels of a setting, and return them as its list holds them."""
1304        if name == "waveform":
1305            return [to_name(value, WAVEFORMS, "waveform") for value in values]
1306        if name == "peak_to_peak":
1307            return [self._to_volts(value, name, 0, 20, " peak to peak") for value in values]
1308        if name in ("fixed_voltage", "mean_voltage", "resting_voltage"):
1309            return [self._to_volts(value, name, -10, 10) for value in values]
1310        zero_meaning = "0 (play until stopped)" if name == "play_duration" else "0 (no ramp)"
1311        return [self._to_seconds(value, name, zero_meaning) for value in values]
1312
1313    def _check_levels(self, peak_to_peak, mean_voltage, setting):
1314        """Check that each channel's periodic waveform stays within -10 V to 10 V, as the device does.
1315
1316        Checked on every channel, also one playing a fixed voltage, so that a change of waveform always
1317        suits the levels it finds. `setting` is the one being changed, for the advice in the message.
1318        """
1319        for channel, (pp, mean) in enumerate(zip(peak_to_peak, mean_voltage), start=1):
1320            pp_uv, mean_uv = _microvolts(pp), _microvolts(mean)
1321            if 2 * abs(mean_uv) + pp_uv > 2 * _MAX_VOLTAGE_UV:
1322                advice = {
1323                    "peak_to_peak": "Change mean_voltage first, set both with configure(), or choose a "
1324                                    "smaller peak_to_peak.",
1325                    "mean_voltage": "Change peak_to_peak first, set both with configure(), or choose a "
1326                                    "smaller mean_voltage.",
1327                }.get(setting, "Change peak_to_peak or mean_voltage.")
1328                raise PulsePalError(
1329                    f"On channel {channel}, a mean voltage of {mean:g} V and a peak to peak voltage of "
1330                    f"{pp:g} V would reach {(abs(mean_uv) + pp_uv / 2) / 1e6:g} V. The waveform must "
1331                    f"stay within -10 V to 10 V. {advice}"
1332                )
1333
1334    def _device_levels(self, waveform, peak_to_peak, fixed_voltage, mean_voltage):
1335        """What the device holds for these settings: waveforms, amplitudes in microvolts (a fixed voltage, or a
1336        periodic waveform's peak to peak voltage) and mean voltages in microvolts."""
1337        amplitudes = [_microvolts(fixed if name == FIXED_VOLTAGE else pp)
1338                      for name, pp, fixed in zip(waveform, peak_to_peak, fixed_voltage)]
1339        return list(waveform), amplitudes, [_microvolts(mean) for mean in mean_voltage]
1340
1341    def _apply_levels(self, name, values):
1342        """Apply new values of waveform, peak_to_peak, fixed_voltage or mean_voltage, for all four channels."""
1343        values = self._normalize(name, values)
1344        levels = {level: list(getattr(self, level))[1:]
1345                  for level in ("waveform", "peak_to_peak", "fixed_voltage", "mean_voltage")}
1346        levels[name] = values
1347        self._check_levels(levels["peak_to_peak"], levels["mean_voltage"], name)
1348        if self._auto_sync:
1349            self._send_levels(*self._device_levels(**levels))
1350        else:
1351            self._unsent = self._unsent | {name}  # Until sync_to_device() sends it (see auto_sync)
1352        return values
1353
1354    def _apply_waveform(self, values):
1355        return self._apply_levels("waveform", values)
1356
1357    def _apply_peak_to_peak(self, values):
1358        return self._apply_levels("peak_to_peak", values)
1359
1360    def _apply_fixed_voltage(self, values):
1361        return self._apply_levels("fixed_voltage", values)
1362
1363    def _apply_mean_voltage(self, values):
1364        return self._apply_levels("mean_voltage", values)
1365
1366    def _send_levels(self, waveforms, amplitudes_uv, means_uv):
1367        """Program the device's waveforms, amplitudes and mean voltages, in an order it accepts at every step.
1368
1369        The device checks each op against what it holds for the other two (see _is_valid_level()), and one
1370        order does not suit every change: a sine wave of 20 V peak to peak cannot become a fixed voltage of
1371        -5 V by either op first. So each channel first takes an amplitude that suits both its current and
1372        its new waveform and mean voltage: its current amplitude if it can, else its new one, else 0 V, which
1373        suits any. Then the waveforms, the mean voltages and the new amplitudes follow. If the device
1374        refuses a step, which means this object's record of what it holds is out of date (e.g. a param sync
1375        edge loaded a stored set), the change is made from 0 V, which works from any state.
1376        """
1377        current = (self._device_waveform, self._device_amplitude_uv, self._device_mean_uv)
1378        if (waveforms, amplitudes_uv, means_uv) == tuple(map(list, current)):
1379            return
1380        first_amplitudes = []
1381        for w0, a0, m0, w1, a1, m1 in zip(*current, waveforms, amplitudes_uv, means_uv):
1382            for candidate in (a0, a1, 0):
1383                if all(_is_valid_level(w, candidate, m) for w, m in ((w0, m0), (w1, m0), (w1, m1))):
1384                    first_amplitudes.append(candidate)
1385                    break
1386        steps = (("A", first_amplitudes), ("W", waveforms), ("M", means_uv), ("A", amplitudes_uv))
1387        try:
1388            self._send_levels_in_order(steps)
1389        except _Rejected:
1390            self._send_levels_in_order((("A", [0] * 4), ("W", waveforms), ("M", means_uv),
1391                                        ("A", amplitudes_uv)), force=True)
1392
1393    def _send_levels_in_order(self, steps, force=False):
1394        """Send ops 'A' (amplitudes, uV), 'W' (waveform names) and 'M' (mean voltages, uV), in order, and keep
1395        the record of what the device holds. Unless `force`, an op that would not change what the record says
1396        the device holds is skipped."""
1397        for op, values in steps:
1398            if op == "W":
1399                if values == self._device_waveform and not force:
1400                    continue
1401                self._write_command(self._OP_SET_WAVEFORM, bytes(WAVEFORMS.index(name) for name in values))
1402                self._read_ack("setting waveform")
1403                self._device_waveform = list(values)
1404            elif op == "A":
1405                if values == self._device_amplitude_uv and not force:
1406                    continue
1407                self._write_command(self._OP_SET_AMPLITUDE, struct.pack("<4i", *values))
1408                self._read_ack("setting the amplitude")
1409                self._device_amplitude_uv = list(values)
1410            else:
1411                if values == self._device_mean_uv and not force:
1412                    continue
1413                self._write_command(self._OP_SET_MEAN_VOLTAGE, struct.pack("<4i", *values))
1414                self._read_ack("setting mean_voltage")
1415                self._device_mean_uv = list(values)
1416
1417    def _apply_resting_voltage(self, values):
1418        # Any resting voltage within -10 V to 10 V goes with any waveform
1419        values = self._normalize("resting_voltage", values)
1420        if self._auto_sync:
1421            self._send_setting("resting_voltage", self._OP_SET_RESTING_VOLTAGE, values)
1422        else:
1423            self._unsent = self._unsent | {"resting_voltage"}
1424        return values
1425
1426    def _apply_play_duration(self, values):
1427        return self._apply_duration("play_duration", self._OP_SET_PLAY_DURATION, values)
1428
1429    def _apply_on_ramp_duration(self, values):
1430        return self._apply_duration("on_ramp_duration", self._OP_SET_ON_RAMP_DURATION, values)
1431
1432    def _apply_off_ramp_duration(self, values):
1433        return self._apply_duration("off_ramp_duration", self._OP_SET_OFF_RAMP_DURATION, values)
1434
1435    def _apply_duration(self, name, op, values):
1436        values = self._normalize(name, values)
1437        if self._auto_sync:
1438            self._send_setting(name, op, values)
1439        else:
1440            self._unsent = self._unsent | {name}
1441        return values
1442
1443    def _send_setting(self, name, op, values):
1444        """Send the resting voltages (int32 microvolts) or a duration (uint32 microseconds), for all four
1445        channels."""
1446        if name == "resting_voltage":
1447            data = struct.pack("<4i", *(_microvolts(v) for v in values))
1448        else:
1449            data = struct.pack("<4I", *(round(d * 1e6) for d in values))
1450        self._write_command(op, data)
1451        self._read_ack(f"setting {name}")
1452
1453    def _apply_trigger_mode(self, values):
1454        names = [to_name(value, TRIGGER_MODES, "trigger mode") for value in values]
1455        if self._auto_sync:
1456            self._write_command(
1457                self._OP_SET_TRIGGER_MODE,
1458                bytes(TRIGGER_MODES.index(name) for name in names),
1459            )
1460            self._read_ack("setting trigger_mode")
1461        else:
1462            self._unsent = self._unsent | {"trigger_mode"}
1463        return names
1464
1465    # One op programs both trigger channels' links, so each list is sent
1466    # with the other's current values
1467    def _apply_trigger_channel1_links(self, values):
1468        links1 = [to_bool(v, "link_trigger_channel1") for v in values]
1469        self._send_trigger_links(links1, self._link_trigger_channel2[1:])
1470        if not self._auto_sync:
1471            self._unsent = self._unsent | {"link_trigger_channel1"}
1472        return links1
1473
1474    def _apply_trigger_channel2_links(self, values):
1475        links2 = [to_bool(v, "link_trigger_channel2") for v in values]
1476        self._send_trigger_links(self._link_trigger_channel1[1:], links2)
1477        if not self._auto_sync:
1478            self._unsent = self._unsent | {"link_trigger_channel2"}
1479        return links2
1480
1481    def _set_trigger_links(self, links1, links2):
1482        """Program both trigger channels' links with one command."""
1483        self._send_trigger_links(links1, links2)
1484        self._link_trigger_channel1._store(links1)
1485        self._link_trigger_channel2._store(links2)
1486
1487    def _send_trigger_links(self, links1, links2):
1488        if self._auto_sync:
1489            self._write_command(self._OP_SET_TRIGGER_LINKS,
1490                                bytes([*map(int, links1), *map(int, links2)]))
1491            self._read_ack("setting the trigger channel links")
1492
1493    def _snapshot(self):
1494        """All settings, and which are unsent, for batch() to restore if its block fails."""
1495        settings = {name: list(getattr(self, name))[1:] for name in _CHANNEL_SETTINGS}
1496        return settings, self._frequency, self._samples_per_cycle, self._unsent
1497
1498    def _restore(self, snapshot):
1499        settings, self._frequency, self._samples_per_cycle, self._unsent = snapshot
1500        for name, values in settings.items():
1501            getattr(self, name)._store(values)
1502
1503    def _unsent_names(self):
1504        """The settings assigned with auto_sync off and not yet sent, for messages, in the class's order."""
1505        return ", ".join(name for name in _SETTING_NAMES if name in self._unsent)
1506
1507    def _check_sync(self):
1508        """Check the whole set before sync_to_device() sends it."""
1509        self._check_levels(self._peak_to_peak[1:], self._mean_voltage[1:], "sync")
1510
1511    def _send_sync(self):
1512        """Send every setting with op 85 ('U'). The device checks the whole set, so no order matters."""
1513        self._unsent = frozenset()  # All are sent below
1514        waveforms, amplitudes, means = self._device_levels(
1515            self._waveform[1:], self._peak_to_peak[1:], self._fixed_voltage[1:], self._mean_voltage[1:])
1516        links = [int(link) for link in (*self._link_trigger_channel1[1:],
1517                                        *self._link_trigger_channel2[1:])]
1518        payload = struct.pack(
1519            self._SETTINGS_FORMAT,
1520            round(self._frequency * 100),
1521            *(WAVEFORMS.index(name) for name in waveforms),
1522            *amplitudes, *means, *(_microvolts(v) for v in self._resting_voltage[1:]),
1523            *(round(d * 1e6) for d in self._play_duration[1:]),
1524            *(round(d * 1e6) for d in self._on_ramp_duration[1:]),
1525            *(round(d * 1e6) for d in self._off_ramp_duration[1:]),
1526            *links,
1527            *(TRIGGER_MODES.index(name) for name in self._trigger_mode[1:]),
1528        )
1529        self._write_command(self._OP_SET_ALL_SETTINGS, payload)
1530        self._read_ack("sync_to_device()")
1531        # Applied now, or, in param sync mode, at the next edge. Either way these are the levels the next
1532        # assignments build on; if a stored set has not loaded yet, _send_levels() finds out and recovers.
1533        self._device_waveform, self._device_amplitude_uv, self._device_mean_uv = waveforms, amplitudes, means
1534
1535    @staticmethod
1536    def _to_volts(value, name, low, high, note=""):
1537        if isinstance(value, (bool, np.bool_)) or not isinstance(value, numbers.Real) \
1538                or not math.isfinite(value) or not low <= value <= high:
1539            raise PulsePalError(
1540                f"{name} values must be numbers of volts from {low} to "
1541                f"{high}{note}. Received {value!r}."
1542            )
1543        return float(value)
1544
1545    def _to_seconds(self, value, name, zero_meaning):
1546        if isinstance(value, (bool, np.bool_)) or not isinstance(value, numbers.Real):
1547            raise PulsePalError(
1548                f"{name} must be in seconds. Received {value!r}."
1549            )
1550        duration = float(value)
1551        if not math.isfinite(duration) or not \
1552                0 <= duration <= self.info.max_play_duration:
1553            raise PulsePalError(
1554                f"{name} must be {zero_meaning} or a positive number "
1555                f"of seconds up to {self.info.max_play_duration:g}. "
1556                f"Received {value!r}."
1557            )
1558        return duration
1559
1560    # ------------------------------------------------------------------
1561    # Internals: serial port
1562    # ------------------------------------------------------------------
1563
1564    def _write_command(self, op_code, data=b""):
1565        """Send one command, with its framing byte, in a single write.
1566
1567        A command whose reply was not read in full, because the read timed out or was interrupted (Ctrl-C), leaves
1568        that reply, or the rest of it, to arrive later. Read as the reply to the next command, it would put every
1569        reply after it out of step. So it is discarded first (_discard_lost_reply()), and this command's reply, if it
1570        has one, is pending until _read_raw() has read it, as in PulsePalDevice.
1571        """
1572        if self._reply_pending is not None:
1573            self._discard_lost_reply()
1574        if op_code not in _OPS_WITHOUT_TRACKED_REPLY:
1575            self._reply_pending = op_code  # Before the write: a Ctrl-C can come as soon as it returns
1576        message = bytes([self._OP_MENU_BYTE, op_code]) + data
1577        bytes_written = self.port.write(message)
1578        if bytes_written != len(message):
1579            raise PulsePalError(
1580                f"Wrote {bytes_written} byte(s), expected to write "
1581                f"{len(message)} byte(s)."
1582            )
1583
1584    def _discard_lost_reply(self):
1585        """Discard what arrives of a reply that was not read in full (see _write_command()), and bring this object
1586        back in step with the device.
1587
1588        The command may have changed settings, unknown to this object: an assignment interrupted after its write,
1589        for one, which the device took and this object did not. So after one of _SETTING_OPS, they are read back
1590        (op 'R'), outside param sync mode, where op 'R' returns the settings playing rather than a set stored for the
1591        next sync edge.
1592        """
1593        op = self._reply_pending
1594        self._reply_pending = None
1595        n_late = len(_common.read_until_quiet(self.port))
1596        read_back = op in self._SETTING_OPS and "Param Sync" not in list(self._trigger_mode)[1:]
1597        _common.warn_lost_reply(f"op {chr(op)!r}", n_late,
1598                                ", and the settings were read back from the device" if read_back else "")
1599        if read_back:
1600            try:
1601                self.sync_from_device()
1602            except PulsePalError as exc:
1603                raise PulsePalError(
1604                    f"Synth Pal did not send its settings, read back after the reply to an earlier command was lost. "
1605                    f"{_common.NO_REPLY_HINT}"
1606                ) from exc
1607
1608    def _read_raw(self, n_bytes):
1609        """Read exactly n_bytes from the serial port: the whole reply to the command last sent, which is then no
1610        longer pending (see _write_command()). Only connecting reads a reply in more than one call."""
1611        message = self.port.read(n_bytes)
1612        if len(message) < n_bytes:
1613            raise PulsePalError(
1614                f"Synth Pal sent {len(message)} of the {n_bytes} byte(s) of its reply within "
1615                f"{self.port.timeout} s. {_common.NO_REPLY_HINT}"
1616            )
1617        self._reply_pending = None
1618        return message
1619
1620    def _read_ack(self, context):
1621        """Read a one-byte confirmation: 1 if the device executed the
1622        command, 0 if it rejected it."""
1623        try:
1624            reply = self._read_raw(1)[0]
1625        except PulsePalError as exc:
1626            raise PulsePalError(
1627                f"Synth Pal did not confirm {context}: no reply arrived within {self.port.timeout} s. "
1628                f"{_common.NO_REPLY_HINT}"
1629            ) from exc
1630        if reply != 1:
1631            raise _Rejected(
1632                f"Synth Pal rejected {context}. A value was out of range."
1633            )

A class to control a Synth Pal on a USB serial port.

Creating an instance opens the serial port, checks that the device runs Synth Pal firmware, reads its properties into SynthPalDevice.info, shows "PYTHON Connected" on the device's screen, stops any playback and programs the default settings (see SynthPalDevice.set_default_params).

from pulsepal import SynthPalDevice

S = SynthPalDevice("COM3")
S.waveform[1] = "Triangle"
S.trigger(1)
S.close()

Replace "COM3" with the device's USB serial port name, which SynthPalDevice.serialportlist lists. SynthPalDevice is also a context manager, which closes the port on exit.

Closing the connection stops playback, as it does on Pulse Pal (each channel over its off ramp), and puts the device's own name back on its screen. The device keeps its settings, so TTL triggers still play the channels.

SynthPalDevice(port_name=None, *, serial_number=None, timeout=10)
362    def __init__(self, port_name=None, *, serial_number=None, timeout=10):
363        """Open a connection to a Synth Pal.
364
365        Args:
366            port_name: USB serial port of the device, such as `COM3` on
367                Windows or `/dev/ttyACM0` on Linux.
368            serial_number: Instead of `port_name`, the device's USB serial
369                number (`DeviceInfo.serial_number`), which finds its port
370                wherever it is plugged in.
371            timeout: Serial read timeout, in seconds.
372
373        Firmware newer than this module knows is used with a warning: new
374        firmware only adds commands. Update the package to use what is new.
375
376        Raises:
377            PulsePalError: If the device does not reply to the handshake, or
378                runs other firmware.
379            serial.SerialException: If the serial port cannot be opened,
380                e.g. because another program has it open (on Linux and
381                macOS, another session of these classes, which lock it).
382        """
383        self._closed = True
384        self._auto_sync = True
385        self.info = DeviceInfo()
386        self._frequency = None
387        self._samples_per_cycle = None
388        # What the device holds for each output channel's waveform, amplitude (in microvolts: a periodic
389        # waveform's peak to peak voltage, or a fixed voltage) and mean voltage (in microvolts), as far as this
390        # object has programmed them. _send_levels() changes them in an order the device accepts.
391        self._device_waveform = ["Sine"] * 4
392        self._device_amplitude_uv = [5_000_000] * 4
393        self._device_mean_uv = [0] * 4
394        self._waveform = ChannelSettings(
395            "waveform", ["Sine"] * 4, self, "_apply_waveform")
396        self._peak_to_peak = ChannelSettings(
397            "peak_to_peak", [5.0] * 4, self, "_apply_peak_to_peak")
398        self._fixed_voltage = ChannelSettings(
399            "fixed_voltage", [5.0] * 4, self, "_apply_fixed_voltage")
400        self._mean_voltage = ChannelSettings(
401            "mean_voltage", [0.0] * 4, self, "_apply_mean_voltage")
402        self._resting_voltage = ChannelSettings(
403            "resting_voltage", [0.0] * 4, self, "_apply_resting_voltage")
404        self._play_duration = ChannelSettings(
405            "play_duration", [1.0] * 4, self, "_apply_play_duration")
406        self._on_ramp_duration = ChannelSettings(
407            "on_ramp_duration", [0.0] * 4, self, "_apply_on_ramp_duration")
408        self._off_ramp_duration = ChannelSettings(
409            "off_ramp_duration", [0.0] * 4, self, "_apply_off_ramp_duration")
410        self._trigger_mode = ChannelSettings(
411            "trigger_mode", ["Normal"] * 2, self, "_apply_trigger_mode")
412        self._link_trigger_channel1 = ChannelSettings(
413            "link_trigger_channel1", [False] * 4, self,
414            "_apply_trigger_channel1_links")
415        self._link_trigger_channel2 = ChannelSettings(
416            "link_trigger_channel2", [False] * 4, self,
417            "_apply_trigger_channel2_links")
418
419        port_name = _common.port_name_from(port_name, serial_number, "SynthPalDevice")
420        self.port = serial.Serial(
421            port_name,
422            self._BAUD_RATE,
423            timeout=timeout,
424            rtscts=True,
425            # Windows opens a port for one program only. Linux and macOS let a second program open it too, and two
426            # sessions' bytes would then interleave; exclusive locks it (flock), so a second session that also locks
427            # it, such as another of these classes, gets an error instead
428            exclusive=True,
429        )
430        self._closed = False
431        try:
432            # Discard anything left in the buffer by an earlier session
433            self.port.reset_input_buffer()
434            self._handshake()
435            self._read_hardware_info()
436            # Client name op + "PYTHON" in ASCII, shown as "PYTHON Connected"
437            self._write_command(self._OP_SET_CLIENT_NAME, b"PYTHON")
438            self.stop()
439            self.set_default_params()
440        except BaseException:
441            # Op 81 means something else to other devices, so it is sent
442            # only once the device has identified itself as a Synth Pal
443            self._close(send_disconnect=self.info.firmware_version is not None)
444            raise

Open a connection to a Synth Pal.

Arguments:
  • port_name: USB serial port of the device, such as COM3 on Windows or /dev/ttyACM0 on Linux.
  • serial_number: Instead of port_name, the device's USB serial number (DeviceInfo.serial_number), which finds its port wherever it is plugged in.
  • timeout: Serial read timeout, in seconds.

Firmware newer than this module knows is used with a warning: new firmware only adds commands. Update the package to use what is new.

Raises:
  • PulsePalError: If the device does not reply to the handshake, or runs other firmware.
  • serial.SerialException: If the serial port cannot be opened, e.g. because another program has it open (on Linux and macOS, another session of these classes, which lock it).
port: serial.serialposix.Serial

The open serial.Serial port connected to the device.

info: DeviceInfo

Properties of the connected device. See DeviceInfo.

@staticmethod
def serialportlist(ports_to_list='available'):
446    @staticmethod
447    def serialportlist(ports_to_list="available"):
448        """Return the names of the USB serial ports on this computer.
449
450        Called on the class, without connecting to a device, to find the
451        port name to pass to `SynthPalDevice`.
452
453        Args:
454            ports_to_list: `available` to list only the ports that are
455                not already in use, or `all` to list every USB serial
456                port. Not case sensitive.
457
458        Returns:
459            Sorted list of port names, such as `["COM3", "COM7"]`.
460
461        Raises:
462            PulsePalError: If `ports_to_list` is not `available` or `all`.
463        """
464        return _common.serialportlist(ports_to_list)

Return the names of the USB serial ports on this computer.

Called on the class, without connecting to a device, to find the port name to pass to SynthPalDevice.

Arguments:
  • ports_to_list: available to list only the ports that are not already in use, or all to list every USB serial port. Not case sensitive.
Returns:

Sorted list of port names, such as ["COM3", "COM7"].

Raises:
  • PulsePalError: If ports_to_list is not available or all.
def set_default_params(self):
470    def set_default_params(self):
471        """Program the default settings on the device.
472
473        The defaults are a frequency of 100 Hz, and on every output
474        channel a sine wave of 5 V peak to peak around a mean voltage of
475        0 V, resting at 0 V, played for 1 second with no ramps, and a
476        fixed voltage of 5 V for when the waveform is "Fixed Voltage".
477        Both trigger channels are in normal mode, and no output channel is
478        linked to a trigger channel, so a TTL starts nothing until a script
479        links it. They match the settings the device starts with. They are sent at once,
480        also while `SynthPalDevice.auto_sync` is off.
481        """
482        auto_sync = self._auto_sync
483        self._auto_sync = True
484        try:
485            self.frequency = 100
486            # In this order, each is valid whatever the device holds: a mean of 0 V goes with any
487            # amplitude, 5 V is then a valid amplitude for any waveform, and a sine wave is then valid
488            self._send_levels_in_order((("M", [0] * 4), ("A", [5_000_000] * 4), ("W", ["Sine"] * 4)),
489                                       force=True)
490            for settings, value in ((self._waveform, "Sine"), (self._peak_to_peak, 5.0),
491                                    (self._fixed_voltage, 5.0), (self._mean_voltage, 0.0)):
492                settings._store([value] * 4)
493            self.resting_voltage = [0] * 4
494            self.play_duration = [1] * 4
495            self.on_ramp_duration = [0] * 4
496            self.off_ramp_duration = [0] * 4
497            self.trigger_mode = ["Normal"] * 2
498            self._set_trigger_links([False] * 4, [False] * 4)
499            self._unsent = frozenset()  # Every setting has been sent
500        finally:
501            self._auto_sync = auto_sync

Program the default settings on the device.

The defaults are a frequency of 100 Hz, and on every output channel a sine wave of 5 V peak to peak around a mean voltage of 0 V, resting at 0 V, played for 1 second with no ramps, and a fixed voltage of 5 V for when the waveform is "Fixed Voltage". Both trigger channels are in normal mode, and no output channel is linked to a trigger channel, so a TTL starts nothing until a script links it. They match the settings the device starts with. They are sent at once, also while SynthPalDevice.auto_sync is off.

frequency
503    @property
504    def frequency(self):
505        """Frequency of all output channels, in Hz.
506
507        1 to 20000 Hz, rounded to 0.01 Hz. It can be changed during
508        playback: playing channels carry on from the same point in their
509        cycle, at the new frequency, and keep the time they have left to
510        play. The frequency played is exact: the device's sample clock is a
511        whole multiple of it (see `SynthPalDevice.sampling_rate`). For
512        `"White Noise"` it sets only the sampling rate.
513        """
514        return self._frequency

Frequency of all output channels, in Hz.

1 to 20000 Hz, rounded to 0.01 Hz. It can be changed during playback: playing channels carry on from the same point in their cycle, at the new frequency, and keep the time they have left to play. The frequency played is exact: the device's sample clock is a whole multiple of it (see SynthPalDevice.sampling_rate). For "White Noise" it sets only the sampling rate.

auto_sync
544    @property
545    def auto_sync(self):
546        """Whether assigning a setting programs the device at once.
547
548        `True` (the default): each assignment, such as
549        `S.peak_to_peak[1] = 2`, programs the device at once, as the
550        device's own op for that setting does, also while a trigger
551        channel is in param sync mode.
552
553        `False`: assignments change only this object's copy of the settings,
554        checking each value; `SynthPalDevice.sync_to_device` then sends all
555        of them at once. Use it to store the next trial's settings for a
556        param sync edge (see `SynthPalDevice.trigger_mode`), or to change
557        several settings in one command. Turning it back on does not send
558        changes made meanwhile, so it raises `pulsepal.PulsePalError` while
559        there are any: call `SynthPalDevice.sync_to_device` first to send
560        them, or `SynthPalDevice.sync_from_device` to discard them. A
561        `SynthPalDevice.batch` block does it all for you. `close()` warns of
562        changes never sent. `trigger`, `stop`, `status` and
563        `set_default_params` act at once either way.
564        """
565        return self._auto_sync

Whether assigning a setting programs the device at once.

True (the default): each assignment, such as S.peak_to_peak[1] = 2, programs the device at once, as the device's own op for that setting does, also while a trigger channel is in param sync mode.

False: assignments change only this object's copy of the settings, checking each value; SynthPalDevice.sync_to_device then sends all of them at once. Use it to store the next trial's settings for a param sync edge (see SynthPalDevice.trigger_mode), or to change several settings in one command. Turning it back on does not send changes made meanwhile, so it raises pulsepal.PulsePalError while there are any: call SynthPalDevice.sync_to_device first to send them, or SynthPalDevice.sync_from_device to discard them. A SynthPalDevice.batch block does it all for you. close() warns of changes never sent. trigger, stop, status and set_default_params act at once either way.

def batch(self):
578    def batch(self):
579        """Change several settings, and send them in one command.
580
581        Returns a context manager. In its block, assignments change only
582        this object's copy of the settings (as with
583        `SynthPalDevice.auto_sync` off), and when the block ends,
584        `SynthPalDevice.sync_to_device` sends all of them at once. If the
585        block raises an error, nothing is sent, and the settings go back
586        to what they were.
587
588        ```python
589        with S.batch():
590            S.frequency = 880
591            S.waveform = ["Triangle"] * 4
592            S.peak_to_peak = [2, 4, 6, 8]
593        ```
594
595        In param sync mode, the device stores the settings for the next
596        rising edge on the param sync channel (see
597        `SynthPalDevice.trigger_mode`).
598        """
599        return _common.batch(self)

Change several settings, and send them in one command.

Returns a context manager. In its block, assignments change only this object's copy of the settings (as with SynthPalDevice.auto_sync off), and when the block ends, SynthPalDevice.sync_to_device sends all of them at once. If the block raises an error, nothing is sent, and the settings go back to what they were.

with S.batch():
    S.frequency = 880
    S.waveform = ["Triangle"] * 4
    S.peak_to_peak = [2, 4, 6, 8]

In param sync mode, the device stores the settings for the next rising edge on the param sync channel (see SynthPalDevice.trigger_mode).

def sync_from_device(self):
601    def sync_from_device(self):
602        """Read every setting from the device into this object.
603
604        Use it after settings were changed with the device's joystick, for
605        example. The device holds one amplitude per channel: a Fixed
606        Voltage channel's goes to `SynthPalDevice.fixed_voltage`, and any
607        other channel's to `SynthPalDevice.peak_to_peak`. This object keeps
608        the other one, reduced if need be to suit the mean voltage read
609        back. In param sync mode, it reads the settings the device plays
610        now, not a set waiting for the next edge. Changes made with
611        `SynthPalDevice.auto_sync` off and not yet sent are discarded.
612
613        Raises:
614            PulsePalError: If the device does not reply.
615        """
616        self._write_command(self._OP_GET_ALL_SETTINGS)
617        values = struct.unpack(self._SETTINGS_FORMAT, self._read_raw(struct.calcsize(self._SETTINGS_FORMAT)))
618        centihz = values[0]
619        waveforms = [WAVEFORMS[code] for code in values[1:5]]
620        amplitudes, means = list(values[5:9]), list(values[9:13])
621        peak_to_peak, fixed_voltage = list(self._peak_to_peak)[1:], list(self._fixed_voltage)[1:]
622        for i, (name, amplitude, mean) in enumerate(zip(waveforms, amplitudes, means)):
623            if name == FIXED_VOLTAGE:
624                fixed_voltage[i] = amplitude / 1e6
625                # This object's own peak to peak voltage must still suit the mean voltage (see _check_levels())
626                peak_to_peak[i] = min(peak_to_peak[i], (2 * _MAX_VOLTAGE_UV - 2 * abs(mean)) / 1e6)
627            else:
628                peak_to_peak[i] = amplitude / 1e6
629        self._frequency = centihz / 100
630        self._samples_per_cycle = _samples_per_cycle(centihz)
631        self._waveform._store(waveforms)
632        self._peak_to_peak._store(peak_to_peak)
633        self._fixed_voltage._store(fixed_voltage)
634        self._mean_voltage._store([mean / 1e6 for mean in means])
635        self._resting_voltage._store([rest / 1e6 for rest in values[13:17]])
636        self._play_duration._store([d / 1e6 for d in values[17:21]])
637        self._on_ramp_duration._store([d / 1e6 for d in values[21:25]])
638        self._off_ramp_duration._store([d / 1e6 for d in values[25:29]])
639        self._link_trigger_channel1._store([bool(link) for link in values[29:33]])
640        self._link_trigger_channel2._store([bool(link) for link in values[33:37]])
641        self._trigger_mode._store([TRIGGER_MODES[code] for code in values[37:39]])
642        self._device_waveform, self._device_amplitude_uv, self._device_mean_uv = waveforms, amplitudes, means
643        self._unsent = frozenset()  # Every setting is read

Read every setting from the device into this object.

Use it after settings were changed with the device's joystick, for example. The device holds one amplitude per channel: a Fixed Voltage channel's goes to SynthPalDevice.fixed_voltage, and any other channel's to SynthPalDevice.peak_to_peak. This object keeps the other one, reduced if need be to suit the mean voltage read back. In param sync mode, it reads the settings the device plays now, not a set waiting for the next edge. Changes made with SynthPalDevice.auto_sync off and not yet sent are discarded.

Raises:
  • PulsePalError: If the device does not reply.
def export_params(self):
645    def export_params(self):
646        """Return every setting, as a dict of plain values and lists.
647
648        Keyed by setting name: `"frequency"`, then the channel settings,
649        each with one value per channel and no unused index 0. It holds
650        only numbers, booleans and names, so it can be saved with `json`
651        and logged with your data, to record exactly what the device
652        played.
653
654        ```python
655        import json
656        with open("trial_settings.json", "w") as f:
657            json.dump(S.export_params(), f)
658        ```
659        """
660        params = {"frequency": self._frequency}
661        params.update({name: list(getattr(self, name))[1:] for name in _CHANNEL_SETTINGS})
662        return params

Return every setting, as a dict of plain values and lists.

Keyed by setting name: "frequency", then the channel settings, each with one value per channel and no unused index 0. It holds only numbers, booleans and names, so it can be saved with json and logged with your data, to record exactly what the device played.

import json
with open("trial_settings.json", "w") as f:
    json.dump(S.export_params(), f)
def import_params(self, params):
664    def import_params(self, params):
665        """Program the device with settings exported by `SynthPalDevice.export_params`.
666
667        All of them are checked first, and then sent in one command, as
668        at the end of a `SynthPalDevice.batch` block: in param sync mode,
669        the device stores them for the next sync edge. The levels
670        (`waveform`, `peak_to_peak`, `fixed_voltage`, `mean_voltage`) are
671        checked together, so their order in `params` does not matter.
672        Settings missing from `params` keep their values. A name that is
673        not a setting is skipped with a warning, so that settings exported
674        by a newer version of this package, which may have more, can still
675        be imported.
676
677        ```python
678        import json
679        with open("trial_settings.json") as f:
680            S.import_params(json.load(f))
681        ```
682
683        Args:
684            params: A dict of setting name to its value: a number for
685                `"frequency"`, and one value per channel (a list, tuple or
686                NumPy array) for the others, as `export_params` returns.
687
688        Raises:
689            PulsePalError: If a value is invalid. Nothing is sent, and the
690                settings keep the values they had.
691        """
692        names = {"frequency", *_CHANNEL_SETTINGS}
693        unknown = [name for name in params if name not in names]
694        if unknown:
695            warnings.warn(f"import_params(): skipped {', '.join(map(repr, unknown))}, which this version of "
696                          "SynthPalDevice does not have.", stacklevel=2)
697        levels = ("waveform", "peak_to_peak", "fixed_voltage", "mean_voltage")
698        with self.batch():
699            for name, values in params.items():
700                if name in levels:
701                    # Each is checked alone here, and the levels together when the block ends (_check_sync()):
702                    # an assignment would check each against the others' old values
703                    getattr(self, name)._store(
704                        self._normalize(name, _common.one_value_per_channel(name, values, 4)))
705                elif name in names:
706                    setattr(self, name, values)

Program the device with settings exported by SynthPalDevice.export_params.

All of them are checked first, and then sent in one command, as at the end of a SynthPalDevice.batch block: in param sync mode, the device stores them for the next sync edge. The levels (waveform, peak_to_peak, fixed_voltage, mean_voltage) are checked together, so their order in params does not matter. Settings missing from params keep their values. A name that is not a setting is skipped with a warning, so that settings exported by a newer version of this package, which may have more, can still be imported.

import json
with open("trial_settings.json") as f:
    S.import_params(json.load(f))
Arguments:
  • params: A dict of setting name to its value: a number for "frequency", and one value per channel (a list, tuple or NumPy array) for the others, as export_params returns.
Raises:
  • PulsePalError: If a value is invalid. Nothing is sent, and the settings keep the values they had.
def sync_to_device(self):
708    def sync_to_device(self):
709        """Send every setting to the device in one command.
710
711        While a trigger channel is in param sync mode, the device stores
712        the set, replacing any set stored before, and that channel's next
713        rising edge loads it (see `SynthPalDevice.trigger_mode`). Otherwise
714        the device applies it at once. The settings sent are this object's:
715        `frequency`, and for each channel `waveform`, `peak_to_peak` or
716        `fixed_voltage`, `mean_voltage`, `resting_voltage`,
717        `play_duration`, `on_ramp_duration`, `off_ramp_duration`,
718        `link_trigger_channel1` and `link_trigger_channel2`, and
719        `trigger_mode`. They read as sent, including while a set waits for
720        its edge.
721
722        Raises:
723            PulsePalError: If the device rejects the set.
724        """
725        self._check_sync()
726        self._send_sync()

Send every setting to the device in one command.

While a trigger channel is in param sync mode, the device stores the set, replacing any set stored before, and that channel's next rising edge loads it (see SynthPalDevice.trigger_mode). Otherwise the device applies it at once. The settings sent are this object's: frequency, and for each channel waveform, peak_to_peak or fixed_voltage, mean_voltage, resting_voltage, play_duration, on_ramp_duration, off_ramp_duration, link_trigger_channel1 and link_trigger_channel2, and trigger_mode. They read as sent, including while a set waits for its edge.

Raises:
  • PulsePalError: If the device rejects the set.
def configure( self, channels, *, waveform=None, peak_to_peak=None, fixed_voltage=None, mean_voltage=None, resting_voltage=None, play_duration=None, on_ramp_duration=None, off_ramp_duration=None):
728    def configure(self, channels, *, waveform=None, peak_to_peak=None, fixed_voltage=None,
729                  mean_voltage=None, resting_voltage=None, play_duration=None,
730                  on_ramp_duration=None, off_ramp_duration=None):
731        """Set several of the output channels' settings at once.
732
733        Settings left out keep their values. The new settings are checked
734        together, so their order does not matter: for example, a channel
735        whose sine wave swings 2 V around a mean voltage of 9 V can go
736        straight to 20 V peak to peak around 0 V. With
737        `SynthPalDevice.auto_sync` on, the class programs the device in an
738        order it accepts at every step.
739
740        ```python
741        S.configure(1, waveform="Sine", peak_to_peak=20, mean_voltage=0)
742        S.configure([2, 3], waveform="Fixed Voltage", fixed_voltage=-2.5,
743                    play_duration=0.1)
744        ```
745
746        Args:
747            channels: Output channel number 1-4, or several as a list,
748                tuple or NumPy array.
749            waveform, peak_to_peak, fixed_voltage, mean_voltage,
750                resting_voltage, play_duration, on_ramp_duration,
751                off_ramp_duration: New values, as for the settings of the
752                same names: one value for every channel given, or a list
753                with one value per channel given, in the same order.
754
755        Raises:
756            PulsePalError: If a value is out of range, or the settings do
757                not go together (nothing is sent), or the device rejects
758                them.
759        """
760        channels = _common.channel_numbers(channels)
761        if len(set(channels)) != len(channels):
762            raise PulsePalError(f"configure(): channels must be distinct. Received {channels!r}.")
763        requested = {"waveform": waveform, "peak_to_peak": peak_to_peak, "fixed_voltage": fixed_voltage,
764                     "mean_voltage": mean_voltage, "resting_voltage": resting_voltage,
765                     "play_duration": play_duration, "on_ramp_duration": on_ramp_duration,
766                     "off_ramp_duration": off_ramp_duration}
767        new_values = {}
768        for name, value in requested.items():
769            if value is None:
770                continue
771            values = list(value) if isinstance(value, (list, tuple, np.ndarray)) else [value]
772            if len(values) == 1:
773                values = values * len(channels)
774            if len(values) != len(channels):
775                raise PulsePalError(
776                    f"configure(): {name} has {len(values)} values for {len(channels)} channels. Give one "
777                    "value, or one value per channel."
778                )
779            settings = list(getattr(self, name))[1:]
780            for channel, item in zip(channels, values):
781                settings[channel - 1] = item
782            new_values[name] = self._normalize(name, settings)
783        levels = {name: new_values.get(name, list(getattr(self, name))[1:])
784                  for name in ("waveform", "peak_to_peak", "fixed_voltage", "mean_voltage")}
785        self._check_levels(levels["peak_to_peak"], levels["mean_voltage"], "configure")
786        if self._auto_sync:
787            self._send_levels(*self._device_levels(**levels))
788        else:
789            self._unsent = self._unsent | set(new_values)  # Until sync_to_device() sends them (see auto_sync)
790        for name in levels:
791            if name in new_values:
792                getattr(self, name)._store(new_values[name])
793        ops = {"resting_voltage": self._OP_SET_RESTING_VOLTAGE, "play_duration": self._OP_SET_PLAY_DURATION,
794               "on_ramp_duration": self._OP_SET_ON_RAMP_DURATION,
795               "off_ramp_duration": self._OP_SET_OFF_RAMP_DURATION}
796        for name, op in ops.items():
797            if name in new_values:
798                if self._auto_sync:
799                    self._send_setting(name, op, new_values[name])
800                getattr(self, name)._store(new_values[name])

Set several of the output channels' settings at once.

Settings left out keep their values. The new settings are checked together, so their order does not matter: for example, a channel whose sine wave swings 2 V around a mean voltage of 9 V can go straight to 20 V peak to peak around 0 V. With SynthPalDevice.auto_sync on, the class programs the device in an order it accepts at every step.

S.configure(1, waveform="Sine", peak_to_peak=20, mean_voltage=0)
S.configure([2, 3], waveform="Fixed Voltage", fixed_voltage=-2.5,
            play_duration=0.1)
Arguments:
  • channels: Output channel number 1-4, or several as a list, tuple or NumPy array.
  • waveform, peak_to_peak, fixed_voltage, mean_voltage, resting_voltage, play_duration, on_ramp_duration, off_ramp_duration: New values, as for the settings of the same names: one value for every channel given, or a list with one value per channel given, in the same order.
Raises:
  • PulsePalError: If a value is out of range, or the settings do not go together (nothing is sent), or the device rejects them.
samples_per_cycle
802    @property
803    def samples_per_cycle(self):
804        """Samples in one cycle of the waveform, at `frequency`.
805
806        The largest multiple of 4 whose sampling rate is at most
807        `DeviceInfo.max_sampling_rate` (100 kHz), so that a sample falls
808        on every edge, peak and trough of every waveform. For example,
809        332 at 300 Hz and 4 at 20 kHz.
810        """
811        return self._samples_per_cycle

Samples in one cycle of the waveform, at frequency.

The largest multiple of 4 whose sampling rate is at most DeviceInfo.max_sampling_rate (100 kHz), so that a sample falls on every edge, peak and trough of every waveform. For example, 332 at 300 Hz and 4 at 20 kHz.

sampling_rate
813    @property
814    def sampling_rate(self):
815        """The rate at which the device plays samples, in Hz.
816
817        `samples_per_cycle` times `frequency`: 100 kHz or just below it,
818        and at least 50 kHz. For example, 100 kHz at 100 Hz, 99.6 kHz at
819        300 Hz, and 80 kHz at 20 kHz. The device's timer cannot divide
820        every rate exactly, so sample periods differ by up to one tick of
821        its 24 MHz clock (42 ns), arranged so that the frequency is exact.
822        """
823        return self._samples_per_cycle * self._frequency

The rate at which the device plays samples, in Hz.

samples_per_cycle times frequency: 100 kHz or just below it, and at least 50 kHz. For example, 100 kHz at 100 Hz, 99.6 kHz at 300 Hz, and 80 kHz at 20 kHz. The device's timer cannot divide every rate exactly, so sample periods differ by up to one tick of its 24 MHz clock (42 ns), arranged so that the frequency is exact.

waveform
825    @property
826    def waveform(self):
827        """The waveform of each output channel.
828
829        Indexed by channel number (see "Channel settings" above). Each
830        cycle starts at the trigger:
831
832        - `"Sine"` and `"Triangle"`: start at the mean voltage, rising.
833        - `"Square"`: high for the first half of each cycle, then low.
834        - `"Sawtooth"`: rises from its lowest voltage to its highest, then
835          falls back at the end of the cycle.
836        - `"Fixed Voltage"`: steps to the channel's
837          `SynthPalDevice.fixed_voltage` for the play duration, and
838          returns to the resting voltage. It is not periodic, so the
839          frequency does not change it.
840        - `"White Noise"`: a new random voltage on every sample, uniform
841          over the peak to peak voltage around the mean voltage. It is
842          rendered at the sampling rate the frequency sets
843          (`SynthPalDevice.sampling_rate`), so no claim is made about its
844          spectrum. Each channel has its own random sequence, which carries
845          on from one playback to the next: every playback is new noise.
846          The sequences start from the same point when the device powers
847          up.
848
849        The periodic waveforms and white noise swing
850        `SynthPalDevice.peak_to_peak` around `SynthPalDevice.mean_voltage`.
851        Names are not case sensitive. A change applies to playback in
852        progress.
853        """
854        return self._waveform

The waveform of each output channel.

Indexed by channel number (see "Channel settings" above). Each cycle starts at the trigger:

  • "Sine" and "Triangle": start at the mean voltage, rising.
  • "Square": high for the first half of each cycle, then low.
  • "Sawtooth": rises from its lowest voltage to its highest, then falls back at the end of the cycle.
  • "Fixed Voltage": steps to the channel's SynthPalDevice.fixed_voltage for the play duration, and returns to the resting voltage. It is not periodic, so the frequency does not change it.
  • "White Noise": a new random voltage on every sample, uniform over the peak to peak voltage around the mean voltage. It is rendered at the sampling rate the frequency sets (SynthPalDevice.sampling_rate), so no claim is made about its spectrum. Each channel has its own random sequence, which carries on from one playback to the next: every playback is new noise. The sequences start from the same point when the device powers up.

The periodic waveforms and white noise swing SynthPalDevice.peak_to_peak around SynthPalDevice.mean_voltage. Names are not case sensitive. A change applies to playback in progress.

peak_to_peak
860    @property
861    def peak_to_peak(self):
862        """The peak to peak voltage of each output channel's waveform, in volts.
863
864        Indexed by channel number (see "Channel settings" above). 0 to
865        20 V: the waveform swings `peak_to_peak / 2` above and below the
866        channel's `SynthPalDevice.mean_voltage`, so it must stay within
867        -10 V to 10 V: `abs(mean_voltage) + peak_to_peak / 2 <= 10`. To
868        raise it beyond what the mean voltage allows, change the mean
869        voltage first, or set both with `SynthPalDevice.configure`.
870
871        A sine wave of 4 V peak to peak swings from -2 V to 2 V around a
872        mean voltage of 0 V: it is 2 sin(2 pi f t). White noise of 4 V peak
873        to peak is uniform from -2 V to 2 V. A `"Fixed Voltage"` channel
874        keeps its peak to peak voltage for when it plays a periodic waveform
875        again.
876
877        Set to the nearest microvolt. A change applies to playback in
878        progress.
879        """
880        return self._peak_to_peak

The peak to peak voltage of each output channel's waveform, in volts.

Indexed by channel number (see "Channel settings" above). 0 to 20 V: the waveform swings peak_to_peak / 2 above and below the channel's SynthPalDevice.mean_voltage, so it must stay within -10 V to 10 V: abs(mean_voltage) + peak_to_peak / 2 <= 10. To raise it beyond what the mean voltage allows, change the mean voltage first, or set both with SynthPalDevice.configure.

A sine wave of 4 V peak to peak swings from -2 V to 2 V around a mean voltage of 0 V: it is 2 sin(2 pi f t). White noise of 4 V peak to peak is uniform from -2 V to 2 V. A "Fixed Voltage" channel keeps its peak to peak voltage for when it plays a periodic waveform again.

Set to the nearest microvolt. A change applies to playback in progress.

fixed_voltage
886    @property
887    def fixed_voltage(self):
888        """The voltage each output channel steps to when its waveform is
889        `"Fixed Voltage"`, in volts.
890
891        Indexed by channel number (see "Channel settings" above). -10 to
892        10 V, with any resting voltage and mean voltage. A channel playing
893        another waveform keeps its fixed voltage for when its waveform is
894        `"Fixed Voltage"` again.
895
896        Set to the nearest microvolt. A change applies to playback in
897        progress.
898        """
899        return self._fixed_voltage

The voltage each output channel steps to when its waveform is "Fixed Voltage", in volts.

Indexed by channel number (see "Channel settings" above). -10 to 10 V, with any resting voltage and mean voltage. A channel playing another waveform keeps its fixed voltage for when its waveform is "Fixed Voltage" again.

Set to the nearest microvolt. A change applies to playback in progress.

mean_voltage
905    @property
906    def mean_voltage(self):
907        """The mean voltage of each output channel's waveform, in volts.
908
909        Indexed by channel number (see "Channel settings" above). -10 to
910        10 V. A periodic waveform, or white noise, swings around it at full
911        amplitude, and must stay within -10 V to 10 V with the
912        `peak_to_peak` voltage: `abs(mean_voltage) + peak_to_peak / 2 <= 10`.
913        It may differ from the `resting_voltage`, which the channel
914        outputs between playbacks: the ramps move the mean between the two
915        (see "Ramps" above). A `"Fixed Voltage"` ignores it. Set to the
916        nearest microvolt. A change applies to playback in progress.
917        """
918        return self._mean_voltage

The mean voltage of each output channel's waveform, in volts.

Indexed by channel number (see "Channel settings" above). -10 to 10 V. A periodic waveform, or white noise, swings around it at full amplitude, and must stay within -10 V to 10 V with the peak_to_peak voltage: abs(mean_voltage) + peak_to_peak / 2 <= 10. It may differ from the resting_voltage, which the channel outputs between playbacks: the ramps move the mean between the two (see "Ramps" above). A "Fixed Voltage" ignores it. Set to the nearest microvolt. A change applies to playback in progress.

resting_voltage
924    @property
925    def resting_voltage(self):
926        """The resting voltage of each output channel, in volts.
927
928        Indexed by channel number (see "Channel settings" above). -10 to
929        10 V, with any waveform. The channel outputs it while idle, and
930        its ramps start and end there (see "Ramps" above). Set to the
931        nearest microvolt. A change applies to playback in progress.
932        """
933        return self._resting_voltage

The resting voltage of each output channel, in volts.

Indexed by channel number (see "Channel settings" above). -10 to 10 V, with any waveform. The channel outputs it while idle, and its ramps start and end there (see "Ramps" above). Set to the nearest microvolt. A change applies to playback in progress.

play_duration
939    @property
940    def play_duration(self):
941        """How long each output channel plays after a trigger, in seconds.
942
943        Indexed by channel number (see "Channel settings" above). Up to
944        `DeviceInfo.max_play_duration` (3600 s). `0` plays until the
945        channel is stopped. It counts at full amplitude, after the on
946        ramp. The channel stops when the duration has elapsed, part way
947        through a cycle if need be, and returns to its resting voltage,
948        over its off ramp. Durations are counted in samples, rounded to
949        the nearest one, and a nonzero duration lasts at least one sample.
950        A change applies to playback in progress.
951        """
952        return self._play_duration

How long each output channel plays after a trigger, in seconds.

Indexed by channel number (see "Channel settings" above). Up to DeviceInfo.max_play_duration (3600 s). 0 plays until the channel is stopped. It counts at full amplitude, after the on ramp. The channel stops when the duration has elapsed, part way through a cycle if need be, and returns to its resting voltage, over its off ramp. Durations are counted in samples, rounded to the nearest one, and a nonzero duration lasts at least one sample. A change applies to playback in progress.

on_ramp_duration
958    @property
959    def on_ramp_duration(self):
960        """How long each output channel fades in after a trigger, in seconds.
961
962        Indexed by channel number (see "Channel settings" above). Up to
963        `DeviceInfo.max_play_duration` (3600 s); `0` (the default) for no
964        ramp. Over it, the peak to peak voltage rises linearly from 0 V to
965        `peak_to_peak`, and the mean from the `resting_voltage` to the
966        `mean_voltage`, before the play duration (see "Ramps" above).
967        Counted in samples, as `play_duration` is. A change applies to
968        playback in progress: a ramp under way keeps the level it has
969        reached, and goes on at the new rate.
970        """
971        return self._on_ramp_duration

How long each output channel fades in after a trigger, in seconds.

Indexed by channel number (see "Channel settings" above). Up to DeviceInfo.max_play_duration (3600 s); 0 (the default) for no ramp. Over it, the peak to peak voltage rises linearly from 0 V to peak_to_peak, and the mean from the resting_voltage to the mean_voltage, before the play duration (see "Ramps" above). Counted in samples, as play_duration is. A change applies to playback in progress: a ramp under way keeps the level it has reached, and goes on at the new rate.

off_ramp_duration
977    @property
978    def off_ramp_duration(self):
979        """How long each output channel fades out when it stops, in seconds.
980
981        Indexed by channel number (see "Channel settings" above). Up to
982        `DeviceInfo.max_play_duration` (3600 s); `0` (the default) for no
983        ramp. Over it, the peak to peak voltage falls linearly to 0 V, and
984        the mean to the `resting_voltage`, after the play duration or a
985        stop (see "Ramps" above). Counted in samples, as `play_duration`
986        is. A change applies to playback in progress.
987        """
988        return self._off_ramp_duration

How long each output channel fades out when it stops, in seconds.

Indexed by channel number (see "Channel settings" above). Up to DeviceInfo.max_play_duration (3600 s); 0 (the default) for no ramp. Over it, the peak to peak voltage falls linearly to 0 V, and the mean to the resting_voltage, after the play duration or a stop (see "Ramps" above). Counted in samples, as play_duration is. A change applies to playback in progress.

trigger_mode
 994    @property
 995    def trigger_mode(self):
 996        """How each trigger channel acts on the output channels linked to it.
 997
 998        Indexed by trigger channel number, 1 or 2 (index 0 is unused).
 999        These are Pulse Pal's trigger modes:
1000
1001        - `"Normal"`: a rising edge starts the linked channels. Channels
1002          that are playing ignore it.
1003        - `"Toggle"`: a rising edge starts the linked channels, or stops
1004          those that are playing.
1005        - `"Gated"`: a rising edge starts the linked channels, and a
1006          falling edge stops them, unless the other trigger channel is
1007          also gated, linked to them, and still high. With a play
1008          duration of 0, a channel plays for exactly as long as the TTL is
1009          high.
1010        - `"Param Sync"`: a rising edge starts and stops nothing (the
1011          channel's links are ignored). It loads the settings most recently
1012          sent by `SynthPalDevice.sync_to_device` (or at the end of a
1013          `SynthPalDevice.batch` block), if any. This is how the next
1014          trial's settings are sent during the current trial and applied
1015          the instant it starts.
1016
1017        At a param sync edge, the frequency and both trigger modes change
1018        at once: they are shared by all channels. An output channel at its
1019        resting voltage takes its new settings at once. One that is playing
1020        (off ramp included) finishes on the settings it started with, at
1021        the new frequency, and takes the new ones the moment it reaches its
1022        resting voltage, so the next trigger plays them. A trigger mode
1023        sent by `SynthPalDevice.sync_to_device` applies from the next
1024        edge.
1025
1026        Only `SynthPalDevice.sync_to_device` is held back for the edge:
1027        with `SynthPalDevice.auto_sync` on, every assignment programs the
1028        device at once, also in param sync mode. So leaving param sync mode
1029        means assigning `trigger_mode` with `auto_sync` on. When no trigger
1030        channel is left in the mode, the device discards a stored set.
1031
1032        To start channels on the same edge, wire the TTL to the other
1033        trigger channel too: the settings load first, so the channels it
1034        starts play the new settings. Connecting, and `set_default_params`,
1035        take both trigger channels out of param sync mode.
1036
1037        Names are not case sensitive.
1038        """
1039        return self._trigger_mode

How each trigger channel acts on the output channels linked to it.

Indexed by trigger channel number, 1 or 2 (index 0 is unused). These are Pulse Pal's trigger modes:

  • "Normal": a rising edge starts the linked channels. Channels that are playing ignore it.
  • "Toggle": a rising edge starts the linked channels, or stops those that are playing.
  • "Gated": a rising edge starts the linked channels, and a falling edge stops them, unless the other trigger channel is also gated, linked to them, and still high. With a play duration of 0, a channel plays for exactly as long as the TTL is high.
  • "Param Sync": a rising edge starts and stops nothing (the channel's links are ignored). It loads the settings most recently sent by SynthPalDevice.sync_to_device (or at the end of a SynthPalDevice.batch block), if any. This is how the next trial's settings are sent during the current trial and applied the instant it starts.

At a param sync edge, the frequency and both trigger modes change at once: they are shared by all channels. An output channel at its resting voltage takes its new settings at once. One that is playing (off ramp included) finishes on the settings it started with, at the new frequency, and takes the new ones the moment it reaches its resting voltage, so the next trigger plays them. A trigger mode sent by SynthPalDevice.sync_to_device applies from the next edge.

Only SynthPalDevice.sync_to_device is held back for the edge: with SynthPalDevice.auto_sync on, every assignment programs the device at once, also in param sync mode. So leaving param sync mode means assigning trigger_mode with auto_sync on. When no trigger channel is left in the mode, the device discards a stored set.

To start channels on the same edge, wire the TTL to the other trigger channel too: the settings load first, so the channels it starts play the new settings. Connecting, and set_default_params, take both trigger channels out of param sync mode.

Names are not case sensitive.

def set_screen_saver(self, enabled, timeout=1800):
1071    def set_screen_saver(self, enabled, timeout=1800):
1072        """Switch the device's screen saver on or off, and set its timeout.
1073
1074        With the screen saver on, the device dims its screen once it has
1075        been left alone for `timeout` seconds: no command from a computer,
1076        no rising edge on a trigger channel, and no joystick click or
1077        push. The next of these brings the screen back. The device keeps
1078        both settings in its EEPROM, shared with Pulse Pal firmware, and
1079        saves them once no channel is playing. A new device starts with
1080        the screen saver on and 1800 s.
1081
1082        Args:
1083            enabled: `True` to switch the screen saver on, `False` to
1084                switch it off.
1085            timeout: Seconds without activity before the screen dims, 1 to
1086                65535.
1087
1088        Raises:
1089            PulsePalError: If a value is out of range.
1090        """
1091        state = to_bool(enabled, "enabled")
1092        if isinstance(timeout, (bool, np.bool_)) \
1093                or not isinstance(timeout, numbers.Integral) \
1094                or not 1 <= timeout <= 65535:
1095            raise PulsePalError(
1096                "timeout must be a whole number of seconds from 1 to 65535. "
1097                f"Received {timeout!r}."
1098            )
1099        self._write_command(self._OP_SET_SCREEN_SAVER,
1100                            struct.pack("<BH", int(state), int(timeout)))
1101        self._read_ack("set_screen_saver()")

Switch the device's screen saver on or off, and set its timeout.

With the screen saver on, the device dims its screen once it has been left alone for timeout seconds: no command from a computer, no rising edge on a trigger channel, and no joystick click or push. The next of these brings the screen back. The device keeps both settings in its EEPROM, shared with Pulse Pal firmware, and saves them once no channel is playing. A new device starts with the screen saver on and 1800 s.

Arguments:
  • enabled: True to switch the screen saver on, False to switch it off.
  • timeout: Seconds without activity before the screen dims, 1 to 65535.
Raises:
  • PulsePalError: If a value is out of range.
def trigger(self, channels):
1107    def trigger(self, channels):
1108        """Trigger output channels in software.
1109
1110        Channels that are idle start from the beginning of their waveform
1111        cycle, together. Channels that are playing ignore it, as they
1112        ignore a trigger channel in normal mode.
1113
1114        ```python
1115        S.trigger(1)
1116        S.trigger([2, 4])     # a list, tuple or NumPy array
1117        ```
1118
1119        Args:
1120            channels: Output channel number 1-4, or several as a list,
1121                tuple or NumPy array.
1122        """
1123        self._write_command(self._OP_TRIGGER, bytes([_common.channel_bits(channels)]))

Trigger output channels in software.

Channels that are idle start from the beginning of their waveform cycle, together. Channels that are playing ignore it, as they ignore a trigger channel in normal mode.

S.trigger(1)
S.trigger([2, 4])     # a list, tuple or NumPy array
Arguments:
  • channels: Output channel number 1-4, or several as a list, tuple or NumPy array.
def stop(self, channels=None):
1125    def stop(self, channels=None):
1126        """Stop playback. The stopped channels return to their resting
1127        voltage, over their off ramps.
1128
1129        Args:
1130            channels: Output channel number 1-4, or several as a list,
1131                tuple or NumPy array. `None` stops all channels.
1132        """
1133        bits = self._ALL_CHANNELS if channels is None else _common.channel_bits(channels)
1134        self._write_command(self._OP_STOP, bytes([bits]))

Stop playback. The stopped channels return to their resting voltage, over their off ramps.

Arguments:
  • channels: Output channel number 1-4, or several as a list, tuple or NumPy array. None stops all channels.
def status(self):
1136    def status(self):
1137        """Read the device's playback state.
1138
1139        Returns:
1140            A `DeviceStatus`, with the channels that are playing, the
1141            samples per cycle, each channel's output range, and timing
1142            figures.
1143        """
1144        self._write_command(self._OP_GET_STATUS)
1145        values = struct.unpack(
1146            self._STATUS_FORMAT,
1147            self._read_raw(struct.calcsize(self._STATUS_FORMAT)),
1148        )
1149        playing_bits = values[0]
1150        range_names = list(OUTPUT_RANGES)
1151        return DeviceStatus(
1152            playing=[ch for ch in range(1, 5)
1153                     if playing_bits & (1 << (ch - 1))],
1154            samples_per_cycle=values[1],
1155            output_ranges=[None, *(range_names[i] if i < len(range_names)
1156                                   else None for i in values[2:6])],
1157            longest_interrupt_us=values[6] / 1000,
1158            late_updates=values[7],
1159        )

Read the device's playback state.

Returns:

A DeviceStatus, with the channels that are playing, the samples per cycle, each channel's output range, and timing figures.

def close(self):
1177    def close(self):
1178        """Close the connection to the device.
1179
1180        The device stops playback on all channels, each over its off ramp,
1181        and shows its own name on its screen again, in place of "PYTHON
1182        Connected". It keeps its settings, so TTL triggers still play the
1183        channels. Safe to call more than once. Called automatically when
1184        leaving a `with` block and when the object is garbage collected,
1185        so playback also stops when the last reference to the object
1186        goes.
1187
1188        """
1189        self._close(send_disconnect=True)

Close the connection to the device.

The device stops playback on all channels, each over its off ramp, and shows its own name on its screen again, in place of "PYTHON Connected". It keeps its settings, so TTL triggers still play the channels. Safe to call more than once. Called automatically when leaving a with block and when the object is garbage collected, so playback also stops when the last reference to the object goes.

@dataclass
class DeviceInfo:
189@dataclass
190class DeviceInfo:
191    """Properties of the connected Synth Pal.
192
193    Populated when `SynthPalDevice` connects, and available as
194    `SynthPalDevice.info`.
195    """
196
197    firmware_version: int = None
198    """Synth Pal firmware version running on the device."""
199
200    hardware_version: int = None
201    """Pulse Pal hardware version, e.g. `3`."""
202
203    circuit_board_revision: int = None
204    """Revision of the device's circuit board, 0-31.
205
206    The board encodes its revision on five pins, which the firmware reads
207    at startup. A board that encodes none reports `0`. To record exactly
208    which hardware ran an experiment, copy `hardware_version` and
209    `circuit_board_revision` to your data.
210    """
211
212    serial_number: str = None
213    """USB serial number of the device, e.g. `"20345420"`: the Teensy's,
214    unique to each Pulse Pal 3, and the same whichever firmware it runs.
215
216    Connect by it, wherever the device is plugged in, with
217    `SynthPalDevice(serial_number="20345420")`. To record exactly which unit ran an
218    experiment, copy it to your data.
219    """
220
221    n_channels: int = None
222    """Number of output channels."""
223
224    min_frequency: float = None
225    """Lowest frequency, in Hz."""
226
227    max_frequency: float = None
228    """Highest frequency, in Hz."""
229
230    max_sampling_rate: int = None
231    """Highest sampling rate, in Hz. See `SynthPalDevice.sampling_rate`."""
232
233    timer_clock_hz: int = None
234    """Clock that the sample clock counts, in Hz."""
235
236    max_play_duration: float = None
237    """Longest play duration, in seconds. See
238    `SynthPalDevice.play_duration`."""
239
240    waveforms: tuple = WAVEFORMS
241    """Names of the waveforms, accepted by `SynthPalDevice.waveform`."""
242
243    trigger_modes: tuple = TRIGGER_MODES
244    """Names of the trigger modes, accepted by
245    `SynthPalDevice.trigger_mode`."""

Properties of the connected Synth Pal.

Populated when SynthPalDevice connects, and available as SynthPalDevice.info.

DeviceInfo( firmware_version: int = None, hardware_version: int = None, circuit_board_revision: int = None, serial_number: str = None, n_channels: int = None, min_frequency: float = None, max_frequency: float = None, max_sampling_rate: int = None, timer_clock_hz: int = None, max_play_duration: float = None, waveforms: tuple = ('Sine', 'Triangle', 'Square', 'Sawtooth', 'Fixed Voltage', 'White Noise'), trigger_modes: tuple = ('Normal', 'Toggle', 'Gated', 'Param Sync'))
firmware_version: int = None

Synth Pal firmware version running on the device.

hardware_version: int = None

Pulse Pal hardware version, e.g. 3.

circuit_board_revision: int = None

Revision of the device's circuit board, 0-31.

The board encodes its revision on five pins, which the firmware reads at startup. A board that encodes none reports 0. To record exactly which hardware ran an experiment, copy hardware_version and circuit_board_revision to your data.

serial_number: str = None

USB serial number of the device, e.g. "20345420": the Teensy's, unique to each Pulse Pal 3, and the same whichever firmware it runs.

Connect by it, wherever the device is plugged in, with SynthPalDevice(serial_number="20345420"). To record exactly which unit ran an experiment, copy it to your data.

n_channels: int = None

Number of output channels.

min_frequency: float = None

Lowest frequency, in Hz.

max_frequency: float = None

Highest frequency, in Hz.

max_sampling_rate: int = None

Highest sampling rate, in Hz. See SynthPalDevice.sampling_rate.

timer_clock_hz: int = None

Clock that the sample clock counts, in Hz.

max_play_duration: float = None

Longest play duration, in seconds. See SynthPalDevice.play_duration.

waveforms: tuple = ('Sine', 'Triangle', 'Square', 'Sawtooth', 'Fixed Voltage', 'White Noise')

Names of the waveforms, accepted by SynthPalDevice.waveform.

trigger_modes: tuple = ('Normal', 'Toggle', 'Gated', 'Param Sync')

Names of the trigger modes, accepted by SynthPalDevice.trigger_mode.

@dataclass
class DeviceStatus:
248@dataclass
249class DeviceStatus:
250    """A snapshot of the device's playback state, from
251    `SynthPalDevice.status`."""
252
253    playing: list
254    """Numbers of the output channels that are playing, e.g. `[1, 3]`.
255
256    A channel in its off ramp counts as playing until it reaches its
257    resting voltage."""
258
259    samples_per_cycle: int
260    """Samples in one cycle of the waveform. See
261    `SynthPalDevice.samples_per_cycle`."""
262
263    output_ranges: list
264    """The output range the device chose for each channel, indexed by
265    channel number, with index 0 unused, e.g. `"-5V:5V"`.
266
267    Each channel uses the range with the finest voltage steps that holds
268    its whole waveform and its resting voltage: `"0V:5V"` (76 uV steps),
269    then `"0V:10V"` or `"-5V:5V"` (153 uV), then `"-10V:10V"` (305 uV).
270    """
271
272    longest_interrupt_us: float
273    """Longest run of the device's sample clock interrupt since the
274    previous call to `SynthPalDevice.status`, in microseconds.
275
276    It must stay below the sample period, `1e6 / sampling_rate`.
277    """
278
279    late_updates: int
280    """Output updates since the previous call to `SynthPalDevice.status`
281    that may have come later than their fixed time after a sample clock
282    tick. It is normally 0; a change of frequency during playback can
283    make one update late. See "Timing" in the
284    [Synth Pal protocol](https://github.com/sanworks/PulsePal/blob/develop/Firmware/SynthPal/PROTOCOL.md#timing).
285    """

A snapshot of the device's playback state, from SynthPalDevice.status.

DeviceStatus( playing: list, samples_per_cycle: int, output_ranges: list, longest_interrupt_us: float, late_updates: int)
playing: list

Numbers of the output channels that are playing, e.g. [1, 3].

A channel in its off ramp counts as playing until it reaches its resting voltage.

samples_per_cycle: int

Samples in one cycle of the waveform. See SynthPalDevice.samples_per_cycle.

output_ranges: list

The output range the device chose for each channel, indexed by channel number, with index 0 unused, e.g. "-5V:5V".

Each channel uses the range with the finest voltage steps that holds its whole waveform and its resting voltage: "0V:5V" (76 uV steps), then "0V:10V" or "-5V:5V" (153 uV), then "-10V:10V" (305 uV).

longest_interrupt_us: float

Longest run of the device's sample clock interrupt since the previous call to SynthPalDevice.status, in microseconds.

It must stay below the sample period, 1e6 / sampling_rate.

late_updates: int

Output updates since the previous call to SynthPalDevice.status that may have come later than their fixed time after a sample clock tick. It is normally 0; a change of frequency during playback can make one update late. See "Timing" in the Synth Pal protocol.

WAVEFORMS = ('Sine', 'Triangle', 'Square', 'Sawtooth', 'Fixed Voltage', 'White Noise')

Names of the waveforms, in order of their code on the device.

TRIGGER_MODES = ('Normal', 'Toggle', 'Gated', 'Param Sync')

Names of the trigger modes, in order of their code on the device. These are Pulse Pal's trigger modes, with the same codes.