diff --git a/README.md b/README.md index 7e3a80c..4568082 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Refer to the [wiki](https://github.com/diglet48/restim/wiki) for help. * Stereostim (three-phase only) and other audio-based devices (Mk312, 2B, ...) * FOC-Stim * NeoDK (coming soon) +* Coyote 3.0 (coming soon, experimental) ## Main features diff --git a/designer/device_wizard/coyote_waveform_select.ui b/designer/device_wizard/coyote_waveform_select.ui new file mode 100644 index 0000000..aa61a18 --- /dev/null +++ b/designer/device_wizard/coyote_waveform_select.ui @@ -0,0 +1,54 @@ + + + WizardPageCoyote + + + + 0 + 0 + 611 + 497 + + + + WizardPage + + + + + + Three-phase + + + + + + + <html><head/><body> + <p>A = left<br/>B = right<br/>C = neutral</p> + <p>Connect A- and B- to a shared common electrode (e.g. a conductive rubber loop).</p> + </body></html> + + + true + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + diff --git a/designer/device_wizard/type_select.ui b/designer/device_wizard/type_select.ui index db8a6e3..1e692d0 100644 --- a/designer/device_wizard/type_select.ui +++ b/designer/device_wizard/type_select.ui @@ -34,7 +34,21 @@ + + + + NeoStim + + + + + + Coyote 3 + + + + Qt::Orientation::Vertical @@ -47,15 +61,8 @@ - - - - NeoStim - - - - + \ No newline at end of file diff --git a/designer/mainwindow.ui b/designer/mainwindow.ui index 7a2cdff..9103160 100644 --- a/designer/mainwindow.ui +++ b/designer/mainwindow.ui @@ -254,6 +254,11 @@ Carrier settings + + + Coyote + + Pulse settings @@ -486,6 +491,12 @@
qt_ui/three_phase_settings_widget.h
1 + + CoyoteSettingsWidget + QWidget +
qt_ui/coyote_settings_widget.h
+ 1 +
PulseSettingsWidget QWidget diff --git a/designer/preferencesdialog.ui b/designer/preferencesdialog.ui index 64b6d88..6aa95b8 100644 --- a/designer/preferencesdialog.ui +++ b/designer/preferencesdialog.ui @@ -612,6 +612,208 @@
+ + + Coyote + + + + + + Device + + + + + + Channel A Limit + + + + + + + 0 + + + 200 + + + + + + + Channel B Limit + + + + + + + 0 + + + 200 + + + + + + + Channel A Freq Balance + + + + + + + 0 + + + 255 + + + + + + + Channel B Freq Balance + + + + + + + 0 + + + 255 + + + + + + + Channel A Intensity Balance + + + + + + + 0 + + + 255 + + + + + + + Channel B Intensity Balance + + + + + + + 0 + + + 255 + + + + + + + + + + Algorithm + + + + + + Max Intensity Change per Pulse (%) + + + + + + + 1 + + + 0.0 + + + 100.0 + + + 0.1 + + + 3.0 + + + + + + + + + + Display + + + + + + Graph Window (s) + + + + + + + 1 + + + 0.1 + + + 10.0 + + + 0.1 + + + 3.0 + + + + + + + Debug Logging + + + + + + + + + + + + + + + Media sync diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py new file mode 100644 index 0000000..e1bf088 --- /dev/null +++ b/device/coyote/algorithm.py @@ -0,0 +1,282 @@ +""" +DG-LAB Coyote 3.0 E-Stim Algorithm Implementation + +EXPERIMENTAL: Best-effort adaptation of restim's funscript-based algorithms to the Coyote 3.0's +hardware square pulse generator. This implementation attempts to simulate smooth parameter changes +and modulation on hardware that fundamentally outputs discrete square pulses. + +Protocol Specification (Chinese): +https://github.com/DG-LAB-OPENSOURCE/DG-LAB-OPENSOURCE/blob/main/coyote/v3/README_V3.md + +Hardware Specifications & Limitations: +-------------------------------------- +- Two independent channels (A and B) +- Square pulse generator only (no smooth waveforms like continuous audio devices) +- Pulse parameters: + * Intensity: 0-100% + * Duration: 5-240ms (sent as "waveform frequency" parameter in protocol, despite the name) + - Spec documents 10-240ms range, but hardware appears to support down to 5ms + - Spec also provides an optional extended mapping from input values 10-1000 → output 10-240 + * Relationship: frequency_hz = 1000 / duration_ms (simple inverse) + * Effective frequency range: ~4.17Hz (240ms) to 200Hz (5ms) + * This algorithm works in Hz internally, then converts to duration_ms when sending to device +- Protocol: B0 command contains 4 pulses per packet (20 bytes total) +- Spec recommends ~100ms update rate, but this implementation uses adaptive scheduling + (sends next packet at 80% of current packet duration for seamless transitions) +- Device repeats last packet until new one arrives +- Invalid waveform data causes device to discard entire 4-pulse packet for that channel +- Channel strength range: 0-200 (separate from pulse intensity 0-100%) +- Balance parameters (BF command): frequency balance and intensity balance affect perceived output + +Key Limitations: +- Cannot produce smooth continuous waveforms - only discrete square pulses +- Limited frequency range compared to audio-based devices +- Packet-based protocol requires continuous streaming (no gaps or device repeats last packet) +- No native envelope/modulation support - must be simulated via parameter variations +- Hardware balance parameters (frequency/intensity) affect output independently +- Each pulse duration is quantized to integer milliseconds + +Algorithm Overview (Best-Effort Approach): +------------------------------------------ +- Maps funscript pulse_frequency to channel-specific frequency ranges +- Applies optional jitter (pulse_interval_random) to pulse timing +- Adds zero-mean micro-texture via pulse_width modulation to simulate smoothness +- Smooths intensity transitions based on pulse_rise_time +- Maintains pulse queues (750ms horizon) for continuous output +- Uses barycentric mapping for three-phase position diagram intensity control +- Adaptive packet scheduling (80% of packet duration) for seamless output + +Each channel maintains an independent pulse queue. The algorithm attempts to create perceptually +smooth sensations by varying pulse duration and intensity, but results will differ significantly +from audio-based continuous algorithms due to fundamental hardware limitations. +""" + +from __future__ import annotations + +import logging +import time +from dataclasses import dataclass +from typing import Dict, List, Optional, Tuple + +from device.coyote.channel_controller import ChannelController +from device.coyote.channel_state import ChannelState +from device.coyote.common import normalize, split_seconds, volume_at +from device.coyote.config import PulseTuning, load_pulse_tuning +from device.coyote.constants import PULSES_PER_PACKET +from device.coyote.pulse_generator import PulseGenerator +from device.coyote.types import CoyotePulse, CoyotePulses +from stim_math.axis import AbstractMediaSync +from stim_math.audio_gen.params import CoyoteAlgorithmParams, SafetyParams +from stim_math.audio_gen.various import ThreePhasePosition +from stim_math.threephase import ThreePhaseCenterCalibration + +logger = logging.getLogger("restim.coyote") + + +@dataclass +class ChannelPipeline: + name: str + generator: PulseGenerator + controller: ChannelController + state: ChannelState + + +class CoyoteAlgorithm: + def __init__( + self, + media: AbstractMediaSync, + params: CoyoteAlgorithmParams, + safety_limits: SafetyParams, + carrier_freq_limits: Tuple[float, float], + pulse_freq_limits: Tuple[float, float], + pulse_width_limits: Tuple[float, float], + pulse_rise_time_limits: Tuple[float, float], + tuning: Optional[PulseTuning] = None, + ) -> None: + self.media = media + self.params = params + self.safety_limits = safety_limits + self._carrier_limits = carrier_freq_limits + self._pulse_rise_time_limits = pulse_rise_time_limits # retained for API compatibility + self.tuning = tuning or load_pulse_tuning() + + self.position = ThreePhasePosition(params.position, params.transform) + + channels: List[ChannelPipeline] = [] + for name, channel_params in (("A", params.channel_a), ("B", params.channel_b)): + generator = PulseGenerator(name, params, channel_params, carrier_freq_limits, pulse_freq_limits, pulse_width_limits, self.tuning) + controller = ChannelController(name, media, params, generator, self._positional_intensity, self.tuning) + state = ChannelState() + channels.append(ChannelPipeline(name, generator, controller, state)) + self._channels: Tuple[ChannelPipeline, ...] = tuple(channels) + + self._last_update_time: Optional[float] = None + self.next_update_time: float = 0.0 + self._start_time: Optional[float] = None + + def generate_packet(self, current_time: float) -> Optional[CoyotePulses]: + if self._last_update_time is None: + self._last_update_time = current_time + + delta_ms = max(0.0, (current_time - self._last_update_time) * 1000.0) + self._last_update_time = current_time + + self._advance_state(current_time, delta_ms) + + if not self._needs_packet(): + self._schedule_from_remaining(current_time) + return None + + for channel in self._channels: + channel.controller.fill_queue(current_time) + + packet_map: Dict[str, List[CoyotePulse]] = {} + duration_map: Dict[str, int] = {} + for channel in self._channels: + pulses = channel.controller.next_packet() + channel.state.load_packet(current_time, pulses) + packet_map[channel.name] = pulses + duration_map[channel.name] = sum(p.duration for p in pulses) + + pulses_a = packet_map.get("A", []) + pulses_b = packet_map.get("B", []) + duration_a_ms = duration_map.get("A", 0) + duration_b_ms = duration_map.get("B", 0) + + durations = [duration for duration in duration_map.values() if duration > 0] + if not durations: + durations = [1] + min_duration_ms = max(1, min(durations)) + self.next_update_time = current_time + (min_duration_ms / 1000.0) * self.tuning.packet_margin + + self._log_packet(current_time, pulses_a, pulses_b, duration_a_ms, duration_b_ms) + + return CoyotePulses(pulses_a, pulses_b) + + def get_next_update_time(self) -> float: + return self.next_update_time + + def _needs_packet(self) -> bool: + queue_low = any(not channel.controller.has_pulses(PULSES_PER_PACKET) for channel in self._channels) + ready = any(channel.state.ready() for channel in self._channels) + return ready or queue_low + + def _advance_state(self, current_time: float, delta_ms: float) -> None: + for channel in self._channels: + channel.state.advance(delta_ms) + + delta_s = delta_ms / 1000.0 + if delta_s <= 0: + return + + carrier_hz = float(self.params.carrier_frequency.interpolate(current_time)) + carrier_norm = normalize(carrier_hz, self._carrier_limits) + texture_speed = self.tuning.texture_min_hz + (self.tuning.texture_max_hz - self.tuning.texture_min_hz) * carrier_norm + + for channel in self._channels: + channel.generator.advance_phase(texture_speed, delta_s) + + def _schedule_from_remaining(self, current_time: float) -> None: + remaining = min(channel.state.remaining_ms() for channel in self._channels) + self.next_update_time = current_time + (remaining / 1000.0) * self.tuning.packet_margin + + def _positional_intensity(self, time_s: float, volume: float) -> Tuple[int, int]: + alpha, beta = self.position.get_position(time_s) + + w_left = max(0.0, (beta + 1.0) / 2.0) + w_right = max(0.0, (1.0 - beta) / 2.0) + w_neutral = max(0.0, alpha) + + total = w_left + w_right + w_neutral + if total > 0: + w_left /= total + w_right /= total + w_neutral /= total + else: + w_left = w_right = w_neutral = 0.0 + + center_db = float(self.params.calibrate.center.last_value()) + scale = ThreePhaseCenterCalibration(center_db).get_scale(alpha, beta) + + intensity_a = int((w_left + w_neutral) * volume * scale * 100.0) + intensity_b = int((w_right + w_neutral) * volume * scale * 100.0) + return intensity_a, intensity_b + + def _log_packet( + self, + current_time: float, + pulses_a: List[CoyotePulse], + pulses_b: List[CoyotePulse], + duration_a_ms: int, + duration_b_ms: int, + ) -> None: + if not logger.isEnabledFor(logging.DEBUG): + return + + alpha, beta = self.position.get_position(current_time) + comps = self._display_time_components(current_time) + media_type = self._media_type() + volume = volume_at(self.media, self.params.volume, current_time) + + lines = [ + "=" * 72, + f"Packet Generated @ {comps[0]:02}:{comps[1]:02}:{comps[2]:02}.{comps[3]:03} [{media_type}]", + "=" * 72, + f"Position: alpha={alpha:+.2f}, beta={beta:+.2f}, volume={volume:.0%}", + "", + f"Channel A: duration={duration_a_ms:.0f} ms", + ] + + for idx, pulse in enumerate(pulses_a, 1): + lines.append(f" Pulse {idx}: {pulse.duration} ms @ {pulse.frequency} Hz ({pulse.intensity}%)") + + lines.extend(["", f"Channel B: duration={duration_b_ms:.0f} ms"]) + for idx, pulse in enumerate(pulses_b, 1): + lines.append(f" Pulse {idx}: {pulse.duration} ms @ {pulse.frequency} Hz ({pulse.intensity}%)") + + next_ms = max(0.0, (self.next_update_time - current_time) * 1000.0) + lines.extend( + [ + "", + f"Next update: {next_ms:.0f} ms " + f"(packet_dur_a={duration_a_ms:.0f} ms, packet_dur_b={duration_b_ms:.0f} ms, margin={self.tuning.packet_margin:.0%})", + "=" * 72, + "", + ] + ) + + logger.debug("\n".join(lines)) + + def _media_type(self) -> str: + media_type = getattr(self.media, "media_type", None) + if media_type: + return str(media_type) + class_name = self.media.__class__.__name__.lower() + if class_name.startswith("internal"): + return "internal" + if "vlc" in class_name: + return "vlc" + if "mpv" in class_name: + return "mpv" + return class_name + + def _display_time_components(self, current_time: float): + media_type = getattr(self.media, "media_type", None) + if media_type and str(media_type).lower() != "internal": + mapper = getattr(self.media, "map_timestamp", None) + if callable(mapper) and self.media.is_playing(): + try: + rel_time = mapper(time.time()) + if rel_time is not None and rel_time >= 0: + return split_seconds(rel_time) + except Exception: # pragma: no cover - defensive + pass + + if media_type and str(media_type).lower() == "internal": + now = time.localtime() + millis = int((time.time() - int(time.time())) * 1000) + return now.tm_hour, now.tm_min, now.tm_sec, millis + + if self._start_time is None: + self._start_time = current_time + return split_seconds(current_time - self._start_time) diff --git a/device/coyote/channel_controller.py b/device/coyote/channel_controller.py new file mode 100644 index 0000000..94da5b6 --- /dev/null +++ b/device/coyote/channel_controller.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import logging +from collections import deque +from typing import Callable, Deque, List, Tuple + +from device.coyote.common import clamp, volume_at +from device.coyote.config import PulseTuning +from device.coyote.constants import MIN_PULSE_DURATION_MS, PULSES_PER_PACKET +from device.coyote.pulse_generator import PulseDebug, PulseGenerator +from device.coyote.types import CoyotePulse +from stim_math.axis import AbstractMediaSync +from stim_math.audio_gen.params import CoyoteAlgorithmParams + +logger = logging.getLogger("restim.coyote") + + +class ChannelController: + """Maintains a rolling queue of pulses for a single hardware channel.""" + + def __init__( + self, + name: str, + media: AbstractMediaSync, + params: CoyoteAlgorithmParams, + generator: PulseGenerator, + positional_intensity_fn: Callable[[float, float], Tuple[int, int]], + tuning: PulseTuning, + ) -> None: + self._name = name + self._media = media + self._params = params + self._generator = generator + self._positional_intensity_fn = positional_intensity_fn + self._tuning = tuning + + self._queue: Deque[CoyotePulse] = deque() + self._queued_ms = 0.0 + + self._last_intensity: float | None = None + self._last_time: float | None = None + self._max_change_per_pulse = float(self._params.max_intensity_change_per_pulse.get()) + + def has_pulses(self, count: int) -> bool: + return len(self._queue) >= count + + def queue_duration_ms(self) -> float: + return self._queued_ms + + def fill_queue(self, now_s: float) -> None: + horizon_end = now_s + self._tuning.queue_horizon_s + coverage_s = self._queued_ms / 1000.0 + end_time = now_s + coverage_s + + added: List[CoyotePulse] = [] + seq_index = 0 + while end_time < horizon_end or len(self._queue) < PULSES_PER_PACKET: + pulse_time = end_time + pulse, debug = self._generate_pulse(pulse_time, seq_index) + self._queue.append(pulse) + added.append(pulse) + self._queued_ms += pulse.duration + end_time += pulse.duration / 1000.0 + seq_index += 1 + + if added and logger.isEnabledFor(logging.DEBUG): + durations = [p.duration for p in added] + freqs = [p.frequency for p in added] + logger.debug( + "[%s] queued %d pulses (dur %d-%d ms, freq %d-%d Hz)", + self._name, + len(added), + min(durations), + max(durations), + min(freqs), + max(freqs), + ) + + def next_packet(self) -> List[CoyotePulse]: + packet: List[CoyotePulse] = [] + while len(packet) < PULSES_PER_PACKET: + if self._queue: + pulse = self._queue.popleft() + self._queued_ms = max(0.0, self._queued_ms - pulse.duration) + packet.append(pulse) + else: + pulse = CoyotePulse(frequency=0, intensity=0, duration=MIN_PULSE_DURATION_MS) + packet.append(pulse) + return packet + + def _generate_pulse(self, time_s: float, seq_index: int) -> Tuple[CoyotePulse, PulseDebug]: + volume = volume_at(self._media, self._params.volume, time_s) + intensity_a, intensity_b = self._positional_intensity_fn(time_s, volume) + target = float(intensity_a if self._name == "A" else intensity_b) + smoothed = self._smooth_intensity(target, time_s) + pulse, debug = self._generator.create_pulse(time_s, int(round(smoothed)), seq_index) + + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + ( + " [%s] pulse #%d: freq_raw=%.2f Hz, freq_norm=%.2f, " + "freq_mapped=%.2f Hz, freq_limits=(%.1f-%.1f) Hz | " + "base_dur=%.2f ms, dur_limits=(%d-%d) ms, width_norm=%.2f, jitter=%.0f%% | " + "texture_mode=%s, texture_up=%.2f ms, texture_dn=%.2f ms, " + "texture_used=%.2f ms | desired=%.2f ms, residual=%.2f ms | " + "result: dur=%d ms, freq=%d Hz, intensity=%d%%" + ), + self._name, + debug.sequence_index, + debug.raw_frequency_hz, + debug.normalised_frequency, + debug.mapped_frequency_hz, + debug.frequency_limits[0], + debug.frequency_limits[1], + debug.base_duration_ms, + debug.duration_limits[0], + debug.duration_limits[1], + debug.width_normalised, + debug.jitter_fraction * 100.0, + debug.texture_mode, + debug.texture_headroom_up_ms, + debug.texture_headroom_down_ms, + debug.texture_applied_ms, + debug.desired_duration_ms, + debug.residual_ms, + pulse.duration, + pulse.frequency, + pulse.intensity, + ) + + return pulse, debug + + def _smooth_intensity(self, target: float, time_s: float) -> float: + carrier_hz = float(self._params.carrier_frequency.interpolate(time_s)) + rise_cycles = float(self._params.pulse_rise_time.interpolate(time_s)) + tau_s = rise_cycles / carrier_hz if carrier_hz > 0 else 0.0 + + last_value = self._last_intensity + last_time = self._last_time + + if last_value is None or tau_s <= 0 or last_time is None: + result = target + else: + dt = max(0.0, time_s - last_time) + allowed = (dt / tau_s) * 100.0 if tau_s > 0 else float("inf") + if self._max_change_per_pulse > 0: + allowed = min(allowed, self._max_change_per_pulse) + delta = clamp(target - last_value, -allowed, allowed) + result = last_value + delta + + self._last_intensity = result + self._last_time = time_s + return result diff --git a/device/coyote/channel_state.py b/device/coyote/channel_state.py new file mode 100644 index 0000000..3bb8529 --- /dev/null +++ b/device/coyote/channel_state.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from collections import deque +from typing import Deque, Iterable, List + +from device.coyote.types import CoyotePulse + + +class ChannelState: + """Tracks playback progress for the last packet issued to a channel.""" + + def __init__(self) -> None: + self._current_packet: Deque[CoyotePulse] = deque() + self._elapsed_ms = 0.0 + self._total_ms = 0.0 + self._start_time_s = 0.0 + self._finish_time_s = 0.0 + + def load_packet(self, start_time_s: float, packet: Iterable[CoyotePulse]) -> None: + pulses = list(packet) + self._current_packet = deque(pulses) + self._total_ms = float(sum(p.duration for p in pulses)) + self._elapsed_ms = 0.0 + self._start_time_s = start_time_s + self._finish_time_s = start_time_s + (self._total_ms / 1000.0 if self._total_ms else 0.0) + + def advance(self, delta_ms: float) -> None: + if delta_ms <= 0: + return + self._elapsed_ms += delta_ms + + def remaining_ms(self) -> float: + if self._total_ms == 0: + return 0.0 + return max(0.0, self._total_ms - self._elapsed_ms) + + def ready(self) -> bool: + return self.remaining_ms() <= 0.0 + + @property + def finish_time_s(self) -> float: + return self._finish_time_s + + @property + def current_packet(self) -> List[CoyotePulse]: + return list(self._current_packet) diff --git a/device/coyote/common.py b/device/coyote/common.py new file mode 100644 index 0000000..80a2b7a --- /dev/null +++ b/device/coyote/common.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import Tuple + +from stim_math.audio_gen.params import VolumeParams +from stim_math.axis import AbstractMediaSync + + +def clamp(value: float, lower: float, upper: float) -> float: + if lower > upper: + lower, upper = upper, lower + return max(lower, min(value, upper)) + + +def normalize(value: float, bounds: Tuple[float, float]) -> float: + low, high = bounds + if high <= low: + return 0.0 + return clamp((value - low) / (high - low), 0.0, 1.0) + + +def volume_at(media: AbstractMediaSync, volume: VolumeParams, time_s: float) -> float: + if not media.is_playing(): + return 0.0 + + master = clamp(float(volume.master.last_value()), 0.0, 1.0) + api = clamp(float(volume.api.interpolate(time_s)), 0.0, 1.0) + inactivity = clamp(float(volume.inactivity.last_value()), 0.0, 1.0) + external = clamp(float(volume.external.last_value()), 0.0, 1.0) + + if inactivity == 0: + inactivity = 1.0 + + return master * api * inactivity * external + + +def split_seconds(seconds: float) -> Tuple[int, int, int, int]: + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + millis = int((seconds - int(seconds)) * 1000) + return hours, minutes, secs, millis diff --git a/device/coyote/config.py b/device/coyote/config.py new file mode 100644 index 0000000..25498db --- /dev/null +++ b/device/coyote/config.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from qt_ui import settings + + +def _clamp(value: float, lower: float, upper: float) -> float: + return max(lower, min(value, upper)) + + +@dataclass(frozen=True) +class PulseTuning: + queue_horizon_s: float + packet_margin: float + texture_min_hz: float + texture_max_hz: float + texture_depth_fraction: float + jitter_limit_fraction: float + residual_bound: float + + @classmethod + def from_settings(cls) -> "PulseTuning": + queue_horizon = max(0.1, float(settings.coyote_queue_horizon_seconds.get())) + margin = _clamp(float(settings.coyote_packet_margin.get()), 0.1, 1.0) + + texture_min = max(0.0, float(settings.coyote_texture_min_hz.get())) + texture_max = max(texture_min + 1e-6, float(settings.coyote_texture_max_hz.get())) + + depth = _clamp(float(settings.coyote_texture_depth_fraction.get()), 0.0, 1.0) + jitter_limit = _clamp(float(settings.coyote_jitter_limit_fraction.get()), 0.0, 1.0) + residual = max(0.0, float(settings.coyote_residual_bound.get())) + + return cls( + queue_horizon_s=queue_horizon, + packet_margin=margin, + texture_min_hz=texture_min, + texture_max_hz=texture_max, + texture_depth_fraction=depth, + jitter_limit_fraction=jitter_limit, + residual_bound=residual, + ) + + +def load_pulse_tuning() -> PulseTuning: + """Helper for consumers that do not need to customise tuning.""" + return PulseTuning.from_settings() diff --git a/device/coyote/constants.py b/device/coyote/constants.py new file mode 100644 index 0000000..51c7862 --- /dev/null +++ b/device/coyote/constants.py @@ -0,0 +1,35 @@ +# BLE device identification +DEVICE_NAME = "47L121000" + +# Coyote hardware timing and derived limits +# Pulse duration limits in milliseconds +MIN_PULSE_DURATION_MS = 5 +MAX_PULSE_DURATION_MS = 240 + +# Derived hardware frequency limits (Hz) +HARDWARE_MAX_FREQ_HZ = 1000.0 / MIN_PULSE_DURATION_MS # ~200 Hz +HARDWARE_MIN_FREQ_HZ = 1000.0 / MAX_PULSE_DURATION_MS # ~4.17 Hz + +# Packet behaviour +PULSES_PER_PACKET = 4 + +# BLE / Protocol constants +LOG_PREFIX = "[Coyote]" +BATTERY_SERVICE_UUID = "0000180A-0000-1000-8000-00805f9b34fb" +MAIN_SERVICE_UUID = "0000180C-0000-1000-8000-00805f9b34fb" +WRITE_CHAR_UUID = "0000150A-0000-1000-8000-00805f9b34fb" +NOTIFY_CHAR_UUID = "0000150B-0000-1000-8000-00805f9b34fb" +BATTERY_CHAR_UUID = "00001500-0000-1000-8000-00805f9b34fb" + +CMD_B0 = 0xB0 +CMD_POWER_UPDATE = 0xB1 +CMD_ACK = 0x51 +CMD_ACTIVE_POWER = 0x53 + +INTERP_ABSOLUTE_SET = 0b11 +INTERP_NO_CHANGE = 0b00 +SEQUENCE_MODULO = 16 # 4-bit sequence number wraps at 16 +B0_NO_PULSES_PAD_BYTES = 16 + +# Connection behavior +SCAN_RETRY_SECONDS = 5 diff --git a/device/coyote/device.py b/device/coyote/device.py new file mode 100644 index 0000000..5820a74 --- /dev/null +++ b/device/coyote/device.py @@ -0,0 +1,441 @@ +import asyncio +import logging +from typing import Optional +import time +import threading + +from bleak import BleakClient, BleakScanner +from device.output_device import OutputDevice + +from PySide6.QtCore import QObject, Signal +from device.coyote.constants import ( + LOG_PREFIX, + WRITE_CHAR_UUID, + NOTIFY_CHAR_UUID, + BATTERY_CHAR_UUID, + CMD_B0, + CMD_POWER_UPDATE, + CMD_ACK, + CMD_ACTIVE_POWER, + INTERP_ABSOLUTE_SET, + INTERP_NO_CHANGE, + SEQUENCE_MODULO, + B0_NO_PULSES_PAD_BYTES, + PULSES_PER_PACKET, + SCAN_RETRY_SECONDS, +) +from device.coyote.types import CoyoteParams, CoyotePulse, CoyotePulses, CoyoteStrengths, ConnectionStage +from device.coyote.algorithm import CoyoteAlgorithm + +logger = logging.getLogger('restim.coyote') + +class CoyoteDevice(OutputDevice, QObject): + parameters: CoyoteParams = None + connection_status_changed = Signal(bool, str) # Connected, Stage + battery_level_changed = Signal(int) + parameters_changed = Signal() + power_levels_changed = Signal(CoyoteStrengths) + pulse_sent = Signal(CoyotePulses) + + def __init__(self, device_name: str): + OutputDevice.__init__(self) + QObject.__init__(self) + self.device_name = device_name + self.client: Optional[BleakClient] = None + self.algorithm: Optional[CoyoteAlgorithm] = None + self.running = False + self.connection_stage = ConnectionStage.DISCONNECTED + self.strengths = CoyoteStrengths(channel_a=0, channel_b=0) + self.battery_level = 100 + self.parameters = None + self._event_loop = None + self.sequence_number = 1 + + # Start connection process + self._start_connection_loop() + + def _start_connection_loop(self): + """Start the connection process in a separate thread""" + loop = asyncio.new_event_loop() + self._event_loop = loop + asyncio.set_event_loop(loop) + + def run_loop(): + logger.info(f"{LOG_PREFIX} Starting asyncio loop thread") + loop.run_until_complete(self._connection_loop()) + loop.run_forever() + + threading.Thread(target=run_loop, daemon=True).start() + + async def _connection_loop(self): + """Main connection loop that runs the state machine""" + logger.info(f"{LOG_PREFIX} Starting connection loop") + prev_stage = self.connection_stage + + attempt_counter = 0 + + while True: + try: + # Check if client is still connected + if (self.connection_stage == ConnectionStage.CONNECTED and + (not self.client or not self.client.is_connected)): + logger.warning(f"{LOG_PREFIX} Device disconnected unexpectedly") + await self.disconnect() + continue + + if self.connection_stage == ConnectionStage.DISCONNECTED: + logger.info(f"{LOG_PREFIX} Starting connection process") + self.connection_stage = ConnectionStage.SCANNING + + elif self.connection_stage == ConnectionStage.SCANNING: + if await self._scan_for_device(): + attempt_counter = 0 + logger.info(f"{LOG_PREFIX} Device found, connecting...") + self.connection_stage = ConnectionStage.CONNECTING + else: + attempt_counter += 1 + logger.info(f"{LOG_PREFIX} Device not found (attempt {attempt_counter}); retrying in {SCAN_RETRY_SECONDS} seconds...") + await asyncio.sleep(SCAN_RETRY_SECONDS) + + elif self.connection_stage == ConnectionStage.CONNECTING: + try: + await self.client.connect() + logger.info(f"{LOG_PREFIX} Connected, discovering services...") + self.connection_stage = ConnectionStage.SERVICE_DISCOVERY + except Exception as e: + logger.error(f"{LOG_PREFIX} Connection failed: {e}") + await self.disconnect() + + elif self.connection_stage == ConnectionStage.SERVICE_DISCOVERY: + services = self.client.services.services + if len(services) > 0: + logger.info(f"{LOG_PREFIX} Services discovered ({len(services)}), subscribing to battery...") + self.connection_stage = ConnectionStage.BATTERY_SUBSCRIBE + else: + logger.error(f"{LOG_PREFIX} Service discovery failed") + await self.disconnect() + + elif self.connection_stage == ConnectionStage.BATTERY_SUBSCRIBE: + if await self._subscribe_to_notifications(BATTERY_CHAR_UUID): + logger.info(f"{LOG_PREFIX} Battery subscribed, subscribing to status...") + self.connection_stage = ConnectionStage.STATUS_SUBSCRIBE + else: + logger.error(f"{LOG_PREFIX} Battery subscription failed") + await self.disconnect() + + elif self.connection_stage == ConnectionStage.STATUS_SUBSCRIBE: + if await self._subscribe_to_notifications(NOTIFY_CHAR_UUID): + logger.info(f"{LOG_PREFIX} Status subscribed, syncing parameters...") + self.connection_stage = ConnectionStage.SYNC_PARAMETERS + else: + logger.error(f"{LOG_PREFIX} Status subscription failed") + await self.disconnect() + + elif self.connection_stage == ConnectionStage.SYNC_PARAMETERS: + if await self._send_parameters(): + logger.info(f"{LOG_PREFIX} Parameters synced, connection complete") + # TODO: wait for ACK so we know device is ready + self.connection_stage = ConnectionStage.CONNECTED + else: + logger.error(f"{LOG_PREFIX} Parameter sync failed") + await self.disconnect() + + elif self.connection_stage == ConnectionStage.CONNECTED: + # Just maintain the connection + await asyncio.sleep(1) + + # Emit signal when connection status changes + if prev_stage != self.connection_stage: + is_connected = self.connection_stage == ConnectionStage.CONNECTED + self.connection_status_changed.emit(is_connected, self.connection_stage) + prev_stage = self.connection_stage + + except Exception as e: + logger.error(f"{LOG_PREFIX} Connection loop error: {e}") + # raise e + await self.disconnect() + + # Small delay between iterations + await asyncio.sleep(0.1) + + def start_updates(self, algorithm: Optional[any]): + logger.info(f"{LOG_PREFIX} start_updates called") + self.algorithm = algorithm + self.running = True + + future = None + if self._event_loop: + logger.info(f"{LOG_PREFIX} scheduling update_loop in event loop") + future = asyncio.run_coroutine_threadsafe(self.update_loop(), self._event_loop) + else: + logger.error(f"{LOG_PREFIX} No event loop present!") + + if future: + logger.info(f"{LOG_PREFIX} Future scheduled") + else: + logger.warning(f"{LOG_PREFIX} Update loop not scheduled") + + def stop_updates(self): + """Stop the update loop but maintain connection""" + logger.info(f"{LOG_PREFIX} Stopping updates") + self.running = False + self.algorithm = None + + async def _handle_battery_notification(self, sender, data: bytearray): + """Handle battery level notifications""" + battery_level = data[0] + + logger.info(f"{LOG_PREFIX} Battery level notification received: {battery_level}%") + + self.battery_level = battery_level + self.battery_level_changed.emit(battery_level) + + async def _handle_status_notification(self, sender, data: bytearray): + """Handle incoming status notifications from the device.""" + + if not data: + logger.warning(f"{LOG_PREFIX} Received empty status notification") + return + + # if len(data) != 4: + # logger.warning(f"Unexpected notification length: {len(data)} - {list(data)}") + # return + + command_id = data[0] + sequence_number = data[1] + power_a = data[2] + power_b = data[3] + + if command_id == CMD_POWER_UPDATE: + logger.info(f"{LOG_PREFIX} Power level update (seq={sequence_number}) - Channel A: {power_a}, Channel B: {power_b}") + self.strengths.channel_a = power_a + self.strengths.channel_b = power_b + self.power_levels_changed.emit(self.strengths) + + elif command_id == CMD_ACK: + logger.debug(f"{LOG_PREFIX} Command acknowledged (seq={sequence_number})") + + elif command_id == CMD_ACTIVE_POWER: + if len(data) < 4: + logger.warning(f"{LOG_PREFIX} Malformed active power notification: {list(data)}") + return + + power_a = data[2] + power_b = data[3] + + logger.info(f"{LOG_PREFIX} Active power update - Channel A: {power_a}, Channel B: {power_b}") + + # self.strengths.channel_a = power_a + # self.strengths.channel_b = power_b + # self.power_levels_changed.emit(self.strengths) + + # if len(data) > 4: + # extra = data[4:] + # logger.warning(f"Extra fields in 0x53 notification (undocumented): {list(extra)}") + + else: + logger.warning(f"{LOG_PREFIX} Unknown notification type: 0x{command_id:02X} (seq={sequence_number})") + logger.warning(f"{LOG_PREFIX} Raw notification: {list(data)}") + + async def _send_parameters(self): + """Send device parameters""" + logger.info( + f"{LOG_PREFIX} Syncing parameters - " + f"Limits: A={self.parameters.channel_a_limit}, B={self.parameters.channel_b_limit}, " + f"Freq Balance: A={self.parameters.channel_a_freq_balance}, B={self.parameters.channel_b_freq_balance}, " + f"Intensity Balance: A={self.parameters.channel_a_intensity_balance}, B={self.parameters.channel_b_intensity_balance}" + ) + + command = bytes([ + 0xBF, # Does this command produce an ACK? Only if the seq nibble is > 0 + self.parameters.channel_a_limit, + self.parameters.channel_b_limit, + self.parameters.channel_a_freq_balance, + self.parameters.channel_b_freq_balance, + self.parameters.channel_a_intensity_balance, + self.parameters.channel_b_intensity_balance + ]) + + try: + await self.client.write_gatt_char(WRITE_CHAR_UUID, command) + return True + except Exception as e: + logger.error(f"{LOG_PREFIX} Failed to sync parameters: {e}") + return False + + async def _subscribe_to_notifications(self, char_uuid: str) -> bool: + """Subscribe to notifications for a characteristic""" + try: + char = self.client.services.get_characteristic(char_uuid) + if not char: + logger.error(f"{LOG_PREFIX} Characteristic {char_uuid} not found") + return False + + await self.client.start_notify(char_uuid, + self._handle_battery_notification if char_uuid == BATTERY_CHAR_UUID + else self._handle_status_notification) + return True + except Exception as e: + logger.error(f"{LOG_PREFIX} Failed to subscribe to {char_uuid}: {e}") + return False + + async def _scan_for_device(self): + """Scan for Coyote device""" + try: + logger.info(f"{LOG_PREFIX} Scanning for device: {self.device_name}") + device = await BleakScanner.find_device_by_name(self.device_name) + if device: + logger.info(f"{LOG_PREFIX} Found device: {device.name} ({device.address})") + self.client = BleakClient(device) + self.connection_stage = ConnectionStage.CONNECTING + return True + else: + logger.info(f"{LOG_PREFIX} No BLE advertisement for {self.device_name} detected during scan window") + return False + except Exception as e: + logger.error(f"{LOG_PREFIX} Scan error: {e}") + await self.disconnect() + return False + + async def send_command(self, + strengths: Optional[CoyoteStrengths] = None, + pulses: Optional[CoyotePulses] = None): + """ + Send strength update and/or pulse pattern command to device. + + Args: + strengths: Optional strength update for channels A and B + pulses: Optional pulse patterns for channels A and B + """ + + if pulses: + self.pulse_sent.emit(pulses) + + if not self.client or not self.client.is_connected: + # logger.warning("Attempted to send command while disconnected") + + # Optimistic update for offline testing + if strengths: + self.strengths.channel_a = strengths.channel_a + self.strengths.channel_b = strengths.channel_b + + return + + if not strengths and not pulses: + logger.warning(f"{LOG_PREFIX} send_command called with no data") + return + + # Determine strength interpretation (default absolute set if new strength provided) + if strengths: + interp_a = INTERP_ABSOLUTE_SET # Absolute set for Channel A + interp_b = INTERP_ABSOLUTE_SET # Absolute set for Channel B + else: + interp_a = INTERP_NO_CHANGE # No change + interp_b = INTERP_NO_CHANGE # No change + + # Pack sequence number + interpretation into 1 byte (upper 4 = seq, lower 4 = interp) + request_ack = not pulses + control_byte = ((self.sequence_number if request_ack else 0) << 4) | (interp_a << 2) | interp_b + + # Build base command (B0 packet structure) + command = bytearray([ + CMD_B0, # Command ID + control_byte, # Combined seq + interpretation + strengths.channel_a if strengths else 0, + strengths.channel_b if strengths else 0, + ]) + + # Append pulse data if provided (waveform duration (aka frequency) + intensity) + if pulses: + command.extend([a.duration for a in pulses.channel_a]) + command.extend([a.intensity for a in pulses.channel_a]) + command.extend([b.duration for b in pulses.channel_b]) + command.extend([b.intensity for b in pulses.channel_b]) + else: + command.extend([0] * B0_NO_PULSES_PAD_BYTES) # No pulses = zero padding + + # Log what we're sending + if logger.isEnabledFor(logging.DEBUG): + logger.debug(f"{LOG_PREFIX} Sending command (seq={self.sequence_number}):") + + if pulses: + pulses_a = "\n".join( + f" Pulse {i+1}: Freq={pulse.frequency} Hz, Intensity={pulse.intensity}" + for i, pulse in enumerate(pulses.channel_a) + ) + pulses_b = "\n".join( + f" Pulse {i+1}: Freq={pulse.frequency} Hz, Intensity={pulse.intensity}" + for i, pulse in enumerate(pulses.channel_b) + ) + + logger.debug( + f"{LOG_PREFIX} Channel A ({self.strengths.channel_a}):\n{pulses_a}\n" + f"{LOG_PREFIX} Channel B ({self.strengths.channel_b}):\n{pulses_b}" + ) + + # Send the final command + try: + await self.client.write_gatt_char(WRITE_CHAR_UUID, command) + self.sequence_number = (self.sequence_number + 1) % SEQUENCE_MODULO # Wrap seq at 4 bits (0-15) + except Exception as e: + logger.error(f"{LOG_PREFIX} Failed to send command: {e}") + + async def disconnect(self): + """Disconnect from device""" + logger.info(f"{LOG_PREFIX} Disconnecting from device") + + if self.client: + self.running = False + + # Send zero pulses to turn off outputs + zero_pulses = CoyotePulses( + channel_a=[CoyotePulse(frequency=0, intensity=0, duration=0)] * PULSES_PER_PACKET, + channel_b=[CoyotePulse(frequency=0, intensity=0, duration=0)] * PULSES_PER_PACKET + ) + await self.send_command(pulses=zero_pulses) + await self.client.disconnect() + self.client = None + self.connection_stage = ConnectionStage.DISCONNECTED + + async def update_loop(self): + logger.info(f"{LOG_PREFIX} Starting update loop, running={self.running}, algorithm={self.algorithm}") + + try: + logger.info(f"{LOG_PREFIX} Update loop started, running={self.running}") + + while self.running: + try: + if not self.algorithm: + logger.warning(f"{LOG_PREFIX} Algorithm not yet set") + await asyncio.sleep(0.1) + continue + + current_time = time.time() + # Only log when a packet is actually generated and sent + if current_time >= self.algorithm.next_update_time: + pulses = self.algorithm.generate_packet(current_time) + if pulses is not None: + await self.send_command(pulses=pulses) + # Check if algorithm still exists after generate_packet() + if self.algorithm: + sleep_time = max(0.001, self.algorithm.next_update_time - time.time()) + else: + sleep_time = 0.01 + else: + sleep_time = 0.01 + + await asyncio.sleep(sleep_time) + + except Exception as inner_e: + logger.exception(f"{LOG_PREFIX} Exception inside update loop iteration: {inner_e}") + await asyncio.sleep(0.1) # prevent tight-crash-loop + + except Exception as outer_e: + logger.exception(f"{LOG_PREFIX} Fatal exception in update_loop: {outer_e}") + + finally: + logger.info(f"{LOG_PREFIX} Update loop stopped") + + def is_connected_and_running(self) -> bool: + return (self.connection_stage == ConnectionStage.CONNECTED and + self.client and self.client.is_connected) diff --git a/device/coyote/pulse_generator.py b/device/coyote/pulse_generator.py new file mode 100644 index 0000000..1de5eb7 --- /dev/null +++ b/device/coyote/pulse_generator.py @@ -0,0 +1,205 @@ +from __future__ import annotations + +import logging +import math +import random +from dataclasses import dataclass +from typing import Tuple + +from device.coyote.common import clamp, normalize +from device.coyote.config import PulseTuning +from device.coyote.constants import ( + HARDWARE_MAX_FREQ_HZ, + HARDWARE_MIN_FREQ_HZ, + MAX_PULSE_DURATION_MS, + MIN_PULSE_DURATION_MS, +) +from device.coyote.types import CoyotePulse +from stim_math.audio_gen.params import CoyoteAlgorithmParams, CoyoteChannelParams + +logger = logging.getLogger("restim.coyote") + + +@dataclass +class TextureInfo: + offset_ms: float + mode: str + headroom_up_ms: float + headroom_down_ms: float + + +@dataclass +class PulseDebug: + sequence_index: int + raw_frequency_hz: float + normalised_frequency: float + mapped_frequency_hz: float + frequency_limits: Tuple[float, float] + base_duration_ms: float + duration_limits: Tuple[int, int] + jitter_fraction: float + jitter_factor: float + width_normalised: float + texture_mode: str + texture_headroom_up_ms: float + texture_headroom_down_ms: float + texture_applied_ms: float + desired_duration_ms: float + residual_ms: float + + +class PulseGenerator: + """Builds hardware-friendly pulses for a single Coyote channel.""" + + def __init__( + self, + name: str, + params: CoyoteAlgorithmParams, + channel_params: CoyoteChannelParams, + carrier_freq_limits: Tuple[float, float], + pulse_freq_limits: Tuple[float, float], + pulse_width_limits: Tuple[float, float], + tuning: PulseTuning, + ) -> None: + self.name = name + self.params = params + self.channel_params = channel_params + self._carrier_limits = carrier_freq_limits + self._pulse_freq_limits = pulse_freq_limits + self._pulse_width_limits = pulse_width_limits + self._tuning = tuning + + self._phase = 0.0 + self._residual_ms = 0.0 + + @property + def carrier_limits(self) -> Tuple[float, float]: + return self._carrier_limits + + def advance_phase(self, texture_speed_hz: float, delta_time_s: float) -> None: + if delta_time_s <= 0 or texture_speed_hz <= 0: + return + phase_delta = delta_time_s * texture_speed_hz * 2 * math.pi + self._phase = (self._phase + phase_delta) % (2 * math.pi) + + def create_pulse(self, time_s: float, intensity: int, sequence_index: int) -> Tuple[CoyotePulse, PulseDebug]: + min_freq, max_freq = self._channel_frequency_window() + duration_limits = self._duration_limits(min_freq, max_freq) + + raw_frequency = float(self.params.pulse_frequency.interpolate(time_s)) + normalised = normalize(raw_frequency, self._pulse_freq_limits) + mapped_frequency = min_freq + (max_freq - min_freq) * normalised + if mapped_frequency <= 0: + mapped_frequency = 1000.0 / duration_limits[1] + base_duration = 1000.0 / mapped_frequency + + jitter_fraction = clamp( + float(self.params.pulse_interval_random.interpolate(time_s)), + 0.0, + self._tuning.jitter_limit_fraction, + ) + jitter_factor = 1.0 + random.uniform(-jitter_fraction, jitter_fraction) + + width_normalised = self._pulse_width_normalised(time_s) + texture_info = self._texture_offset(base_duration, width_normalised, min_freq, max_freq) + + desired_ms = base_duration * jitter_factor + texture_info.offset_ms + duration, residual = self._apply_residual(desired_ms) + duration, clamped = self._clamp_duration(duration, duration_limits) + if clamped: + residual = 0.0 + + final_duration = max(MIN_PULSE_DURATION_MS, duration) + final_frequency = int(max(1, round(1000.0 / final_duration))) + final_intensity = int(clamp(intensity, 0, 100)) + + debug = PulseDebug( + sequence_index=sequence_index, + raw_frequency_hz=raw_frequency, + normalised_frequency=normalised, + mapped_frequency_hz=mapped_frequency, + frequency_limits=(min_freq, max_freq), + base_duration_ms=base_duration, + duration_limits=duration_limits, + jitter_fraction=jitter_fraction, + jitter_factor=jitter_factor, + width_normalised=width_normalised, + texture_mode=texture_info.mode, + texture_headroom_up_ms=texture_info.headroom_up_ms, + texture_headroom_down_ms=texture_info.headroom_down_ms, + texture_applied_ms=texture_info.offset_ms, + desired_duration_ms=desired_ms, + residual_ms=residual, + ) + + return CoyotePulse(duration=final_duration, intensity=final_intensity, frequency=final_frequency), debug + + def _channel_frequency_window(self) -> Tuple[float, float]: + minimum = max(float(self.channel_params.minimum_frequency.get()), HARDWARE_MIN_FREQ_HZ) + maximum = min(float(self.channel_params.maximum_frequency.get()), HARDWARE_MAX_FREQ_HZ) + if minimum >= maximum: + return HARDWARE_MIN_FREQ_HZ, HARDWARE_MAX_FREQ_HZ + return minimum, maximum + + def _pulse_width_normalised(self, time_s: float) -> float: + raw = float(self.params.pulse_width.interpolate(time_s)) + low, high = self._pulse_width_limits + if high <= low: + return 0.0 + return clamp((raw - low) / (high - low), 0.0, 1.0) + + def _texture_offset( + self, + base_duration: float, + width_norm: float, + min_freq: float, + max_freq: float, + ) -> TextureInfo: + if width_norm <= 0 or self._tuning.texture_depth_fraction <= 0: + return TextureInfo(offset_ms=0.0, mode="none", headroom_up_ms=0.0, headroom_down_ms=0.0) + + min_duration = 1000.0 / max_freq + max_duration = 1000.0 / min_freq + + up_headroom = max(0.0, max_duration - base_duration) * self._tuning.texture_depth_fraction * width_norm + down_headroom = max(0.0, base_duration - min_duration) * self._tuning.texture_depth_fraction * width_norm + + if up_headroom > 1e-6 and down_headroom > 1e-6: + amplitude = min(up_headroom, down_headroom) + offset = amplitude * math.sin(self._phase) + return TextureInfo(offset_ms=offset, mode="sym", headroom_up_ms=up_headroom, headroom_down_ms=down_headroom) + + sine = math.sin(self._phase) + rectified = abs(sine) - 2.0 / math.pi + + if up_headroom > 1e-6: + offset = up_headroom * rectified + return TextureInfo(offset_ms=offset, mode="up", headroom_up_ms=up_headroom, headroom_down_ms=down_headroom) + if down_headroom > 1e-6: + offset = -down_headroom * rectified + return TextureInfo(offset_ms=offset, mode="down", headroom_up_ms=up_headroom, headroom_down_ms=down_headroom) + return TextureInfo(offset_ms=0.0, mode="none", headroom_up_ms=up_headroom, headroom_down_ms=down_headroom) + + def _apply_residual(self, desired_ms: float) -> Tuple[int, float]: + accum = desired_ms + self._residual_ms + rounded = int(round(accum)) + residual = accum - rounded + bound = self._tuning.residual_bound + residual = clamp(residual, -bound, bound) + self._residual_ms = residual + return max(1, rounded), residual + + def _duration_limits(self, min_freq: float, max_freq: float) -> Tuple[int, int]: + minimum = max(MIN_PULSE_DURATION_MS, int(round(1000.0 / max_freq))) + maximum = min(MAX_PULSE_DURATION_MS, int(round(1000.0 / min_freq))) + if minimum > maximum: + return MIN_PULSE_DURATION_MS, MAX_PULSE_DURATION_MS + return minimum, maximum + + def _clamp_duration(self, duration_ms: int, limits: Tuple[int, int]) -> Tuple[int, bool]: + low, high = limits + clamped_duration = int(clamp(duration_ms, low, high)) + clamped = clamped_duration != duration_ms + if clamped: + self._residual_ms = 0.0 + return clamped_duration, clamped diff --git a/device/coyote/types.py b/device/coyote/types.py new file mode 100644 index 0000000..bdbc2dd --- /dev/null +++ b/device/coyote/types.py @@ -0,0 +1,54 @@ +from dataclasses import dataclass + + +class ConnectionStage: + DISCONNECTED = "Disconnected" + SCANNING = "Scanning for device..." + CONNECTING = "Connecting..." + SERVICE_DISCOVERY = "Discovering services..." + BATTERY_SUBSCRIBE = "Setting up battery notifications..." + STATUS_SUBSCRIBE = "Setting up status notifications..." + SYNC_PARAMETERS = "Syncing parameters..." + CONNECTED = "Connected" + + +@dataclass +class CoyoteParams: + """ + Represents configurable parameters for the Coyote device + channel_a_limit: 0-200 power limit for channel A + channel_b_limit: 0-200 power limit for channel B + channel_a_freq_balance: 0-255 frequency balance for channel A + channel_b_freq_balance: 0-255 frequency balance for channel B + channel_a_intensity_balance: 0-255 intensity balance for channel A + channel_b_intensity_balance: 0-255 intensity balance for channel B + """ + channel_a_limit: int + channel_b_limit: int + channel_a_freq_balance: int + channel_b_freq_balance: int + channel_a_intensity_balance: int + channel_b_intensity_balance: int + + +@dataclass +class CoyotePulse: + frequency: int # Calculated from duration: 1000/duration_ms, range ~4-200 Hz + intensity: int # 0-100 + duration: int # 5-240ms (spec says 10-240, but 5ms works) + + +@dataclass +class CoyotePulses: + channel_a: list[CoyotePulse] # Exactly 4 pulses + channel_b: list[CoyotePulse] # Exactly 4 pulses + + def duration() -> int: + return 0 + + +@dataclass +class CoyoteStrengths: + """Represents channel strength (volume) settings""" + channel_a: int # 0-100 + channel_b: int # 0-100 diff --git a/qt_ui/algorithm_factory.py b/qt_ui/algorithm_factory.py index 33dd584..cbf8d15 100644 --- a/qt_ui/algorithm_factory.py +++ b/qt_ui/algorithm_factory.py @@ -3,6 +3,7 @@ from device.focstim.fourphase_algorithm import FOCStimFourphaseAlgorithm from device.neostim.algorithm import NeoStimAlgorithm +from device.coyote.algorithm import CoyoteAlgorithm from qt_ui.device_wizard.enums import DeviceConfiguration, DeviceType, WaveformType from stim_math.audio_gen.base_classes import AudioGenerationAlgorithm from device.focstim.threephase_algorithm import FOCStimThreephaseAlgorithm @@ -34,7 +35,7 @@ def __init__(self, mainwindow, self.load_funscripts = load_funscripts self.create_for_bake = create_for_bake - def create_algorithm(self, device: DeviceConfiguration) -> AudioGenerationAlgorithm | NeoStimAlgorithm: + def create_algorithm(self, device: DeviceConfiguration) -> AudioGenerationAlgorithm | NeoStimAlgorithm | CoyoteAlgorithm: if device.device_type == DeviceType.AUDIO_THREE_PHASE: if device.waveform_type == WaveformType.CONTINUOUS: return self.create_3phase_continuous(device) @@ -50,6 +51,8 @@ def create_algorithm(self, device: DeviceConfiguration) -> AudioGenerationAlgori return self.create_focstim_4phase_pulsebased(device) elif device.device_type == DeviceType.NEOSTIM_THREE_PHASE: return self.create_neostim(device) + elif device.device_type == DeviceType.COYOTE_THREE_PHASE: + return self.create_coyote(device) else: raise RuntimeError('unknown device type') @@ -241,6 +244,59 @@ def create_neostim(self, device: DeviceConfiguration) -> NeoStimAlgorithm: ), ) return algorithm + + def create_coyote(self, device: DeviceConfiguration) -> AudioGenerationAlgorithm: + # Get frequency limits from kit + carrier_freq_limits = self.kit.limits_for_axis(AxisEnum.CARRIER_FREQUENCY) + pulse_freq_limits = self.kit.limits_for_axis(AxisEnum.PULSE_FREQUENCY) + pulse_width_limits = self.kit.limits_for_axis(AxisEnum.PULSE_WIDTH) + pulse_rise_time_limits = self.kit.limits_for_axis(AxisEnum.PULSE_RISE_TIME) + + # Create the algorithm + algorithm = CoyoteAlgorithm( + self.media_sync, + CoyoteAlgorithmParams( + position=ThreephasePositionParams( + self.get_axis_alpha(), + self.get_axis_beta(), + ), + transform=self.mainwindow.tab_threephase.transform_params, + calibrate=self.mainwindow.tab_threephase.calibrate_params, + volume=VolumeParams( + api=self.get_axis_volume_api(), + master=self.get_axis_volume_master(), + inactivity=self.get_axis_volume_inactivity(), + external=self.get_axis_volume_external(), + ), + carrier_frequency=self.get_axis_pulse_carrier_frequency(), + pulse_frequency=self.get_axis_pulse_frequency(), + pulse_width=self.get_axis_pulse_width(), + pulse_interval_random=self.get_axis_pulse_interval_random(), + pulse_rise_time=self.get_axis_pulse_rise_time(), + max_intensity_change_per_pulse=settings.coyote_max_intensity_change_per_pulse, + channel_a=CoyoteChannelParams( + minimum_frequency=settings.coyote_channel_a_freq_min, + maximum_frequency=settings.coyote_channel_a_freq_max, + maximum_strength=settings.coyote_channel_a_strength_max, + vibration=self.get_axis_vib1_all() + ), + channel_b=CoyoteChannelParams( + minimum_frequency=settings.coyote_channel_b_freq_min, + maximum_frequency=settings.coyote_channel_b_freq_max, + maximum_strength=settings.coyote_channel_b_strength_max, + vibration=self.get_axis_vib2_all() + ) + ), + safety_limits=SafetyParams( + device.min_frequency, + device.max_frequency, + ), + carrier_freq_limits=carrier_freq_limits, + pulse_freq_limits=pulse_freq_limits, + pulse_width_limits=pulse_width_limits, + pulse_rise_time_limits=pulse_rise_time_limits + ) + return algorithm def get_axis_alpha(self): return self.get_axis_from_script_mapping(AxisEnum.POSITION_ALPHA) or self.mainwindow.alpha @@ -401,13 +457,13 @@ def get_axis_neostim_switch_time(self): def get_axis_neostim_debug(self): return self.mainwindow.tab_neostim.axis_debug - def get_axis_from_script_mapping(self, axis: AxisEnum) -> AbstractAxis | None: + def get_axis_from_script_mapping(self, axis: AxisEnum, limits: Optional[(int, int)] = None) -> AbstractAxis | None: if not self.load_funscripts: return None funscript_item = self.script_mapping.get_config_for_axis(axis) if funscript_item: - limit_min, limit_max = self.kit.limits_for_axis(axis) + limit_min, limit_max = limits or self.kit.limits_for_axis(axis) # TODO: not very memory efficient if multiple algorithms reference the same script. # but worst-case it only wastes a few MB or so... return create_precomputed_axis(funscript_item.script.x, diff --git a/qt_ui/coyote_settings_widget.py b/qt_ui/coyote_settings_widget.py new file mode 100644 index 0000000..c166652 --- /dev/null +++ b/qt_ui/coyote_settings_widget.py @@ -0,0 +1,699 @@ +import asyncio +import logging +import time +from dataclasses import dataclass +from typing import Dict, Optional +from PySide6 import QtWidgets +from PySide6.QtWidgets import (QWidget, QVBoxLayout, QLabel, QSlider, QHBoxLayout, + QGraphicsView, QGraphicsScene, QGraphicsLineItem, QSpinBox, + QGraphicsRectItem, QToolTip, QGraphicsEllipseItem) +from PySide6.QtCore import QSettings, Qt, QTimer +from PySide6.QtGui import QPen, QColor, QBrush, QPainterPath +from device.coyote.device import CoyoteDevice, CoyotePulse, CoyotePulses, CoyoteStrengths +from qt_ui import settings + +class CoyoteSettingsWidget(QtWidgets.QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.device: Optional[CoyoteDevice] = None + self.channel_controls: Dict[str, ChannelControl] = {} + self.coyote_logger = logging.getLogger('restim.coyote') + self._base_log_level = self.coyote_logger.getEffectiveLevel() + self.graph_window = settings.coyote_graph_window + self.setupUi() + self.apply_debug_logging(settings.coyote_debug_logging.get()) + + def setupUi(self): + self.setLayout(QVBoxLayout()) + + self.label_connection_status = QLabel("Device: Disconnected") + self.label_connection_stage = QLabel("Stage: Waiting") + self.label_battery_level = QLabel("Battery: —") + status_layout = QHBoxLayout() + status_layout.addWidget(self.label_connection_status) + status_layout.addWidget(self.label_connection_stage) + status_layout.addWidget(self.label_battery_level) + self.layout().addLayout(status_layout) + + configs = ( + ChannelConfig( + channel_id='A', + freq_min_setting=settings.coyote_channel_a_freq_min, + freq_max_setting=settings.coyote_channel_a_freq_max, + strength_max_setting=settings.coyote_channel_a_strength_max, + ), + ChannelConfig( + channel_id='B', + freq_min_setting=settings.coyote_channel_b_freq_min, + freq_max_setting=settings.coyote_channel_b_freq_max, + strength_max_setting=settings.coyote_channel_b_strength_max, + ), + ) + + for config in configs: + control = ChannelControl(self, config) + self.channel_controls[config.channel_id] = control + self.layout().addLayout(control.build_ui()) + control.reset_volume() + + def setup_device(self, device: CoyoteDevice): + self.device = device + + self.device.connection_status_changed.connect(self.on_connection_status_changed) + self.device.battery_level_changed.connect(self.on_battery_level_changed) + self.device.parameters_changed.connect(self.on_parameters_changed) + self.device.power_levels_changed.connect(self.on_power_levels_changed) + self.device.pulse_sent.connect(self.on_pulse_sent) + + for control in self.channel_controls.values(): + control.reset_volume() + + if device.strengths: + for control in self.channel_controls.values(): + control.update_from_device(device.strengths) + + def update_channel_strength(self, control: 'ChannelControl', value: int): + if not self.device or not self.device._event_loop: + return + + strengths = control.with_strength(self.device.strengths, value) + + asyncio.run_coroutine_threadsafe( + self.device.send_command(strengths), + self.device._event_loop + ) + + self.device.strengths = strengths + + def on_connection_status_changed(self, connected: bool, stage: str = None): + self.label_connection_status.setText("Device: Connected" if connected else "Device: Disconnected") + if stage: + normalized_stage = stage.strip() + if connected and normalized_stage.lower() == "connected": + stage_text = "Ready" + else: + stage_text = normalized_stage + self.label_connection_stage.setText(f"Stage: {stage_text}") + else: + self.label_connection_stage.setText("Stage: —") + + def on_battery_level_changed(self, level: int): + self.label_battery_level.setText(f"Battery: {level}%") + + def on_parameters_changed(self): + pass + + def on_power_levels_changed(self, strengths: CoyoteStrengths): + for control in self.channel_controls.values(): + control.update_from_device(strengths) + + def on_pulse_sent(self, pulses: CoyotePulses): + if not self.device: + return + + for control in self.channel_controls.values(): + control.apply_pulses(pulses, self.device.strengths) + + def apply_debug_logging(self, enabled: bool): + new_level = logging.DEBUG if enabled else logging.INFO + self.coyote_logger.setLevel(new_level) + +@dataclass(frozen=True) +class ChannelConfig: + channel_id: str + freq_min_setting: settings.Setting + freq_max_setting: settings.Setting + strength_max_setting: settings.Setting + +class ChannelControl: + def __init__(self, parent: 'CoyoteSettingsWidget', config: ChannelConfig): + self.parent = parent + self.config = config + + self.freq_min: Optional[QSpinBox] = None + self.freq_max: Optional[QSpinBox] = None + self.strength_max: Optional[QSpinBox] = None + self.volume_slider: Optional[QSlider] = None + self.volume_label: Optional[QLabel] = None + self.pulse_graph: Optional[PulseGraphContainer] = None + self.stats_label: Optional[QLabel] = None + + @property + def channel_id(self) -> str: + return self.config.channel_id + + @property + def _is_channel_a(self) -> bool: + return self.channel_id.upper() == 'A' + + def build_ui(self) -> QHBoxLayout: + layout = QHBoxLayout() + + left = QVBoxLayout() + left.addWidget(QLabel(f"Channel {self.channel_id}")) + + freq_min_layout = QHBoxLayout() + self.freq_min = QSpinBox() + self.freq_min.setRange(10, 500) + self.freq_min.setSingleStep(10) + self.freq_min.setValue(self.config.freq_min_setting.get()) + self.freq_min.valueChanged.connect(self.on_freq_min_changed) + freq_min_layout.addWidget(QLabel("Min Freq (Hz)")) + freq_min_layout.addWidget(self.freq_min) + left.addLayout(freq_min_layout) + + freq_max_layout = QHBoxLayout() + self.freq_max = QSpinBox() + self.freq_max.setRange(10, 500) + self.freq_max.setSingleStep(10) + self.freq_max.setValue(self.config.freq_max_setting.get()) + self.freq_max.valueChanged.connect(self.on_freq_max_changed) + freq_max_layout.addWidget(QLabel("Max Freq (Hz)")) + freq_max_layout.addWidget(self.freq_max) + left.addLayout(freq_max_layout) + + strength_layout = QHBoxLayout() + strength_layout.addWidget(QLabel("Max Strength")) + self.strength_max = QSpinBox() + self.strength_max.setRange(1, 200) + self.strength_max.setSingleStep(1) + self.strength_max.setValue(self.config.strength_max_setting.get()) + self.strength_max.valueChanged.connect(self.on_strength_max_changed) + strength_layout.addWidget(self.strength_max) + left.addLayout(strength_layout) + + layout.addLayout(left) + + self.pulse_graph = PulseGraphContainer(self.parent.graph_window, self.freq_min, self.freq_max) + self.pulse_graph.plot.setMinimumHeight(100) + + graph_column = QVBoxLayout() + graph_column.addWidget(self.pulse_graph) + + self.stats_label = QLabel("Intensity: 0%\nFrequency: 0 Hz") + self.stats_label.setAlignment(Qt.AlignHCenter) + self.pulse_graph.attach_stats_label(self.stats_label) + graph_column.addWidget(self.stats_label) + + layout.addLayout(graph_column) + + volume_layout = QVBoxLayout() + self.volume_slider = QSlider(Qt.Vertical) + self.volume_slider.setRange(0, self.config.strength_max_setting.get()) + self.volume_slider.valueChanged.connect(self.on_volume_changed) + self.volume_label = QLabel() + self.volume_label.setAlignment(Qt.AlignHCenter) + volume_layout.addWidget(self.volume_slider) + volume_layout.addWidget(self.volume_label) + layout.addLayout(volume_layout) + + self.update_volume_label(self.volume_slider.value()) + return layout + + def reset_volume(self): + self.set_strength_from_device(0) + + def select_strength(self, strengths: CoyoteStrengths) -> int: + return strengths.channel_a if self._is_channel_a else strengths.channel_b + + def with_strength(self, strengths: CoyoteStrengths, value: int) -> CoyoteStrengths: + if self._is_channel_a: + return CoyoteStrengths(channel_a=value, channel_b=strengths.channel_b) + return CoyoteStrengths(channel_a=strengths.channel_a, channel_b=value) + + def extract_pulses(self, pulses: CoyotePulses) -> list[CoyotePulse]: + return pulses.channel_a if self._is_channel_a else pulses.channel_b + + def update_from_device(self, strengths: CoyoteStrengths): + self.set_strength_from_device(self.select_strength(strengths)) + + def apply_pulses(self, pulses: CoyotePulses, strengths: CoyoteStrengths): + channel_pulses = self.extract_pulses(pulses) + if not channel_pulses: + return + self.handle_pulses(channel_pulses, self.select_strength(strengths)) + + def on_volume_changed(self, value: int): + self.update_volume_label(value) + self.parent.update_channel_strength(self, value) + + def update_volume_label(self, value: int): + max_strength = max(1, self.config.strength_max_setting.get()) + percentage = int((value / max_strength) * 100) + self.volume_label.setText(f"{value} ({percentage}%)") + + def set_strength_from_device(self, value: int): + if self.volume_slider is None: + return + self.volume_slider.blockSignals(True) + self.volume_slider.setValue(value) + self.volume_slider.blockSignals(False) + self.update_volume_label(value) + + def on_strength_max_changed(self, value: int): + self.config.strength_max_setting.set(value) + + current_value = self.volume_slider.value() if self.volume_slider else 0 + if self.volume_slider: + self.volume_slider.blockSignals(True) + self.volume_slider.setRange(0, value) + clamped_value = min(current_value, value) + self.volume_slider.setValue(clamped_value) + self.volume_slider.blockSignals(False) + self.update_volume_label(clamped_value) + current_value = clamped_value + + self.parent.update_channel_strength(self, current_value) + + def on_freq_min_changed(self, value: int): + if self.freq_min is None or self.freq_max is None: + return + + corrected = value + if value >= self.freq_max.value(): + corrected = max(self.freq_max.value() - self.freq_min.singleStep(), self.freq_min.minimum()) + if corrected != value: + self.freq_min.blockSignals(True) + self.freq_min.setValue(corrected) + self.freq_min.blockSignals(False) + self.config.freq_min_setting.set(corrected) + + def on_freq_max_changed(self, value: int): + if self.freq_min is None or self.freq_max is None: + return + + corrected = value + if value <= self.freq_min.value(): + corrected = min(self.freq_min.value() + self.freq_max.singleStep(), self.freq_max.maximum()) + if corrected != value: + self.freq_max.blockSignals(True) + self.freq_max.setValue(corrected) + self.freq_max.blockSignals(False) + self.config.freq_max_setting.set(corrected) + + def handle_pulses(self, pulses: list[CoyotePulse], strength: int): + if not self.pulse_graph or not pulses: + return + + channel_limit = self.config.strength_max_setting.get() + for pulse in pulses: + self.pulse_graph.add_pulse( + frequency=pulse.frequency, + intensity=pulse.intensity, + duration=pulse.duration, + current_strength=strength, + channel_limit=channel_limit, + ) + +class PulseGraphContainer(QWidget): + def __init__(self, window_seconds: settings.Setting, freq_min: QSpinBox, freq_max: QSpinBox, *args, **kwargs): + super().__init__(*args, **kwargs) + # Store frequency range controls + self.freq_min = freq_min + self.freq_max = freq_max + + # Initialize entries list to store CoyotePulse objects + self.entries = [] + + # Time window for stats display (in seconds) + self.stats_window = window_seconds + + # Create layout + self.layout = QVBoxLayout(self) + + # Create plot widget + self.plot = PulseGraph(window_seconds, *args, **kwargs) + self.layout.addWidget(self.plot) + + # Optional stats label managed by parent component + self.stats_label: Optional[QLabel] = None + + def attach_stats_label(self, label: QLabel): + self.stats_label = label + self.stats_label.setText("Intensity: 0%\nFrequency: 0 Hz") + + def get_frequency_range_text(self, entries) -> str: + """Get the frequency range text from the given entries.""" + if not entries: + return "N/A" + frequencies = [entry.frequency for entry in entries] + avg_frequency = sum(frequencies) / len(frequencies) + min_freq = min(frequencies) + max_freq = max(frequencies) + + # If min, max, and average are all the same, just show the single value + if min_freq == max_freq == round(avg_frequency): + return f"{int(avg_frequency)} Hz" + # If min and max differ, show average with range + return f"{avg_frequency:.0f} Hz ({min_freq} – {max_freq})" + + def format_intensity_text(self, intensities) -> str: + """Format intensity text with smart range display.""" + if not intensities: + return "N/A" + avg_intensity = sum(intensities) / len(intensities) + min_intensity = min(intensities) + max_intensity = max(intensities) + + # If min, max, and average are all the same, just show the single value + if min_intensity == max_intensity == round(avg_intensity): + return f"{int(avg_intensity)}%" + # If min and max differ, show average with range + return f"{avg_intensity:.0f}% ({min_intensity} – {max_intensity})" + + def clean_old_entries(self): + """Remove entries outside the time window""" + current_time = time.time() + stats_window = self.stats_window.get() + self.entries = [e for e in self.entries if current_time - e.timestamp <= stats_window] + + def update_label_text(self): + # Clean up old entries + self.clean_old_entries() + + # Calculate stats using pulses from the time window + recent_entries = self.entries + + # Get frequency range text + freq_text = self.get_frequency_range_text(recent_entries) + + # Get intensity range + intensities = [entry.intensity for entry in recent_entries] + intensity_text = self.format_intensity_text(intensities) + + if self.stats_label: + self.stats_label.setText(f"Intensity: {intensity_text}\nFrequency: {freq_text}") + + def add_pulse(self, frequency, intensity, duration, current_strength, channel_limit): + # Calculate effective intensity after applying current strength + effective_intensity = intensity * (current_strength / 100) + + # For zero intensity pulses, still create them but with zero intensity + # This shows empty space in the graph + + # Create a CoyotePulse object + pulse = CoyotePulse( + frequency=frequency, + intensity=intensity, + duration=duration + ) + + # Add timestamp for time-window filtering + pulse.timestamp = time.time() + + # Store pulse data + self.entries.append(pulse) + + self.update_label_text() + + # Update the plot - even zero intensity pulses are sent through for visualization + self.plot.add_pulse(pulse, effective_intensity, channel_limit) + +class PulseGraph(QWidget): + def __init__(self, window_seconds: settings.Setting, parent=None): + super().__init__(parent) + self.setLayout(QVBoxLayout()) + + self.view = QGraphicsView() + self.scene = QGraphicsScene() + self.view.setScene(self.scene) + + # Completely disable scrolling and user interaction + self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.view.setInteractive(True) # Enable interaction for tooltips + self.view.setDragMode(QGraphicsView.NoDrag) + self.view.setTransformationAnchor(QGraphicsView.NoAnchor) + self.view.setResizeAnchor(QGraphicsView.NoAnchor) + self.view.setViewportUpdateMode(QGraphicsView.FullViewportUpdate) + + # Prevent wheel events + self.view.wheelEvent = lambda event: None + + self.layout().addWidget(self.view) + + # Configuration for time window (in seconds) + self.time_window = window_seconds + + # Store pulses for visualization + self.pulses = [] + self.channel_limit = 100 # Default channel limit + + # Packet tracking for FIFO visualization + self.current_packet_index = 0 # Which 4-pulse packet is currently active + self.last_packet_time = 0 # When the last packet was received + self.pulse_fingerprints = {} # Track pulse fingerprints to avoid duplicates + + # Initialize the scene size + self.updateSceneRect() + + self.timer = QTimer() + self.timer.timeout.connect(self.refresh) + self.timer.start(50) + + # Colors for visualization + self.pulse_color = QColor(0, 255, 0, 200) # Semi-transparent lime + self.pulse_border_color = QColor("darkgreen") + + # Time scaling factor - how many pixels per ms of duration + self.time_scale_factor = 0.5 # pixels per ms + + def resizeEvent(self, event): + """Handle resize events by updating the scene rectangle""" + super().resizeEvent(event) + self.updateSceneRect() + # Force a refresh after resize + self.refresh() + + def updateSceneRect(self): + """Update the scene rectangle to match the view size""" + if self.view: + width = self.view.viewport().width() + height = self.view.viewport().height() + self.view.setSceneRect(0, 0, width, height) + + def get_pulse_fingerprint(self, pulse: CoyotePulse) -> str: + """Generate a fingerprint for a pulse to detect duplicates""" + return f"{pulse.frequency}_{pulse.intensity}_{pulse.duration}" + + def clean_old_pulses(self): + """Remove pulses outside the time window""" + current_time = time.time() + time_window = self.time_window.get() + self.pulses = [p for p in self.pulses if current_time - p.timestamp <= time_window] + + # Also clean up old fingerprints + for fingerprint, timestamp in list(self.pulse_fingerprints.items()): + if current_time - timestamp > time_window: + self.pulse_fingerprints.pop(fingerprint) + + def add_pulse(self, pulse: CoyotePulse, applied_intensity: float, channel_limit: int): + """Add a new pulse to the visualization""" + # Don't skip zero intensity pulses, but display them differently + self.channel_limit = channel_limit + + # Generate a fingerprint for this pulse + fingerprint = self.get_pulse_fingerprint(pulse) + + # Check if this pulse is from a new packet + current_time = time.time() + is_new_packet = len(self.pulses) % 4 == 0 or current_time - self.last_packet_time > 0.2 + + # If we've seen this exact pulse recently and it's not a new packet, skip it + if fingerprint in self.pulse_fingerprints and not is_new_packet: + # Only add if it's been more than 1 second since we last saw this pulse + last_seen_time = self.pulse_fingerprints[fingerprint] + if current_time - last_seen_time < 1.0: + return # Skip this pulse, it's a duplicate + + # Update fingerprint timestamp + self.pulse_fingerprints[fingerprint] = current_time + + # If it's a new packet, increment the packet index + if is_new_packet: + self.current_packet_index += 1 + self.last_packet_time = current_time + + # Store the CoyotePulse with additional metadata + pulse_copy = CoyotePulse( + frequency=pulse.frequency, + intensity=pulse.intensity, + duration=pulse.duration + ) + + # Add additional attributes to the pulse + pulse_copy.applied_intensity = applied_intensity + pulse_copy.packet_index = self.current_packet_index + pulse_copy.timestamp = current_time + + # Add the pulse + self.pulses.append(pulse_copy) + + # Clean up old pulses that are outside our time window + self.clean_old_pulses() + + def refresh(self): + """Redraw the pulse visualization""" + self.scene.clear() + + # Always ensure we're using the current viewport size + self.updateSceneRect() + + width = self.view.viewport().width() + height = self.view.viewport().height() + + # Clean up old pulses again (in case the timer fired without any new pulses added) + self.clean_old_pulses() + + if not self.pulses: + return + + # Sort pulses by timestamp so they display in chronological order + sorted_pulses = sorted(self.pulses, key=lambda p: p.timestamp) + + # Find the maximum intensity in current visible pulses + max_intensity = max(pulse.applied_intensity for pulse in sorted_pulses) + # Use either the channel limit or the current max intensity, whichever is larger + scale_max = max(max_intensity, self.channel_limit) + + # Get the time span of the visible pulses + now = time.time() + time_window = self.time_window.get() + oldest_time = now - time_window + newest_time = now + time_span_sec = time_window + + # Calculate total width available for all pulses + usable_width = width - 10 # Leave small margin on right side + + # Scale based on the time window, not the pulse count + # This ensures consistent scaling regardless of pulse frequency + time_scale = usable_width / (time_span_sec * 1000) # Convert to ms + + # Group pulses by packet for continuous display + pulses_by_packet = {} + for pulse in sorted_pulses: + packet_idx = pulse.packet_index + if packet_idx not in pulses_by_packet: + pulses_by_packet[packet_idx] = [] + pulses_by_packet[packet_idx].append(pulse) + + # Get sorted list of packet indices + packet_indices = sorted(pulses_by_packet.keys()) + + # Draw each packet's pulses as a continuous sequence + for i, packet_idx in enumerate(packet_indices): + packet_pulses = sorted(pulses_by_packet[packet_idx], key=lambda p: p.timestamp) + + # Determine the time range this packet covers + if i < len(packet_indices) - 1: + # This packet runs until the next packet starts + next_packet_idx = packet_indices[i + 1] + next_packet_start = min(p.timestamp for p in pulses_by_packet[next_packet_idx]) + packet_end_time = next_packet_start + else: + # This is the last packet, it runs until now + packet_end_time = now + + # Calculate packet colors + packet_color = QColor(0, 255, 0, 200) if packet_idx % 2 == 0 else QColor(100, 255, 100, 200) + + # Draw each pulse in this packet + for j, pulse in enumerate(packet_pulses): + # Calculate time positions + pulse_start_time = pulse.timestamp + + # For continuity, calculate the end time: + if j < len(packet_pulses) - 1: + # If there's another pulse in this packet, it extends to that pulse + pulse_end_time = packet_pulses[j + 1].timestamp + else: + # If this is the last pulse in the packet, it extends to the packet end + pulse_end_time = packet_end_time + + # Ensure we're within the visible time window + pulse_start_time = max(pulse_start_time, oldest_time) + pulse_end_time = min(pulse_end_time, newest_time) + + # Calculate positions and dimensions + time_position_start = (pulse_start_time - oldest_time) / time_span_sec + time_position_end = (pulse_end_time - oldest_time) / time_span_sec + + x_start = 5 + (time_position_start * usable_width) + x_end = 5 + (time_position_end * usable_width) + rect_width = max(2, x_end - x_start) # Ensure minimum width + + # Calculate height based on intensity (always define rect_height) + height_ratio = pulse.applied_intensity / scale_max if scale_max > 0 else 0 + rect_height = height * height_ratio + + # For zero-intensity pulses, still show something to indicate timing + if pulse.applied_intensity <= 0: + # Draw a thin line or empty rectangle to show timing without intensity + empty_rect = QGraphicsRectItem( + x_start, height - 2, # Just a thin line at the bottom + rect_width, 2 + ) + empty_rect.setPen(QPen(QColor(100, 100, 100, 100), 1)) # Very light gray + empty_rect.setBrush(QBrush(QColor(100, 100, 100, 50))) # Almost transparent + self.scene.addItem(empty_rect) + else: + # Create rectangle for the pulse + rect = PulseRectItem( + x_start, height - rect_height, # x, y (bottom-aligned) + rect_width, rect_height, # width, height + pulse # pass pulse data for tooltip + ) + + rect.setPen(QPen(self.pulse_border_color, 1)) + rect.setBrush(QBrush(packet_color)) + + # Add rectangle to scene + self.scene.addItem(rect) + + # Draw frequency tick marks for visualization + if pulse.frequency > 0 and rect_width > 10: + # Number of ticks based on frequency (higher frequency = more ticks) + num_ticks = min(max(2, int(pulse.frequency / 20)), 8) # 2-8 ticks + + tick_spacing = rect_width / (num_ticks + 1) + tick_height = rect_height * 0.4 # 40% of rectangle height + + for t in range(1, num_ticks + 1): + tick_x = x_start + (t * tick_spacing) + tick_y = height - rect_height + + # Draw tick mark + tick = QGraphicsLineItem( + tick_x, tick_y, # Start at top of rectangle + tick_x, tick_y + tick_height # Go down + ) + tick.setPen(QPen(QColor("white"), 1)) + self.scene.addItem(tick) + +class PulseRectItem(QGraphicsRectItem): + def __init__(self, x, y, width, height, pulse): + super().__init__(x, y, width, height) + self.pulse = pulse + self.setAcceptHoverEvents(True) + + def hoverEnterEvent(self, event): + # Show tooltip with pulse information + freq = self.pulse.frequency + intensity = self.pulse.intensity + duration = self.pulse.duration + + tooltip_text = f"Frequency: {freq} Hz\nIntensity: {intensity}%\nDuration: {duration} ms" + QToolTip.showText(event.screenPos(), tooltip_text) + + # Change appearance on hover + current_pen = self.pen() + current_pen.setWidth(2) # Make border thicker + self.setPen(current_pen) + + def hoverLeaveEvent(self, event): + # Restore original appearance + current_pen = self.pen() + current_pen.setWidth(1) # Restore original border width + self.setPen(current_pen) + diff --git a/qt_ui/device_wizard/coyote_waveform_select.py b/qt_ui/device_wizard/coyote_waveform_select.py new file mode 100644 index 0000000..ea0b8f3 --- /dev/null +++ b/qt_ui/device_wizard/coyote_waveform_select.py @@ -0,0 +1,16 @@ +from PySide6.QtWidgets import QWizardPage + +from qt_ui.device_wizard.coyote_waveform_select_ui import Ui_WizardPageCoyote + + +class WizardPageCoyoteWaveformSelect(QWizardPage, Ui_WizardPageCoyote): + def __init__(self, parent=None): + super().__init__(parent) + self.setupUi(self) + + self.three_phase_radio.toggled.connect(self.completeChanged) + + def isComplete(self) -> bool: + return any([ + self.three_phase_radio.isChecked() and self.three_phase_radio.isEnabled(), + ]) diff --git a/qt_ui/device_wizard/coyote_waveform_select_ui.py b/qt_ui/device_wizard/coyote_waveform_select_ui.py new file mode 100644 index 0000000..3c8f2bd --- /dev/null +++ b/qt_ui/device_wizard/coyote_waveform_select_ui.py @@ -0,0 +1,57 @@ +# -*- coding: utf-8 -*- + +################################################################################ +## Form generated from reading UI file 'coyote_waveform_select.ui' +## +## Created by: Qt User Interface Compiler version 6.9.0 +## +## WARNING! All changes made in this file will be lost when recompiling UI file! +################################################################################ + +from PySide6.QtCore import (QCoreApplication, QDate, QDateTime, QLocale, + QMetaObject, QObject, QPoint, QRect, + QSize, QTime, QUrl, Qt) +from PySide6.QtGui import (QBrush, QColor, QConicalGradient, QCursor, + QFont, QFontDatabase, QGradient, QIcon, + QImage, QKeySequence, QLinearGradient, QPainter, + QPalette, QPixmap, QRadialGradient, QTransform) +from PySide6.QtWidgets import (QApplication, QLabel, QRadioButton, QSizePolicy, + QSpacerItem, QVBoxLayout, QWidget, QWizardPage) + +class Ui_WizardPageCoyote(object): + def setupUi(self, WizardPageCoyote): + if not WizardPageCoyote.objectName(): + WizardPageCoyote.setObjectName(u"WizardPageCoyote") + WizardPageCoyote.resize(611, 497) + self.verticalLayout = QVBoxLayout(WizardPageCoyote) + self.verticalLayout.setObjectName(u"verticalLayout") + self.three_phase_radio = QRadioButton(WizardPageCoyote) + self.three_phase_radio.setObjectName(u"three_phase_radio") + + self.verticalLayout.addWidget(self.three_phase_radio) + + self.label = QLabel(WizardPageCoyote) + self.label.setObjectName(u"label") + self.label.setWordWrap(True) + + self.verticalLayout.addWidget(self.label) + + self.verticalSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) + + self.verticalLayout.addItem(self.verticalSpacer) + + + self.retranslateUi(WizardPageCoyote) + + QMetaObject.connectSlotsByName(WizardPageCoyote) + # setupUi + + def retranslateUi(self, WizardPageCoyote): + WizardPageCoyote.setWindowTitle(QCoreApplication.translate("WizardPageCoyote", u"WizardPage", None)) + self.three_phase_radio.setText(QCoreApplication.translate("WizardPageCoyote", u"Three-phase", None)) + self.label.setText(QCoreApplication.translate("WizardPageCoyote", u"\n" +"

A = left
B = right
C = neutral

\n" +"

Connect A- and B- to a shared common electrode (e.g. a conductive rubber loop).

\n" +" ", None)) + # retranslateUi + diff --git a/qt_ui/device_wizard/enums.py b/qt_ui/device_wizard/enums.py index 4544286..9bbf21b 100644 --- a/qt_ui/device_wizard/enums.py +++ b/qt_ui/device_wizard/enums.py @@ -12,6 +12,7 @@ class DeviceType(Enum): FOCSTIM_THREE_PHASE = 5 NEOSTIM_THREE_PHASE = 6 FOCSTIM_FOUR_PHASE = 7 + COYOTE_THREE_PHASE = 8 class WaveformType(Enum): @@ -30,7 +31,7 @@ class DeviceConfiguration: def save(self): settings.device_config_device_type.set(self.device_type.value) - if self.device_type in (DeviceType.AUDIO_THREE_PHASE, DeviceType.FOCSTIM_THREE_PHASE): + if self.device_type in (DeviceType.AUDIO_THREE_PHASE, DeviceType.FOCSTIM_THREE_PHASE, DeviceType.COYOTE_THREE_PHASE): settings.device_config_waveform_type.set(self.waveform_type.value) settings.device_config_min_freq.set(self.min_frequency) settings.device_config_max_freq.set(self.max_frequency) diff --git a/qt_ui/device_wizard/type_select.py b/qt_ui/device_wizard/type_select.py index 81f63e5..4b50d23 100644 --- a/qt_ui/device_wizard/type_select.py +++ b/qt_ui/device_wizard/type_select.py @@ -11,10 +11,12 @@ def __init__(self, parent=None): self.audio_based_radio.toggled.connect(self.completeChanged) self.focstim_radio.toggled.connect(self.completeChanged) self.neostim_radio.toggled.connect(self.completeChanged) + self.coyote_radio.toggled.connect(self.completeChanged) def isComplete(self) -> bool: return any([ self.audio_based_radio.isChecked(), self.focstim_radio.isChecked(), - self.neostim_radio.isChecked() + self.neostim_radio.isChecked(), + self.coyote_radio.isChecked() ]) diff --git a/qt_ui/device_wizard/type_select_ui.py b/qt_ui/device_wizard/type_select_ui.py index 12ccc6a..f6ddbe1 100644 --- a/qt_ui/device_wizard/type_select_ui.py +++ b/qt_ui/device_wizard/type_select_ui.py @@ -36,15 +36,20 @@ def setupUi(self, WizardPageDeviceType): self.formLayout.setWidget(1, QFormLayout.LabelRole, self.focstim_radio) - self.verticalSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) - - self.formLayout.setItem(3, QFormLayout.LabelRole, self.verticalSpacer) - self.neostim_radio = QRadioButton(WizardPageDeviceType) self.neostim_radio.setObjectName(u"neostim_radio") self.formLayout.setWidget(2, QFormLayout.LabelRole, self.neostim_radio) + self.coyote_radio = QRadioButton(WizardPageDeviceType) + self.coyote_radio.setObjectName(u"coyote_radio") + + self.formLayout.setWidget(3, QFormLayout.LabelRole, self.coyote_radio) + + self.verticalSpacer = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) + + self.formLayout.setItem(4, QFormLayout.LabelRole, self.verticalSpacer) + self.retranslateUi(WizardPageDeviceType) @@ -57,5 +62,6 @@ def retranslateUi(self, WizardPageDeviceType): self.audio_based_radio.setText(QCoreApplication.translate("WizardPageDeviceType", u"Audio-based three-phase", None)) self.focstim_radio.setText(QCoreApplication.translate("WizardPageDeviceType", u"FOC-Stim", None)) self.neostim_radio.setText(QCoreApplication.translate("WizardPageDeviceType", u"NeoStim", None)) + self.coyote_radio.setText(QCoreApplication.translate("WizardPageDeviceType", u"Coyote 3", None)) # retranslateUi diff --git a/qt_ui/device_wizard/wizard.py b/qt_ui/device_wizard/wizard.py index 862e9c8..159a50d 100644 --- a/qt_ui/device_wizard/wizard.py +++ b/qt_ui/device_wizard/wizard.py @@ -9,7 +9,10 @@ from qt_ui.device_wizard.waveform_select import WizardPageWaveformType from qt_ui.device_wizard.safety_limits import WizardPageSafetyLimits from qt_ui.device_wizard.neostim_waveform_select import WizardPageNeoStimWaveformSelect +from qt_ui.device_wizard.coyote_waveform_select import WizardPageCoyoteWaveformSelect from qt_ui.device_wizard.enums import DeviceType, WaveformType, DeviceConfiguration +from qt_ui.settings import device_config_waveform_amplitude_amps +from device.coyote import constants as coyote_constants logger = logging.getLogger('restim.device_wizard') @@ -22,6 +25,7 @@ class WizardPage(Enum): Page_limits_foc = 6 Page_neostim_waveform = 4 Page_focstim_waveform = 5 + Page_coyote_waveform = 7 class DeviceSelectionWizard(QWizard): @@ -48,6 +52,9 @@ def __init__(self, parent=None): self.setPage(WizardPage.Page_neostim_waveform.value, self.page_neostim_waveform_select) self.page_focstim_waveform_select = WizardPageFocStimWaveformSelect() self.setPage(WizardPage.Page_focstim_waveform.value, self.page_focstim_waveform_select) + self.page_coyote_waveform_select = WizardPageCoyoteWaveformSelect() + self.page_coyote_waveform_select.setFinalPage(True) + self.setPage(WizardPage.Page_coyote_waveform.value, self.page_coyote_waveform_select) self.set_configuration(DeviceConfiguration.from_settings()) @@ -76,6 +83,8 @@ def nextId(self): return WizardPage.Page_focstim_waveform.value elif self.page_device_type.neostim_radio.isChecked(): return WizardPage.Page_neostim_waveform.value + elif self.page_device_type.coyote_radio.isChecked(): + return WizardPage.Page_coyote_waveform.value else: raise RuntimeError("unknown device type") @@ -96,6 +105,8 @@ def validateCurrentPage(self) -> bool: pass elif self.page_device_type.neostim_radio.isChecked(): pass + elif self.page_device_type.coyote_radio.isChecked(): + pass return super(DeviceSelectionWizard, self).validateCurrentPage() @@ -146,6 +157,14 @@ def get_configuration(self) -> DeviceConfiguration: None, None, None ) + elif self.page_device_type.coyote_radio.isChecked(): + return DeviceConfiguration( + DeviceType.COYOTE_THREE_PHASE, + WaveformType.PULSE_BASED, + coyote_constants.HARDWARE_MIN_FREQ_HZ, + coyote_constants.HARDWARE_MAX_FREQ_HZ, + None + ) else: assert(False) @@ -160,6 +179,8 @@ def set_configuration(self, config: DeviceConfiguration): self.page_focstim_waveform_select.four_phase_radio.setChecked(True) if config.device_type == DeviceType.NEOSTIM_THREE_PHASE: self.page_device_type.neostim_radio.setChecked(True) + if config.device_type == DeviceType.COYOTE_THREE_PHASE: + self.page_device_type.coyote_radio.setChecked(True) self.page_waveform_type.continuous_radio.setChecked(config.waveform_type == WaveformType.CONTINUOUS) self.page_waveform_type.pulse_based_radio.setChecked(config.waveform_type == WaveformType.PULSE_BASED) diff --git a/qt_ui/main_window_ui.py b/qt_ui/main_window_ui.py index 3649d62..e8d229f 100644 --- a/qt_ui/main_window_ui.py +++ b/qt_ui/main_window_ui.py @@ -23,6 +23,7 @@ from qt_ui.ab_test_widget import ABTestWidget from qt_ui.carrier_settings_widget import CarrierSettingsWidget +from qt_ui.coyote_settings_widget import CoyoteSettingsWidget from qt_ui.four_phase_settings_widget import FourPhaseSettingsWidget from qt_ui.media_settings_widget import MediaSettingsWidget from qt_ui.neostim_settings_widget import NeoStimSettingsWidget @@ -205,6 +206,9 @@ def setupUi(self, MainWindow): self.tab_carrier = CarrierSettingsWidget() self.tab_carrier.setObjectName(u"tab_carrier") self.tabWidget.addTab(self.tab_carrier, "") + self.tab_coyote = CoyoteSettingsWidget() + self.tab_coyote.setObjectName(u"tab_coyote") + self.tabWidget.addTab(self.tab_coyote, "") self.tab_pulse_settings = PulseSettingsWidget() self.tab_pulse_settings.setObjectName(u"tab_pulse_settings") self.tabWidget.addTab(self.tab_pulse_settings, "") @@ -308,6 +312,7 @@ def retranslateUi(self, MainWindow): self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_threephase), QCoreApplication.translate("MainWindow", u"3-phase", None)) self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_fourphase), QCoreApplication.translate("MainWindow", u"4-phase", None)) self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_carrier), QCoreApplication.translate("MainWindow", u"Carrier settings", None)) + self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_coyote), QCoreApplication.translate("MainWindow", u"Coyote", None)) self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_pulse_settings), QCoreApplication.translate("MainWindow", u"Pulse settings", None)) self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_neostim), QCoreApplication.translate("MainWindow", u"NeoStim", None)) self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_a_b_testing), QCoreApplication.translate("MainWindow", u"A/B testing", None)) diff --git a/qt_ui/mainwindow.py b/qt_ui/mainwindow.py index 6adf27c..7db5aab 100644 --- a/qt_ui/mainwindow.py +++ b/qt_ui/mainwindow.py @@ -31,6 +31,8 @@ from qt_ui.models.funscript_kit import FunscriptKitModel from device.focstim.proto_device import FOCStimProtoDevice from device.neostim.neostim_device import NeoStim +from device.coyote.device import CoyoteDevice, CoyoteParams +from device.coyote.constants import DEVICE_NAME from qt_ui.widgets.icon_with_connection_status import IconWithConnectionStatus from stim_math.axis import create_temporal_axis @@ -349,7 +351,8 @@ def set_visible(widget, state): self.tab_vibrate, self.tab_details, self.tab_a_b_testing, - self.tab_neostim} + self.tab_neostim, + self.tab_coyote} visible = {self.tab_threephase, self.tab_volume, self.tab_vibrate, self.tab_details} @@ -372,6 +375,9 @@ def set_visible(widget, state): if config.device_type == DeviceType.NEOSTIM_THREE_PHASE: visible |= {self.tab_neostim} visible -= {self.tab_vibrate, self.tab_details} + if config.device_type == DeviceType.COYOTE_THREE_PHASE: + visible |= {self.tab_coyote, self.tab_pulse_settings} + visible -= {self.tab_vibrate} for tab in all_tabs: set_visible(tab, tab in visible) @@ -388,7 +394,7 @@ def set_visible(widget, state): self.tcode_command_router.set_carrier_axis(self.tab_pulse_settings.axis_carrier_frequency) # populate motion generator and patterns combobox - if config.device_type in (DeviceType.AUDIO_THREE_PHASE, DeviceType.NEOSTIM_THREE_PHASE, DeviceType.FOCSTIM_THREE_PHASE): + if config.device_type in (DeviceType.AUDIO_THREE_PHASE, DeviceType.NEOSTIM_THREE_PHASE, DeviceType.FOCSTIM_THREE_PHASE, DeviceType.COYOTE_THREE_PHASE): self.motion_3.set_enable(True) self.motion_4.set_enable(False) self.stackedWidget_visual.setCurrentIndex( @@ -401,6 +407,20 @@ def set_visible(widget, state): self.stackedWidget_visual.setCurrentIndex( self.stackedWidget_visual.indexOf(self.page_fourphase) ) + + if config.device_type == DeviceType.COYOTE_THREE_PHASE: + self.output_device = CoyoteDevice(DEVICE_NAME) + self.output_device.parameters = CoyoteParams( + channel_a_limit=qt_ui.settings.coyote_channel_a_limit.get(), + channel_b_limit=qt_ui.settings.coyote_channel_b_limit.get(), + channel_a_freq_balance=qt_ui.settings.coyote_channel_a_freq_balance.get(), + channel_b_freq_balance=qt_ui.settings.coyote_channel_b_freq_balance.get(), + channel_a_intensity_balance=qt_ui.settings.coyote_channel_a_intensity_balance.get(), + channel_b_intensity_balance=qt_ui.settings.coyote_channel_b_intensity_balance.get() + ) + self.tab_coyote.setup_device(self.output_device) + + self.refresh_pattern_combobox() self.refresh_pattern_combobox() @@ -416,10 +436,11 @@ def signal_start_stop(self): self.signal_stop(PlayState.STOPPED) def signal_start(self): - assert self.output_device is None - self.autostart_timer.stop() device = DeviceConfiguration.from_settings() + + assert (self.output_device is None or device.device_type == DeviceType.COYOTE_THREE_PHASE) + algorithm_factory = AlgorithmFactory( self, FunscriptKitModel.load_from_settings(), @@ -474,13 +495,25 @@ def signal_start(self): self.playstate = PlayState.PLAYING self.tab_volume.set_play_state(self.playstate) self.refresh_play_button_icon() + elif device.device_type == DeviceType.COYOTE_THREE_PHASE: + if not self.output_device: + logger.warning("Coyote device is no longer initialized") + return + + self.output_device.start_updates(algorithm) + self.playstate = PlayState.PLAYING + self.refresh_play_button_icon() else: raise RuntimeError("Unknown device type") def signal_stop(self, new_playstate: PlayState = PlayState.STOPPED): + """Stop signal generation.""" if self.output_device is not None: - self.output_device.stop() - self.output_device = None + if isinstance(self.output_device, CoyoteDevice): + self.output_device.stop_updates() # Only stop sending to device + else: + self.output_device.stop() # Other devices may need full stop + self.output_device = None self.playstate = new_playstate self.tab_volume.set_play_state(self.playstate) self.refresh_play_button_icon() @@ -551,7 +584,7 @@ def refresh_pattern_combobox(self): config = DeviceConfiguration.from_settings() currently_selected_text = self.comboBox_patternSelect.currentText() - if config.device_type in (DeviceType.AUDIO_THREE_PHASE, DeviceType.NEOSTIM_THREE_PHASE, DeviceType.FOCSTIM_THREE_PHASE): + if config.device_type in (DeviceType.AUDIO_THREE_PHASE, DeviceType.NEOSTIM_THREE_PHASE, DeviceType.FOCSTIM_THREE_PHASE, DeviceType.COYOTE_THREE_PHASE): self.comboBox_patternSelect.clear() for pattern in self.motion_3.patterns: self.comboBox_patternSelect.addItem(pattern.name(), pattern) diff --git a/qt_ui/preferences_dialog.py b/qt_ui/preferences_dialog.py index 2a4edab..c8dee7e 100644 --- a/qt_ui/preferences_dialog.py +++ b/qt_ui/preferences_dialog.py @@ -24,6 +24,9 @@ def __init__(self, parent=None): self.tabWidget.setCurrentIndex(0) + self._coyote_logger = logging.getLogger('restim.coyote') + self._coyote_default_log_level = self._coyote_logger.getEffectiveLevel() + # Initialize pattern service and cache pattern data immediately self.pattern_service = PatternControlService() self._cached_patterns = None @@ -151,6 +154,19 @@ def loadSettings(self): # neostim settings self.neostim_port.setCurrentIndex(self.neostim_port.findData(qt_ui.settings.neostim_serial_port.get())) + # Coyote 3 + self.coyote_channel_a_limit.setValue(qt_ui.settings.coyote_channel_a_limit.get()) + self.coyote_channel_b_limit.setValue(qt_ui.settings.coyote_channel_b_limit.get()) + self.coyote_channel_a_freq_balance.setValue(qt_ui.settings.coyote_channel_a_freq_balance.get()) + self.coyote_channel_b_freq_balance.setValue(qt_ui.settings.coyote_channel_b_freq_balance.get()) + self.coyote_channel_a_intensity_balance.setValue(qt_ui.settings.coyote_channel_a_intensity_balance.get()) + self.coyote_channel_b_intensity_balance.setValue(qt_ui.settings.coyote_channel_b_intensity_balance.get()) + self.coyote_max_intensity_change_per_pulse.setValue( + qt_ui.settings.coyote_max_intensity_change_per_pulse.get() + ) + self.coyote_graph_window.setValue(qt_ui.settings.coyote_graph_window.get()) + self.coyote_debug_logging.setChecked(qt_ui.settings.coyote_debug_logging.get()) + # media sync settings self.mpc_address.setText(qt_ui.settings.media_sync_mpc_address.get()) self.heresphere_address.setText(qt_ui.settings.media_sync_heresphere_address.get()) @@ -169,6 +185,8 @@ def loadSettings(self): # refresh pattern preferences (just reload checkboxes from settings) self.refresh_pattern_preferences() + self.apply_coyote_logging() + def repopulate_audio_devices(self): self.audio_output_device.clear() default_audio_output_device_name = qt_ui.settings.audio_output_device.get() @@ -313,6 +331,19 @@ def saveSettings(self): # neoStim qt_ui.settings.neostim_serial_port.set(str(self.neostim_port.currentData())) + # Coyote 3 + qt_ui.settings.coyote_channel_a_limit.set(self.coyote_channel_a_limit.value()) + qt_ui.settings.coyote_channel_b_limit.set(self.coyote_channel_b_limit.value()) + qt_ui.settings.coyote_channel_a_freq_balance.set(self.coyote_channel_a_freq_balance.value()) + qt_ui.settings.coyote_channel_b_freq_balance.set(self.coyote_channel_b_freq_balance.value()) + qt_ui.settings.coyote_channel_a_intensity_balance.set(self.coyote_channel_a_intensity_balance.value()) + qt_ui.settings.coyote_channel_b_intensity_balance.set(self.coyote_channel_b_intensity_balance.value()) + qt_ui.settings.coyote_max_intensity_change_per_pulse.set( + self.coyote_max_intensity_change_per_pulse.value() + ) + qt_ui.settings.coyote_graph_window.set(self.coyote_graph_window.value()) + qt_ui.settings.coyote_debug_logging.set(self.coyote_debug_logging.isChecked()) + # media sync settings qt_ui.settings.media_sync_mpc_address.set(self.mpc_address.text()) qt_ui.settings.media_sync_heresphere_address.set(self.heresphere_address.text()) @@ -340,6 +371,13 @@ def saveSettings(self): if was_enabled != is_enabled: self.pattern_service.set_pattern_enabled(pattern_name, is_enabled) + self.apply_coyote_logging() + + def apply_coyote_logging(self): + enabled = qt_ui.settings.coyote_debug_logging.get() + new_level = logging.DEBUG if enabled else logging.INFO + self._coyote_logger.setLevel(new_level) + def funscript_reset_defaults(self): self.tableView.model().reset_to_defaults() @@ -417,4 +455,3 @@ def disable_all_patterns(self): widget = self.patterns_table.cellWidget(row, 1) if isinstance(widget, QCheckBox): widget.setChecked(False) - diff --git a/qt_ui/preferences_dialog_ui.py b/qt_ui/preferences_dialog_ui.py index 8768ac4..cc99980 100644 --- a/qt_ui/preferences_dialog_ui.py +++ b/qt_ui/preferences_dialog_ui.py @@ -314,11 +314,6 @@ def setupUi(self, PreferencesDialog): self.groupBox_10.setObjectName(u"groupBox_10") self.gridLayout_7 = QGridLayout(self.groupBox_10) self.gridLayout_7.setObjectName(u"gridLayout_7") - self.label_22 = QLabel(self.groupBox_10) - self.label_22.setObjectName(u"label_22") - - self.gridLayout_7.addWidget(self.label_22, 3, 0, 1, 1) - self.focstim_ssid = QLineEdit(self.groupBox_10) self.focstim_ssid.setObjectName(u"focstim_ssid") @@ -334,11 +329,6 @@ def setupUi(self, PreferencesDialog): self.gridLayout_7.addWidget(self.label_21, 1, 0, 1, 1) - self.focstim_read_ip = QToolButton(self.groupBox_10) - self.focstim_read_ip.setObjectName(u"focstim_read_ip") - - self.gridLayout_7.addWidget(self.focstim_read_ip, 3, 2, 1, 1) - self.focstim_password = QLineEdit(self.groupBox_10) self.focstim_password.setObjectName(u"focstim_password") @@ -352,7 +342,17 @@ def setupUi(self, PreferencesDialog): self.focstim_ip = QLineEdit(self.groupBox_10) self.focstim_ip.setObjectName(u"focstim_ip") - self.gridLayout_7.addWidget(self.focstim_ip, 3, 1, 1, 1) + self.gridLayout_7.addWidget(self.focstim_ip, 4, 1, 1, 1) + + self.label_22 = QLabel(self.groupBox_10) + self.label_22.setObjectName(u"label_22") + + self.gridLayout_7.addWidget(self.label_22, 4, 0, 1, 1) + + self.focstim_read_ip = QToolButton(self.groupBox_10) + self.focstim_read_ip.setObjectName(u"focstim_read_ip") + + self.gridLayout_7.addWidget(self.focstim_read_ip, 4, 2, 1, 1) self.verticalLayout_5.addWidget(self.groupBox_10) @@ -361,15 +361,15 @@ def setupUi(self, PreferencesDialog): self.groupBox_9.setObjectName(u"groupBox_9") self.formLayout_8 = QFormLayout(self.groupBox_9) self.formLayout_8.setObjectName(u"formLayout_8") - self.label_18 = QLabel(self.groupBox_9) - self.label_18.setObjectName(u"label_18") + self.label_15 = QLabel(self.groupBox_9) + self.label_15.setObjectName(u"label_15") - self.formLayout_8.setWidget(2, QFormLayout.ItemRole.LabelRole, self.label_18) + self.formLayout_8.setWidget(0, QFormLayout.ItemRole.LabelRole, self.label_15) - self.focstim_dump_notifications = QCheckBox(self.groupBox_9) - self.focstim_dump_notifications.setObjectName(u"focstim_dump_notifications") + self.focstim_use_teleplot = QCheckBox(self.groupBox_9) + self.focstim_use_teleplot.setObjectName(u"focstim_use_teleplot") - self.formLayout_8.setWidget(2, QFormLayout.ItemRole.FieldRole, self.focstim_dump_notifications) + self.formLayout_8.setWidget(0, QFormLayout.ItemRole.FieldRole, self.focstim_use_teleplot) self.label_16 = QLabel(self.groupBox_9) self.label_16.setObjectName(u"label_16") @@ -381,15 +381,15 @@ def setupUi(self, PreferencesDialog): self.formLayout_8.setWidget(1, QFormLayout.ItemRole.FieldRole, self.focstim_teleplot_prefix) - self.label_15 = QLabel(self.groupBox_9) - self.label_15.setObjectName(u"label_15") + self.label_18 = QLabel(self.groupBox_9) + self.label_18.setObjectName(u"label_18") - self.formLayout_8.setWidget(0, QFormLayout.ItemRole.LabelRole, self.label_15) + self.formLayout_8.setWidget(2, QFormLayout.ItemRole.LabelRole, self.label_18) - self.focstim_use_teleplot = QCheckBox(self.groupBox_9) - self.focstim_use_teleplot.setObjectName(u"focstim_use_teleplot") + self.focstim_dump_notifications = QCheckBox(self.groupBox_9) + self.focstim_dump_notifications.setObjectName(u"focstim_dump_notifications") - self.formLayout_8.setWidget(0, QFormLayout.ItemRole.FieldRole, self.focstim_use_teleplot) + self.formLayout_8.setWidget(2, QFormLayout.ItemRole.FieldRole, self.focstim_dump_notifications) self.verticalLayout_5.addWidget(self.groupBox_9) @@ -432,6 +432,144 @@ def setupUi(self, PreferencesDialog): self.verticalLayout_8.addItem(self.verticalSpacer_5) self.tabWidget.addTab(self.tab_neostim, "") + self.tab_coyote = QWidget() + self.tab_coyote.setObjectName(u"tab_coyote") + self.verticalLayout_coyote = QVBoxLayout(self.tab_coyote) + self.verticalLayout_coyote.setObjectName(u"verticalLayout_coyote") + self.groupBox_coyote_params = QGroupBox(self.tab_coyote) + self.groupBox_coyote_params.setObjectName(u"groupBox_coyote_params") + self.formLayout_coyote_params = QFormLayout(self.groupBox_coyote_params) + self.formLayout_coyote_params.setObjectName(u"formLayout_coyote_params") + self.label_coyote_channel_a_limit = QLabel(self.groupBox_coyote_params) + self.label_coyote_channel_a_limit.setObjectName(u"label_coyote_channel_a_limit") + + self.formLayout_coyote_params.setWidget(0, QFormLayout.ItemRole.LabelRole, self.label_coyote_channel_a_limit) + + self.coyote_channel_a_limit = QSpinBox(self.groupBox_coyote_params) + self.coyote_channel_a_limit.setObjectName(u"coyote_channel_a_limit") + self.coyote_channel_a_limit.setMinimum(0) + self.coyote_channel_a_limit.setMaximum(200) + + self.formLayout_coyote_params.setWidget(0, QFormLayout.ItemRole.FieldRole, self.coyote_channel_a_limit) + + self.label_coyote_channel_b_limit = QLabel(self.groupBox_coyote_params) + self.label_coyote_channel_b_limit.setObjectName(u"label_coyote_channel_b_limit") + + self.formLayout_coyote_params.setWidget(1, QFormLayout.ItemRole.LabelRole, self.label_coyote_channel_b_limit) + + self.coyote_channel_b_limit = QSpinBox(self.groupBox_coyote_params) + self.coyote_channel_b_limit.setObjectName(u"coyote_channel_b_limit") + self.coyote_channel_b_limit.setMinimum(0) + self.coyote_channel_b_limit.setMaximum(200) + + self.formLayout_coyote_params.setWidget(1, QFormLayout.ItemRole.FieldRole, self.coyote_channel_b_limit) + + self.label_coyote_channel_a_freq_balance = QLabel(self.groupBox_coyote_params) + self.label_coyote_channel_a_freq_balance.setObjectName(u"label_coyote_channel_a_freq_balance") + + self.formLayout_coyote_params.setWidget(2, QFormLayout.ItemRole.LabelRole, self.label_coyote_channel_a_freq_balance) + + self.coyote_channel_a_freq_balance = QSpinBox(self.groupBox_coyote_params) + self.coyote_channel_a_freq_balance.setObjectName(u"coyote_channel_a_freq_balance") + self.coyote_channel_a_freq_balance.setMinimum(0) + self.coyote_channel_a_freq_balance.setMaximum(255) + + self.formLayout_coyote_params.setWidget(2, QFormLayout.ItemRole.FieldRole, self.coyote_channel_a_freq_balance) + + self.label_coyote_channel_b_freq_balance = QLabel(self.groupBox_coyote_params) + self.label_coyote_channel_b_freq_balance.setObjectName(u"label_coyote_channel_b_freq_balance") + + self.formLayout_coyote_params.setWidget(3, QFormLayout.ItemRole.LabelRole, self.label_coyote_channel_b_freq_balance) + + self.coyote_channel_b_freq_balance = QSpinBox(self.groupBox_coyote_params) + self.coyote_channel_b_freq_balance.setObjectName(u"coyote_channel_b_freq_balance") + self.coyote_channel_b_freq_balance.setMinimum(0) + self.coyote_channel_b_freq_balance.setMaximum(255) + + self.formLayout_coyote_params.setWidget(3, QFormLayout.ItemRole.FieldRole, self.coyote_channel_b_freq_balance) + + self.label_coyote_channel_a_intensity_balance = QLabel(self.groupBox_coyote_params) + self.label_coyote_channel_a_intensity_balance.setObjectName(u"label_coyote_channel_a_intensity_balance") + + self.formLayout_coyote_params.setWidget(4, QFormLayout.ItemRole.LabelRole, self.label_coyote_channel_a_intensity_balance) + + self.coyote_channel_a_intensity_balance = QSpinBox(self.groupBox_coyote_params) + self.coyote_channel_a_intensity_balance.setObjectName(u"coyote_channel_a_intensity_balance") + self.coyote_channel_a_intensity_balance.setMinimum(0) + self.coyote_channel_a_intensity_balance.setMaximum(255) + + self.formLayout_coyote_params.setWidget(4, QFormLayout.ItemRole.FieldRole, self.coyote_channel_a_intensity_balance) + + self.label_coyote_channel_b_intensity_balance = QLabel(self.groupBox_coyote_params) + self.label_coyote_channel_b_intensity_balance.setObjectName(u"label_coyote_channel_b_intensity_balance") + + self.formLayout_coyote_params.setWidget(5, QFormLayout.ItemRole.LabelRole, self.label_coyote_channel_b_intensity_balance) + + self.coyote_channel_b_intensity_balance = QSpinBox(self.groupBox_coyote_params) + self.coyote_channel_b_intensity_balance.setObjectName(u"coyote_channel_b_intensity_balance") + self.coyote_channel_b_intensity_balance.setMinimum(0) + self.coyote_channel_b_intensity_balance.setMaximum(255) + + self.formLayout_coyote_params.setWidget(5, QFormLayout.ItemRole.FieldRole, self.coyote_channel_b_intensity_balance) + + + self.verticalLayout_coyote.addWidget(self.groupBox_coyote_params) + + self.groupBox_coyote_algorithm = QGroupBox(self.tab_coyote) + self.groupBox_coyote_algorithm.setObjectName(u"groupBox_coyote_algorithm") + self.formLayout_coyote_algorithm = QFormLayout(self.groupBox_coyote_algorithm) + self.formLayout_coyote_algorithm.setObjectName(u"formLayout_coyote_algorithm") + self.label_coyote_max_intensity_change_per_pulse = QLabel(self.groupBox_coyote_algorithm) + self.label_coyote_max_intensity_change_per_pulse.setObjectName(u"label_coyote_max_intensity_change_per_pulse") + + self.formLayout_coyote_algorithm.setWidget(0, QFormLayout.ItemRole.LabelRole, self.label_coyote_max_intensity_change_per_pulse) + + self.coyote_max_intensity_change_per_pulse = QDoubleSpinBox(self.groupBox_coyote_algorithm) + self.coyote_max_intensity_change_per_pulse.setObjectName(u"coyote_max_intensity_change_per_pulse") + self.coyote_max_intensity_change_per_pulse.setDecimals(1) + self.coyote_max_intensity_change_per_pulse.setMinimum(0.000000000000000) + self.coyote_max_intensity_change_per_pulse.setMaximum(100.000000000000000) + self.coyote_max_intensity_change_per_pulse.setSingleStep(0.100000000000000) + self.coyote_max_intensity_change_per_pulse.setValue(3.000000000000000) + + self.formLayout_coyote_algorithm.setWidget(0, QFormLayout.ItemRole.FieldRole, self.coyote_max_intensity_change_per_pulse) + + + self.verticalLayout_coyote.addWidget(self.groupBox_coyote_algorithm) + + self.groupBox_coyote_display = QGroupBox(self.tab_coyote) + self.groupBox_coyote_display.setObjectName(u"groupBox_coyote_display") + self.formLayout_coyote_display = QFormLayout(self.groupBox_coyote_display) + self.formLayout_coyote_display.setObjectName(u"formLayout_coyote_display") + self.label_coyote_graph_window = QLabel(self.groupBox_coyote_display) + self.label_coyote_graph_window.setObjectName(u"label_coyote_graph_window") + + self.formLayout_coyote_display.setWidget(0, QFormLayout.ItemRole.LabelRole, self.label_coyote_graph_window) + + self.coyote_graph_window = QDoubleSpinBox(self.groupBox_coyote_display) + self.coyote_graph_window.setObjectName(u"coyote_graph_window") + self.coyote_graph_window.setDecimals(1) + self.coyote_graph_window.setMinimum(0.100000000000000) + self.coyote_graph_window.setMaximum(10.000000000000000) + self.coyote_graph_window.setSingleStep(0.100000000000000) + self.coyote_graph_window.setValue(3.000000000000000) + + self.formLayout_coyote_display.setWidget(0, QFormLayout.ItemRole.FieldRole, self.coyote_graph_window) + + self.label_coyote_debug_logging = QLabel(self.groupBox_coyote_display) + self.label_coyote_debug_logging.setObjectName(u"label_coyote_debug_logging") + + self.formLayout_coyote_display.setWidget(1, QFormLayout.ItemRole.LabelRole, self.label_coyote_debug_logging) + + self.coyote_debug_logging = QCheckBox(self.groupBox_coyote_display) + self.coyote_debug_logging.setObjectName(u"coyote_debug_logging") + + self.formLayout_coyote_display.setWidget(1, QFormLayout.ItemRole.FieldRole, self.coyote_debug_logging) + + + self.verticalLayout_coyote.addWidget(self.groupBox_coyote_display) + + self.tabWidget.addTab(self.tab_coyote, "") self.tab_media_settings = QWidget() self.tab_media_settings.setObjectName(u"tab_media_settings") self.verticalLayout_6 = QVBoxLayout(self.tab_media_settings) @@ -690,9 +828,7 @@ def setupUi(self, PreferencesDialog): QWidget.setTabOrder(self.focstim_refresh_serial_devices, self.focstim_ssid) QWidget.setTabOrder(self.focstim_ssid, self.focstim_password) QWidget.setTabOrder(self.focstim_password, self.focstim_sync) - QWidget.setTabOrder(self.focstim_sync, self.focstim_ip) - QWidget.setTabOrder(self.focstim_ip, self.focstim_read_ip) - QWidget.setTabOrder(self.focstim_read_ip, self.focstim_use_teleplot) + QWidget.setTabOrder(self.focstim_sync, self.focstim_use_teleplot) QWidget.setTabOrder(self.focstim_use_teleplot, self.focstim_teleplot_prefix) QWidget.setTabOrder(self.focstim_teleplot_prefix, self.focstim_dump_notifications) QWidget.setTabOrder(self.focstim_dump_notifications, self.tcp_port) @@ -794,25 +930,39 @@ def retranslateUi(self, PreferencesDialog): self.label_14.setText(QCoreApplication.translate("PreferencesDialog", u"Serial port", None)) self.focstim_refresh_serial_devices.setText(QCoreApplication.translate("PreferencesDialog", u"Refresh", None)) self.groupBox_10.setTitle(QCoreApplication.translate("PreferencesDialog", u"Network", None)) - self.label_22.setText(QCoreApplication.translate("PreferencesDialog", u"IP", None)) - self.focstim_sync.setText(QCoreApplication.translate("PreferencesDialog", u"Sync with device", None)) + self.focstim_sync.setText(QCoreApplication.translate("PreferencesDialog", u"Upload ssid/password", None)) self.label_21.setText(QCoreApplication.translate("PreferencesDialog", u"Password", None)) - self.focstim_read_ip.setText(QCoreApplication.translate("PreferencesDialog", u"Read from device", None)) self.label_20.setText(QCoreApplication.translate("PreferencesDialog", u"SSID", None)) + self.label_22.setText(QCoreApplication.translate("PreferencesDialog", u"IP", None)) + self.focstim_read_ip.setText(QCoreApplication.translate("PreferencesDialog", u"Read from device", None)) self.groupBox_9.setTitle(QCoreApplication.translate("PreferencesDialog", u"Advanced", None)) - self.label_18.setText(QCoreApplication.translate("PreferencesDialog", u"Dump notifications to file", None)) - self.focstim_dump_notifications.setText("") + self.label_15.setText(QCoreApplication.translate("PreferencesDialog", u"Use teleplot", None)) + self.focstim_use_teleplot.setText("") #if QT_CONFIG(tooltip) self.label_16.setToolTip(QCoreApplication.translate("PreferencesDialog", u"Useful if you have multiple FOC-Stim boxes", None)) #endif // QT_CONFIG(tooltip) self.label_16.setText(QCoreApplication.translate("PreferencesDialog", u"teleplot prefix (?)", None)) - self.label_15.setText(QCoreApplication.translate("PreferencesDialog", u"Use teleplot", None)) - self.focstim_use_teleplot.setText("") + self.label_18.setText(QCoreApplication.translate("PreferencesDialog", u"Dump notifications to file", None)) + self.focstim_dump_notifications.setText("") self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_foc), QCoreApplication.translate("PreferencesDialog", u"FOC-Stim", None)) self.groupBox_4.setTitle(QCoreApplication.translate("PreferencesDialog", u"NeoStim", None)) self.neostim_refresh_serial_devices.setText(QCoreApplication.translate("PreferencesDialog", u"Refresh", None)) self.label_17.setText(QCoreApplication.translate("PreferencesDialog", u"Serial port", None)) self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_neostim), QCoreApplication.translate("PreferencesDialog", u"NeoStim", None)) + self.groupBox_coyote_params.setTitle(QCoreApplication.translate("PreferencesDialog", u"Device", None)) + self.label_coyote_channel_a_limit.setText(QCoreApplication.translate("PreferencesDialog", u"Channel A Limit", None)) + self.label_coyote_channel_b_limit.setText(QCoreApplication.translate("PreferencesDialog", u"Channel B Limit", None)) + self.label_coyote_channel_a_freq_balance.setText(QCoreApplication.translate("PreferencesDialog", u"Channel A Freq Balance", None)) + self.label_coyote_channel_b_freq_balance.setText(QCoreApplication.translate("PreferencesDialog", u"Channel B Freq Balance", None)) + self.label_coyote_channel_a_intensity_balance.setText(QCoreApplication.translate("PreferencesDialog", u"Channel A Intensity Balance", None)) + self.label_coyote_channel_b_intensity_balance.setText(QCoreApplication.translate("PreferencesDialog", u"Channel B Intensity Balance", None)) + self.groupBox_coyote_algorithm.setTitle(QCoreApplication.translate("PreferencesDialog", u"Algorithm", None)) + self.label_coyote_max_intensity_change_per_pulse.setText(QCoreApplication.translate("PreferencesDialog", u"Max Intensity Change per Pulse (%)", None)) + self.groupBox_coyote_display.setTitle(QCoreApplication.translate("PreferencesDialog", u"Display", None)) + self.label_coyote_graph_window.setText(QCoreApplication.translate("PreferencesDialog", u"Graph Window (s)", None)) + self.label_coyote_debug_logging.setText(QCoreApplication.translate("PreferencesDialog", u"Debug Logging", None)) + self.coyote_debug_logging.setText("") + self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_coyote), QCoreApplication.translate("PreferencesDialog", u"Coyote", None)) self.groupBox_3.setTitle(QCoreApplication.translate("PreferencesDialog", u"MPC-HC", None)) self.label_31.setText(QCoreApplication.translate("PreferencesDialog", u"address:port", None)) self.mpc_reload.setText(QCoreApplication.translate("PreferencesDialog", u"...", None)) diff --git a/qt_ui/settings.py b/qt_ui/settings.py index 1ffbcbe..21ff739 100644 --- a/qt_ui/settings.py +++ b/qt_ui/settings.py @@ -166,6 +166,29 @@ def set(self, value): neostim_serial_port = Setting("neostim/serial_port", '', str) +coyote_channel_a_limit = Setting("coyote/channel_a_limit", 200, int) +coyote_channel_b_limit = Setting("coyote/channel_b_limit", 200, int) +coyote_channel_a_freq_balance = Setting("coyote/channel_a_freq_balance", 160, int) +coyote_channel_b_freq_balance = Setting("coyote/channel_b_freq_balance", 160, int) +coyote_channel_a_intensity_balance = Setting("coyote/channel_a_intensity_balance", 0, int) +coyote_channel_b_intensity_balance = Setting("coyote/channel_b_intensity_balance", 0, int) +coyote_channel_a_strength_max = Setting("coyote/channel_a_strength_max", 50, int) +coyote_channel_a_freq_min = Setting("coyote/channel_a_freq_min", 70, int) +coyote_channel_a_freq_max = Setting("coyote/channel_a_freq_max", 100, int) +coyote_channel_b_strength_max = Setting("coyote/channel_b_strength_max", 50, int) +coyote_channel_b_freq_min = Setting("coyote/channel_b_freq_min", 30, int) +coyote_channel_b_freq_max = Setting("coyote/channel_b_freq_max", 60, int) +coyote_max_intensity_change_per_pulse = Setting("coyote/max_intensity_change_per_pulse", 1.0, float) +coyote_debug_logging = Setting("coyote/debug_logging", False, bool) +coyote_graph_window = Setting("coyote/graph_window", 3.0, float) +coyote_queue_horizon_seconds = Setting("coyote/queue_horizon_seconds", 0.75, float) +coyote_packet_margin = Setting("coyote/packet_margin", 0.8, float) +coyote_texture_min_hz = Setting("coyote/texture_min_hz", 0.5, float) +coyote_texture_max_hz = Setting("coyote/texture_max_hz", 5.0, float) +coyote_texture_depth_fraction = Setting("coyote/texture_depth_fraction", 0.5, float) +coyote_jitter_limit_fraction = Setting("coyote/jitter_limit_fraction", 0.5, float) +coyote_residual_bound = Setting("coyote/residual_bound", 0.49, float) + # Pattern preferences - we'll store this as a JSON string and convert to dict import json diff --git a/requirements.txt b/requirements.txt index b810596..13a7abb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,4 +9,5 @@ msdparser # protoletariat # dev only protobuf>=6.0.0 pystream-protobuf -stm32loader @ git+https://github.com/diglet48/stm32loader@feat/device-table \ No newline at end of file +bleak # Coyote only +stm32loader @ git+https://github.com/diglet48/stm32loader@feat/device-table diff --git a/stim_math/audio_gen/params.py b/stim_math/audio_gen/params.py index a84c1aa..8234170 100644 --- a/stim_math/audio_gen/params.py +++ b/stim_math/audio_gen/params.py @@ -170,6 +170,34 @@ class NeoStimParams: debug: AbstractAxis # NeoStimDebugSettings + +from qt_ui import settings + +@dataclass +class CoyoteChannelParams: + minimum_frequency: settings.Setting + maximum_frequency: settings.Setting + maximum_strength: settings.Setting + vibration: VibrationParams # TODO: modulate channel A/B freq + +@dataclass +class CoyoteAlgorithmParams: + position: ThreephasePositionParams + transform: ThreephasePositionTransformParams + calibrate: ThreephaseCalibrationParams + volume: VolumeParams + carrier_frequency: AbstractAxis # Hz + pulse_frequency: AbstractAxis # Hz + pulse_width: AbstractAxis # carrier cycles + pulse_interval_random: AbstractAxis + pulse_rise_time: AbstractAxis + max_intensity_change_per_pulse: settings.Setting + + channel_a: CoyoteChannelParams + channel_b: CoyoteChannelParams + + + @dataclass class SafetyParams: minimum_carrier_frequency: float