From 28646f134acbc68ca7e1cdb6f431238c91d9c2c9 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Wed, 21 May 2025 19:04:02 +0700 Subject: [PATCH 01/47] Rebase coyote on master --- README.md | 1 + designer/device_wizard/type_select.ui | 23 +- designer/mainwindow.ui | 11 + designer/preferencesdialog.ui | 133 +++ device/coyote/algorithm.py | 906 +++++++++++++++++++ device/coyote/device.py | 469 ++++++++++ qt_ui/algorithm_factory.py | 85 +- qt_ui/coyote_settings_widget.py | 1161 +++++++++++++++++++++++++ qt_ui/device_wizard/enums.py | 1 + qt_ui/device_wizard/type_select.py | 4 +- qt_ui/device_wizard/type_select_ui.py | 14 +- qt_ui/device_wizard/wizard.py | 14 + qt_ui/main_window_ui.py | 5 + qt_ui/mainwindow.py | 42 +- qt_ui/preferences_dialog.py | 18 + qt_ui/preferences_dialog_ui.py | 101 +++ qt_ui/settings.py | 14 + stim_math/audio_gen/params.py | 27 + 18 files changed, 3007 insertions(+), 22 deletions(-) create mode 100644 device/coyote/algorithm.py create mode 100644 device/coyote/device.py create mode 100644 qt_ui/coyote_settings_widget.py diff --git a/README.md b/README.md index 7e3a80c..90f9a28 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 (experimental) ## Main features 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 23a946f..44797d9 100644 --- a/designer/mainwindow.ui +++ b/designer/mainwindow.ui @@ -254,6 +254,11 @@ Carrier settings + + + Coyote + + Pulse settings @@ -491,6 +496,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 5a8d20e..b523134 100644 --- a/designer/preferencesdialog.ui +++ b/designer/preferencesdialog.ui @@ -510,6 +510,139 @@
+ + + Coyote + + + + + + + + Device Name + + + + + + + 47L121000 + + + + + + + + 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 + + + + + + + Media sync diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py new file mode 100644 index 0000000..13f7d1c --- /dev/null +++ b/device/coyote/algorithm.py @@ -0,0 +1,906 @@ +""" +DG-LAB Coyote 3.0 E-Stim Algorithm Implementation + +This algorithm controls a Coyote 3.0 dual-channel e-stim device by emulating the behavior +of the pulse-based audio algorithm (used for traditional audio e-stim) while working +within the constraints of the Coyote hardware protocol. + +NOTE: This algorithm is designed specifically for the Coyote 3.0 device. +Other versions are not supported. + +The Coyote 3.0 is a dual-channel e-stim device with the following characteristics: +- Two independent channels (A and B) +- Each channel accepts pulses with: + - Intensity (0-100%) + - Duration (5-240ms) + - The device plays these pulses sequentially +- Protocol accepts 4 pulses per packet +- Device repeats the last received packet until a new one is sent + +Frequency Handling: +----------------- +Both carrier and pulse frequencies are user-configurable with ranges defined in +funscript configuration and constrained by safety limits: + +- Each frequency has its own user-defined range from funscript configurations +- Carrier frequency (typically 500-1000 Hz) affects pulse durations +- Pulse frequency (typically 0-100 Hz) controls pulse repetition rate + +Their relationship: +- Higher carrier frequencies result in shorter pulse durations (inversely related) +- Carrier frequency also modulates the effective pulse frequency +- Channel-specific frequency limits determine valid pulse duration ranges +- All frequencies are normalized within ranges before being applied + +This approach ensures all values stay within valid ranges and changes to either +frequency produce intuitive results that adapt to user settings. + +Key Differences Between Audio E-Stim and Coyote: +------------------------------------------------ +1. Protocol Constraints: + - Audio: Continuous stream of audio samples (44.1kHz) with full waveform control + - Coyote: Packets of exactly 4 pulses per channel with limited parameter control (intensity, duration) + +2. Parameter Control: + - Audio: Direct control over carrier frequency, pulse width, pulse shape, polarity, etc. + - Coyote: Only control over intensity (0-100%) and duration (5-240ms per pulse) + +3. Timing: + - Audio: Microsecond-level precision with continuous buffer + - Coyote: Packet-based with potential gaps between updates + +Emulation Approach: +------------------ +This algorithm bridges these differences by: + +1. Buffer Abstraction: + - Maintains a FIFO buffer of pulses that abstract away the packet-based nature + - Similar to audio algorithm's sample buffer, but at a higher level + +2. Parameter Mapping: + - Maps pulse-based algorithm parameters to Coyote parameters: + - Carrier and pulse frequencies → Duration (inversely related) + - Alpha/Beta position → Channel intensity split + - Pulse polarity → Inverted envelope value + - Envelope shape → Duration modulation + +3. Timing Management: + - Uses a predictive timing model to request new packets before the + current one completes, ensuring smooth playback + +The result is an algorithm that behaves as similarly as possible to the +pulse-based audio algorithm while working within the hardware constraints. +""" + +import logging +import numpy as np +from collections import deque +from typing import List, Tuple, Dict, Deque +from stim_math.audio_gen.various import ThreePhasePosition +from stim_math.axis import AbstractMediaSync +from device.coyote.device import CoyotePulse, CoyotePulses +from stim_math.audio_gen.params import SafetyParams, CoyoteAlgorithmParams, VolumeParams +from stim_math.threephase import ThreePhaseCenterCalibration +from stim_math import limits +import time + +logger = logging.getLogger('restim.coyote') + +# Protocol constraints +COYOTE_PULSES_PER_PACKET = 4 # Coyote protocol requires exactly 4 pulses per packet +COYOTE_MIN_PULSE_DURATION = 5 # Minimum pulse duration in ms +COYOTE_MAX_PULSE_DURATION = 240 # Maximum pulse duration in ms + +# ===== Channel State Tracking ===== + +class ChannelState: + """ + Tracks the state of a single channel's pulse buffer. + + This class serves a similar purpose to the audio buffer in the pulse-based algorithm, + but at a higher level of abstraction (pulses instead of audio samples). It maintains + a FIFO queue of pulses that are consumed over time, abstracting away the + packet-based nature of the Coyote protocol. + + The buffer is continuously refilled as pulses are consumed, ensuring smooth playback + and allowing for dynamic parameter changes during operation. + """ + def __init__(self, + pulse_buffer: deque, + start_time: float, + elapsed_duration_ms: float, + + min_freq: float, # Minimum frequency in Hz + max_freq: float, # Maximum frequency in Hz + min_duration: int, # Corresponds to max_freq + max_duration: int): # Corresponds to min_freq + self.pulse_buffer = pulse_buffer + self.start_time = start_time + self.elapsed_duration_ms = elapsed_duration_ms + + self.min_freq = min_freq + self.max_freq = max_freq + self.min_duration = min_duration + self.max_duration = max_duration + + # Track last parameters for detecting changes + self.last_pulse_freq = 0.0 + self.last_pulse_width = 0.0 + self.last_pulse_rise_time = 0.0 + + @property + def is_empty(self) -> bool: + """ + Check if this channel's pulse buffer is empty. + + Returns: + bool: True if the buffer has no pulses, False otherwise + """ + return len(self.pulse_buffer) == 0 + + def advance_time(self, elapsed_time_ms: float) -> bool: + """ + Advance this channel's state by consuming pulses based on elapsed time. + + This method is similar to how the pulse-based algorithm consumes samples + from its audio buffer. Pulses whose duration has passed are removed from + the buffer, and the elapsed time is adjusted accordingly. + + Args: + elapsed_time_ms: Time elapsed since last update in milliseconds + + Returns: + bool: True if the buffer needs more pulses, False otherwise + """ + if self.is_empty: + logger.debug("Channel buffer is empty when advancing time") + return True + + self.elapsed_duration_ms += elapsed_time_ms + + # Consume pulses that have completed + accumulated_duration = 0 + consumed_count = 0 + while self.pulse_buffer and accumulated_duration + self.pulse_buffer[0].duration <= self.elapsed_duration_ms: + pulse = self.pulse_buffer.popleft() + accumulated_duration += pulse.duration + consumed_count += 1 + + if consumed_count > 0: + logger.debug(f"Consumed {consumed_count} pulses, total duration {accumulated_duration:.1f}ms") + + # Adjust elapsed time to account for consumed pulses + self.elapsed_duration_ms -= accumulated_duration + + # Buffer needs refilling if it's getting low (less than 2 packets worth) + buffer_low = len(self.pulse_buffer) < COYOTE_PULSES_PER_PACKET * 2 + if buffer_low: + logger.debug(f"Buffer running low: {len(self.pulse_buffer)} pulses remaining") + return buffer_low + +# ===== Utility Functions ===== + +def frequency_to_duration(frequency: float) -> int: + """ + Convert frequency to Coyote pulse duration using the device's specific mapping. + + This is a key function for emulating the pulse-based algorithm's frequency control. + In the pulse-based algorithm, frequency directly controls the waveform. + For Coyote, we must convert frequency to duration (they're inversely related). + + The Coyote uses a non-linear mapping for durations: + - 5-100ms: Direct 1:1 mapping from period + - 100-600ms: Compressed 5:1 mapping + - 600-1000ms: Compressed 10:1 mapping + + Args: + frequency: Input frequency in Hz + Returns: + Duration in milliseconds (5-240ms range) + """ + # Frequency must be positive + if frequency <= 0: + logger.warning(f"Invalid frequency {frequency}Hz, using default") + frequency = 10.0 # Default fallback + + period = 1000.0 / frequency # Convert Hz to period in ms + + if 5.0 <= period <= 100.0: + calculated = period + elif 100.0 < period <= 600.0: + calculated = (period - 100) / 5.0 + 100 + elif 600.0 < period <= 1000.0: + calculated = (period - 600) / 10.0 + 200 + else: + calculated = 10.0 # Default fallback + + result = int(np.clip(round(calculated), COYOTE_MIN_PULSE_DURATION, COYOTE_MAX_PULSE_DURATION)) + + return result + +def generate_envelope( + t: float, + pulse_freq: float, + pulse_width_cycles: float, + pulse_rise_time_cycles: float, + num_cycles: int = 1 +) -> Tuple[np.ndarray, float]: + """ + Generate an envelope shape that determines how pulses' durations are modulated. + + This function is similar to the envelope generation in the pulse-based algorithm, + but it's used to modulate duration instead of amplitude. It creates a continuous + envelope shape that provides a wave-like sensation when applied to pulse durations. + + Note: The pulse_freq parameter that's passed in should already incorporate + any scaling from the carrier frequency. This allows the relationship between + pulse and carrier frequencies to affect the envelope's timing characteristics. + + Args: + t: Current time in seconds (used for phase calculation) + pulse_freq: Base frequency for the envelope (Hz), already scaled by carrier frequency + pulse_width_cycles: Width of each pulse in carrier cycles (shape factor) + pulse_rise_time_cycles: Fade in/out time in carrier cycles (smoothness) + num_cycles: Number of complete cycles to generate in the envelope + Returns: + Tuple of (envelope array, period in seconds) + """ + # Validate and clip parameters using the same limits as pulse-based algorithm + pulse_freq = np.clip(pulse_freq, limits.PulseFrequency.min, limits.PulseFrequency.max) + pulse_width_cycles = np.clip(pulse_width_cycles, limits.PulseWidth.min, limits.PulseWidth.max) + pulse_rise_time_cycles = np.clip(pulse_rise_time_cycles, limits.PulseRiseTime.min, limits.PulseRiseTime.max) + + # Calculate envelope period (seconds per cycle) + envelope_period = 1.0 / pulse_freq if pulse_freq > 0 else 1.0 + total_period = envelope_period * num_cycles + + # Use higher resolution for more accurate envelope shapes + points_per_cycle = max(100, int(envelope_period * 200)) + num_points = points_per_cycle * num_cycles + + t_points = np.linspace(0, total_period, num_points, endpoint=False) + + # Convert parameters to shaping factors + width_factor = pulse_width_cycles / 10.0 # Wider pulses = more time at peaks + rise_factor = pulse_rise_time_cycles / 10.0 # More rise time = smoother transitions + + # Create base sine wave across multiple cycles + envelope = np.sin(2 * np.pi * t_points / envelope_period) + + # Shape the envelope based on pulse width + envelope_sign = np.sign(envelope) + envelope = envelope_sign * np.power(np.abs(envelope), 1.0 / width_factor) + + # Apply smoothing based on rise time + if rise_factor > 0: + # Choose window size based on rise factor + window_size = int(points_per_cycle * rise_factor) + if window_size > 2: # Need at least 3 points for a valid window + window = np.hanning(window_size) + envelope = np.convolve(envelope, window / np.sum(window), mode='same') + envelope /= max(np.max(np.abs(envelope)), 1e-6) # Renormalize after smoothing + + return envelope, total_period + +def compute_volume(media: AbstractMediaSync, volume_params: VolumeParams, t: float) -> float: + """ + Calculate the overall volume multiplier from all volume sources. + + This function matches the volume calculation in the pulse-based algorithm, + combining multiple volume sources into a single multiplier. + + Args: + media: Media sync object to check playback status + volume_params: Volume parameters + t: Current time in seconds + Returns: + Volume multiplier (0-1) + """ + if not media.is_playing(): + return 0 + + master_vol = np.clip(volume_params.master.last_value(), 0, 1) + api_vol = np.clip(volume_params.api.interpolate(t), 0, 1) + inactivity_vol = np.clip(volume_params.inactivity.last_value(), 0, 1) + external_vol = np.clip(volume_params.external.last_value(), 0, 1) + + if inactivity_vol == 0: + logger.warning("Inactivity volume is 0, using 1") + inactivity_vol = 1 + + volume = master_vol * api_vol * inactivity_vol * external_vol + + return volume + +class CoyoteAlgorithm: + """ + Coyote pulse generation algorithm that emulates the pulse-based audio algorithm. + + This class maintains a buffer of pulses for each channel, dynamically generating + new pulses as needed and packaging them into packets for the Coyote device. + It closely follows the design pattern of the pulse-based algorithm while + adapting to the constraints of the Coyote protocol. + + Frequency Handling: + ------------------ + Both carrier and pulse frequencies are user-configurable parameters with their own + ranges defined in the funscript configuration and constrained by safety limits: + + - Carrier frequency: Typically ranges from 500-1000 Hz, primarily affects pulse timing + and spacing, but not directly their durations + - Pulse frequency: Typically ranges from 0-100 Hz, controls pulse repetition rate + and is the primary factor determining pulse durations + + Their relationship: + - Pulse frequency directly controls the duration of pulses (higher freq = shorter durations) + - Carrier frequency primarily modifies the effective pulse frequency for timing purposes + - The specific channel frequency limits determine the valid range of pulse durations + - All frequencies are normalized within their respective ranges before being applied + + This approach ensures: + - All values stay within their valid ranges + - Changes to either frequency produce intuitive and predictable results + - The algorithm adapts to different user settings and funscript configurations + + Key similarities with pulse-based algorithm: + - Uses a buffer abstraction (pulses instead of audio samples) + - Dynamically generates pulses based on current parameters + - Handles parameter interpolation over time + - Supports per-pulse polarity and phase control + - Maps position coordinates to output intensities + + Key adaptations for Coyote: + - Works with packets of 4 pulses instead of continuous audio + - Maps frequency to duration (inversely related) + - Updates based on packet timing rather than sample count + """ + def __init__(self, media: AbstractMediaSync, params: CoyoteAlgorithmParams, safety_limits: SafetyParams, + carrier_freq_limits=(0, 100), pulse_freq_limits=(0, 100)): + """ + Initialize the Coyote algorithm. + + Args: + media: Media synchronization object + params: Algorithm parameters + safety_limits: Safety constraints for parameters + carrier_freq_limits: Tuple of (min, max) for carrier frequency range + pulse_freq_limits: Tuple of (min, max) for pulse frequency range + """ + self.media = media + self.params = params + self.safety_limits = safety_limits + self.position_params = ThreePhasePosition(params.position, params.transform) + self.seq = 0 # Sequence counter (for phase increment) + self.next_update_time = 0 # When to request next packet + self.last_pulses = None # Last generated packet + self.start_time = 0 # Reference time for relative logging + + # Get carrier frequency range from parameters and kit limits + carrier_min, carrier_max = carrier_freq_limits + self.min_carrier_freq = carrier_min + self.max_carrier_freq = carrier_max + + # Apply safety limits as a final constraint + # self.min_carrier_freq = max(carrier_min, safety_limits.minimum_carrier_frequency) + # self.max_carrier_freq = min(carrier_max, safety_limits.maximum_carrier_frequency) + self.carrier_freq_range = self.max_carrier_freq - self.min_carrier_freq + + # Get pulse frequency range from kit limits + self.min_pulse_freq, self.max_pulse_freq = pulse_freq_limits + self.pulse_freq_range = self.max_pulse_freq - self.min_pulse_freq + + # Initialize per-channel state + self.channel_states = { + 'A': None, # Will be initialized on first packet generation + 'B': None # Will be initialized on first packet generation + } + + # Buffer size (number of pulses to generate ahead) + self.buffer_size = COYOTE_PULSES_PER_PACKET * 4 # Buffer 4 packets worth of pulses + + logger.info("Initialized CoyoteAlgorithm") + logger.info(f"Safety limits: {safety_limits.minimum_carrier_frequency}-{safety_limits.maximum_carrier_frequency}Hz") + logger.info(f"Carrier frequency range: {self.min_carrier_freq}-{self.max_carrier_freq}Hz") + logger.info(f"Pulse frequency range: {self.min_pulse_freq}-{self.max_pulse_freq}Hz") + + # Initialize shared envelope data (used by all channels) + self.shared_envelope = np.array([]) + self.shared_envelope_period = 0.0 + self.last_shared_pulse_freq = 0.0 + self.last_shared_pulse_width = 0.0 + self.last_shared_pulse_rise_time = 0.0 + + # Set start time for relative time logging + self.start_time = np.float64(time.time()) + + def compute_volume(self, t: float) -> float: + """ + Calculate the current volume setting from all sources. + + Args: + t: Current time in seconds + Returns: + Volume multiplier (0-1) + """ + return compute_volume(self.media, self.params.volume, t) + + def _rel_time(self, t: float) -> float: + """ + Convert absolute timestamp to relative time in milliseconds. + + Args: + t: Absolute timestamp in seconds + Returns: + Relative time in milliseconds + """ + return (t - self.start_time) * 1000.0 + + def _compute_channel_intensity(self, + channel_id: str, + alpha: float, + beta: float, + volume: float) -> int: + """ + Convert position coordinates to channel intensity. + + This method is similar to how the pulse-based algorithm maps position + coordinates to channel intensities, but adapted for the Coyote's + dual-channel architecture. + + Args: + channel_id: 'A' or 'B' channel identifier + alpha: +1 to -1 coordinate (top-bottom) where +1 is top, -1 is bottom + beta: +1 to -1 coordinate (left-right) where +1 is left, -1 is right + volume: 0 to 1 volume multiplier + Returns: + Integer intensity 0-100 + """ + # Two-channel bias mapping: beta partitions channels, total strength by volume & calibration + # Partition between A and B via beta (-1..+1 → A_frac=0..1) + A_frac = np.clip(0.5 + 0.5 * beta, 0, 1) + B_frac = 1.0 - A_frac + # Total stimulation strength (shared constant sum) + center_calib = ThreePhaseCenterCalibration(self.params.calibrate.center.last_value()) + scale = center_calib.get_scale(alpha, beta) + total_strength = volume * scale + # Channel intensity based on partition fraction + intensity = (A_frac if channel_id == 'A' else B_frac) * total_strength + + # Convert to 0-100 range + result = int(np.clip(intensity * 100, 0, 100)) + return result + + def _generate_single_pulse(self, + base_time: float, + pulse_index: int, + base_intensity: int, + envelope: np.ndarray, + envelope_period: float, + pulse_freq: float, + carrier_freq: float, + min_duration: int, + max_duration: int, + pulse_interval_random: float, + carrier_norm: float = 0.5, + pulse_norm: float = 0.5) -> CoyotePulse: + """ + Generate a single pulse with envelope-modulated duration. + + This method is the Coyote equivalent of the pulse generation in the + pulse-based algorithm. It creates a single pulse with parameters determined + by the current envelope value, applying randomization and polarity as needed. + + Note: Parameters are already interpolated and clipped to appropriate ranges. + + Args: + base_time: Starting time for this pulse sequence (seconds) + pulse_index: Index of this pulse within the sequence + base_intensity: Base intensity value (0-100) + envelope: The envelope array for duration modulation + envelope_period: Period of the envelope in seconds + pulse_freq: Pulse frequency parameter (Hz) - already interpolated + carrier_freq: Carrier frequency parameter (Hz) - already interpolated + min_duration: Minimum pulse duration (ms) + max_duration: Maximum pulse duration (ms) + pulse_interval_random: Random factor for pulse interval (0-1) - already interpolated + carrier_norm: Normalized carrier frequency (0-1) - pre-calculated + pulse_norm: Normalized pulse frequency (0-1) - pre-calculated + Returns: + A CoyotePulse object + """ + # The carrier frequency only affects pulse timing, not durations + # Higher carrier frequencies = faster pulse intervals + modified_pulse_freq = pulse_freq * (0.5 + carrier_norm) + + # Calculate pulse interval based on the modified frequency + pulse_interval_sec = 1.0 / modified_pulse_freq if modified_pulse_freq > 0 else 1.0 + + # Apply random interval variation if specified (same as pulse-based algorithm) + if pulse_interval_random != 0: + pulse_interval_sec = pulse_interval_sec * np.random.uniform(1 - pulse_interval_random, 1 + pulse_interval_random) + + pulse_time = base_time + pulse_index * pulse_interval_sec + + # Find corresponding position in envelope + # Apply phase offset to shift the envelope (similar to pulse-based algorithm) + # phase = ((pulse_time % envelope_period) / envelope_period + phase_offset / (2 * np.pi)) % 1.0 + # env_idx = int(phase * len(envelope)) + # env_value = envelope[env_idx] + + # # Apply polarity to the envelope value + # # This emulates the effect of polarity in the pulse-based algorithm + # if pulse_polarity < 0: + # env_value = -env_value + + # Get envelope value at the current time + phase = ((pulse_time % envelope_period) / envelope_period / (2 * np.pi)) % 1.0 + env_idx = int(phase * len(envelope)) + env_value = envelope[env_idx] + + # Use pulse_norm to interpolate between min and max duration + # pulse_norm=0 (lowest frequency) → max_duration (longest pulses) + # pulse_norm=1 (highest frequency) → min_duration (shortest pulses) + if min_duration < max_duration: # Just to be safe + # First, establish base duration range based on pulse_norm + base_duration_range = (max_duration - min_duration) + base_min = max_duration - pulse_norm * base_duration_range + base_max = base_min + (base_duration_range * 0.5) # Half the original range + + # Now map envelope value (-1 to +1) to normalized [0, 1] + normalized_env = (env_value + 1.0) / 2.0 + + # Apply envelope modulation within the pulse_norm established range + effective_duration = int(base_min + normalized_env * (base_max - base_min)) + else: + effective_duration = min_duration # Fallback + + # Ensure we stay within device limits + effective_duration = np.clip(effective_duration, COYOTE_MIN_PULSE_DURATION, COYOTE_MAX_PULSE_DURATION) + + # Calculate equivalent frequency for the device output + effective_freq = 1000.0 / effective_duration if effective_duration > 0 else 100.0 + + pulse = CoyotePulse( + frequency=int(effective_freq), + intensity=base_intensity, + duration=effective_duration + ) + + # Log first few pulses and occasional ones after for debugging + if pulse_index < 4 or pulse_index % 10 == 0: + time_since_start = (pulse_time - self.start_time) * 1000 + logger.debug(f"Pulse {pulse_index}: in {time_since_start:.1f}ms, env={env_value:.2f}, " + f"duration={effective_duration}ms, freq={effective_freq:.1f}Hz, intensity={base_intensity}%") + + return pulse + + def _fill_channel_buffer(self, + channel_id: str, + current_time: float, + intensity: int, + channel_params, + initialize: bool = False) -> ChannelState: + """ + Fill or initialize a channel's pulse buffer with pulses. + + This unified method replaces both _initialize_buffer and _update_buffer, + eliminating redundancy and ensuring consistent parameter handling. + + Args: + channel_id: 'A' or 'B' channel identifier + current_time: Current system time in seconds + intensity: Intensity for this channel (0-100) + channel_params: Channel-specific parameters + initialize: If True, create a new buffer; if False, update existing one + Returns: + ChannelState object (new or updated) + """ + if initialize: + logger.info(f"Initializing pulse buffer for channel {channel_id} (in {(current_time - self.start_time) * 1000:.1f}ms)") + state = None + else: + state = self.channel_states[channel_id] + logger.debug(f"Filling buffer for channel {channel_id} (currently has {len(state.pulse_buffer)} pulses)") + + # Get channel frequency limits - these are used to calculate duration range + min_freq = channel_params.minimum_frequency.get() + max_freq = channel_params.maximum_frequency.get() + + # Apply global safety limits to channel limits + # min_freq = np.clip(min_freq, self.safety_limits.minimum_carrier_frequency, + # self.safety_limits.maximum_carrier_frequency) + # max_freq = np.clip(max_freq, self.safety_limits.minimum_carrier_frequency, + # self.safety_limits.maximum_carrier_frequency) + + # Calculate or reuse duration range based on frequency limits + if initialize: + # Initialize empty buffer + pulse_buffer = deque() + # Set up initial state + state = ChannelState( + pulse_buffer=pulse_buffer, + start_time=current_time, + elapsed_duration_ms=0.0, + min_freq=min_freq, + max_freq=max_freq, + min_duration=0, # Will be calculated below + max_duration=0 # Will be calculated below + ) + + # Calculate how many new pulses to generate + if initialize: + new_pulses_needed = self.buffer_size + pulse_idx_offset = 0 + else: + new_pulses_needed = max(0, self.buffer_size - len(state.pulse_buffer)) + pulse_idx_offset = len(state.pulse_buffer) + + if new_pulses_needed <= 0: + return state + + # Calculate base time for next pulse + base_time = current_time + if not initialize and state.pulse_buffer: + # If buffer is not empty, start after the last pulse + # Calculate how long since first pulse for accurate alignment + elapsed_time = sum(p.duration for p in state.pulse_buffer) / 1000.0 + base_time = state.start_time + elapsed_time + + # Generate new pulses with per-pulse parameter interpolation + current_pulse_time = base_time # Track the time for each pulse + for i in range(new_pulses_needed): + pulse_idx = pulse_idx_offset + i + + # Interpolate parameters at the exact time of this pulse + pulse_time = current_pulse_time + + # Interpolate parameters at the exact time of this pulse + carrier_freq = self.params.carrier_frequency.interpolate(pulse_time) + pulse_freq = self.params.pulse_frequency.interpolate(pulse_time) + pulse_width = self.params.pulse_width.interpolate(pulse_time) + pulse_rise_time = self.params.pulse_rise_time.interpolate(pulse_time) + pulse_interval_random = self.params.pulse_interval_random.interpolate(pulse_time) + + # Clip parameters + carrier_freq = np.clip(carrier_freq, + self.min_carrier_freq, + self.max_carrier_freq) + pulse_freq = np.clip(pulse_freq, + self.min_pulse_freq, + self.max_pulse_freq) + pulse_width = np.clip(pulse_width, limits.PulseWidth.min, limits.PulseWidth.max) + pulse_rise_time = np.clip(pulse_rise_time, limits.PulseRiseTime.min, limits.PulseRiseTime.max) + + # Normalize carrier and pulse frequencies within their respective ranges + if self.carrier_freq_range > 0: + carrier_norm = (carrier_freq - self.min_carrier_freq) / self.carrier_freq_range + else: + carrier_norm = 0.5 + + if self.pulse_freq_range > 0: + pulse_norm = (pulse_freq - self.min_pulse_freq) / self.pulse_freq_range + else: + pulse_norm = 0.5 + + # Calculate min and max durations from channel frequency limits + min_duration = frequency_to_duration(max_freq) # Shortest duration (highest frequency) + max_duration = frequency_to_duration(min_freq) # Longest duration (lowest frequency) + + # Ensure durations stay within device limits + min_duration = np.clip(min_duration, COYOTE_MIN_PULSE_DURATION, COYOTE_MAX_PULSE_DURATION) + max_duration = np.clip(max_duration, COYOTE_MIN_PULSE_DURATION, COYOTE_MAX_PULSE_DURATION) + + # Update state duration range + if i == 0: # Only need to update this once per buffer fill + state.min_duration = min_duration + state.max_duration = max_duration + state.last_pulse_freq = pulse_freq + state.last_pulse_width = pulse_width + state.last_pulse_rise_time = pulse_rise_time + + # Get or generate envelope + # Calculate modified pulse frequency based on carrier normalization + modified_pulse_freq = pulse_freq * (0.5 + carrier_norm) + + # Check if we need a new envelope + if (self.shared_envelope.size == 0 or + abs(modified_pulse_freq - self.last_shared_pulse_freq) > 0.1 or + abs(pulse_width - self.last_shared_pulse_width) > 0.01 or + abs(pulse_rise_time - self.last_shared_pulse_rise_time) > 0.01): + + logger.debug(f"Generating new envelope: carrier={carrier_freq:.1f}Hz, " + f"pulse_freq={pulse_freq:.1f}Hz, width={pulse_width:.2f}, " + f"rise={pulse_rise_time:.2f}") + + self.shared_envelope, self.shared_envelope_period = generate_envelope( + pulse_time, + modified_pulse_freq, + pulse_width, + pulse_rise_time, + num_cycles=4 + ) + + # Store parameter values for comparison + self.last_shared_pulse_freq = modified_pulse_freq + self.last_shared_pulse_width = pulse_width + self.last_shared_pulse_rise_time = pulse_rise_time + + pulse = self._generate_single_pulse( + base_time=base_time, + pulse_index=pulse_idx, + base_intensity=intensity, + envelope=self.shared_envelope, + envelope_period=self.shared_envelope_period, + pulse_freq=pulse_freq, + carrier_freq=carrier_freq, + min_duration=min_duration, + max_duration=max_duration, + pulse_interval_random=pulse_interval_random, + carrier_norm=carrier_norm, + pulse_norm=pulse_norm + ) + state.pulse_buffer.append(pulse) + + # Update pulse time for next pulse using the actual duration + current_pulse_time += pulse.duration / 1000.0 + + if initialize: + logger.info(f"Generated initial buffer with {len(state.pulse_buffer)} pulses") + else: + logger.debug(f"Added {new_pulses_needed} pulses to buffer, now has {len(state.pulse_buffer)} pulses") + + return state + + def _get_channel_packet(self, + channel_id: str, + current_time: float, + intensity: int, + channel_params) -> Tuple[List[CoyotePulse], float]: + """ + Get a packet of pulses for a channel, handling buffer management. + + This method is conceptually similar to how the pulse-based algorithm + gets audio samples from its buffer, but adapted for the packet-based + nature of the Coyote protocol. + + Args: + channel_id: 'A' or 'B' channel identifier + current_time: Current system time in seconds + intensity: Intensity for this channel (0-100) + channel_params: Channel-specific parameters + Returns: + Tuple of (list of pulses for this packet, next update time in seconds) + """ + channel_state = self.channel_states[channel_id] + + # Initialize channel state if needed + if channel_state is None: + logger.info(f"First initialization for channel {channel_id}") + channel_state = self._fill_channel_buffer( + channel_id, current_time, intensity, channel_params, initialize=True + ) + self.channel_states[channel_id] = channel_state + else: + # Calculate elapsed time since last update + elapsed_time_ms = (current_time - channel_state.start_time) * 1000.0 + logger.debug(f"Channel {channel_id}: advancing time by {elapsed_time_ms:.1f}ms") + + # Update buffer based on elapsed time + needs_more_pulses = channel_state.advance_time(elapsed_time_ms) + + # Reset start time for future calculations + channel_state.start_time = current_time + + # If buffer is low, fill it + if needs_more_pulses: + if channel_state.is_empty: + logger.warning(f"Channel {channel_id}: Buffer is empty! Reinitializing.") + channel_state = self._fill_channel_buffer( + channel_id, current_time, intensity, channel_params, initialize=True + ) + self.channel_states[channel_id] = channel_state + else: + logger.debug(f"Channel {channel_id}: Filling buffer (currently has {len(channel_state.pulse_buffer)} pulses)") + channel_state = self._fill_channel_buffer( + channel_id, current_time, intensity, channel_params, initialize=False + ) + + # Ensure we have enough pulses for a packet + available_pulses = len(channel_state.pulse_buffer) + + assert available_pulses >= COYOTE_PULSES_PER_PACKET, \ + f"Not enough pulses available for channel {channel_id}: have {available_pulses}, need {COYOTE_PULSES_PER_PACKET}" + + # Take pulses for this packet + packet_pulses = [channel_state.pulse_buffer.popleft() for _ in range(COYOTE_PULSES_PER_PACKET)] + + # Calculate when we should check back based on the pulse durations + packet_duration_ms = sum(p.duration for p in packet_pulses) + + # We want to update before the packet is completely played + # This ensures smooth transitions between packets + margin_factor = 0.8 # Update after 80% of the packet duration + next_update = current_time + (packet_duration_ms * margin_factor / 1000.0) + next_update_in_ms = packet_duration_ms * margin_factor + + logger.debug(f"Channel {channel_id}: Packet with {len(packet_pulses)} pulses, " + f"duration={packet_duration_ms:.1f}ms, next update in {next_update_in_ms:.1f}ms") + + return packet_pulses, next_update + + def generate_packet(self, current_time: float) -> CoyotePulses: + """ + Generate one packet of pulses for both channels. + + This method is the main entry point for generating Coyote pulse packets. + It serves a similar role to the generate_audio method in the pulse-based + algorithm, but adapted for the Coyote's packet-based protocol. + + Args: + current_time: Current system time in seconds + Returns: + CoyotePulses object containing pulses for both channels + """ + self.seq += 1 + + # Set start time if this is the first call + if self.start_time == 0: + self.start_time = current_time + + time_since_start_ms = (current_time - self.start_time) * 1000 + logger.debug(f"\n=== Generating packet #{self.seq} in {time_since_start_ms:.1f}ms ===") + + # Get position and volume (same as pulse-based algorithm) + alpha, beta = self.position_params.get_position(current_time) + volume = compute_volume(self.media, self.params.volume, current_time) + + # Process each channel independently + channel_pulses = {} + channel_next_updates = {} + + for channel_id, channel_params in [('A', self.params.channel_a), ('B', self.params.channel_b)]: + # Calculate intensity for this channel based on position + intensity = self._compute_channel_intensity( + channel_id, + alpha, + beta, + volume + ) + + # Get pulses for this channel + pulses, next_update = self._get_channel_packet( + channel_id, + current_time, + intensity, + channel_params + ) + + # Store results + channel_pulses[channel_id] = pulses + channel_next_updates[channel_id] = next_update + + # Calculate next update time based on shortest channel duration (earliest next update) + next_update_time = min(channel_next_updates.values()) + update_in_ms = (next_update_time - current_time) * 1000 + + # Create final pulse packet + result = CoyotePulses(channel_pulses['A'], channel_pulses['B']) + + # Log details using relative time + logger.debug(f" Position: alpha={alpha:.2f}, beta={beta:.2f}, volume={volume:.2f}") + logger.debug(f" Next update in {update_in_ms:.1f}ms") + + # Store for later reference + self.last_pulses = result + self.next_update_time = next_update_time + + return result + + def get_envelope_data(self) -> Tuple[np.ndarray, float]: + """ + Get the current (shared) envelope data for both channels. + Returns: + Tuple of (envelope array, envelope period in seconds) + If no data is available, returns (empty array, 0) + """ + return self.shared_envelope, self.shared_envelope_period \ No newline at end of file diff --git a/device/coyote/device.py b/device/coyote/device.py new file mode 100644 index 0000000..c845561 --- /dev/null +++ b/device/coyote/device.py @@ -0,0 +1,469 @@ +import asyncio +from dataclasses import dataclass +import logging +from typing import Optional, Callable +import time +import threading +import numpy as np + +from bleak import BleakClient, BleakScanner +from device.output_device import OutputDevice +#from stim_math.audio_gen.coyote import CoyoteThreePhaseAlgorithm #blegh, circular import +from PySide6.QtCore import QObject, Signal + +logger = logging.getLogger('restim.coyote') + +# Coyote BLE UUIDs +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" + +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 # 0-150 Hz + intensity: int # 0-100 + duration: int # 10-240 (converted from Hz frequency) + +@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 + +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) + envelope_updated = Signal(str, np.ndarray, float) # channel_id, envelope_data, envelope_period + + 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[any] = 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("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("Starting connection loop") + prev_stage = self.connection_stage + + 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("Device disconnected unexpectedly") + await self.disconnect() + continue + + if self.connection_stage == ConnectionStage.DISCONNECTED: + logger.info("Starting connection process") + self.connection_stage = ConnectionStage.SCANNING + + elif self.connection_stage == ConnectionStage.SCANNING: + if await self._scan_for_device(): + logger.info("Device found, connecting...") + self.connection_stage = ConnectionStage.CONNECTING + else: + logger.info("Device not found, retrying in 5 seconds...") + await asyncio.sleep(5) + + elif self.connection_stage == ConnectionStage.CONNECTING: + if await self.client.connect(): + logger.info("Connected, discovering services...") + self.connection_stage = ConnectionStage.SERVICE_DISCOVERY + else: + logger.error("Connection failed") + await self.disconnect() + + elif self.connection_stage == ConnectionStage.SERVICE_DISCOVERY: + if await self.client.get_services(): + logger.info("Services discovered, subscribing to battery...") + self.connection_stage = ConnectionStage.BATTERY_SUBSCRIBE + else: + logger.error("Service discovery failed") + await self.disconnect() + + elif self.connection_stage == ConnectionStage.BATTERY_SUBSCRIBE: + if await self._subscribe_to_notifications(BATTERY_CHAR_UUID): + logger.info("Battery subscribed, subscribing to status...") + self.connection_stage = ConnectionStage.STATUS_SUBSCRIBE + else: + logger.error("Battery subscription failed") + await self.disconnect() + + elif self.connection_stage == ConnectionStage.STATUS_SUBSCRIBE: + if await self._subscribe_to_notifications(NOTIFY_CHAR_UUID): + logger.info("Status subscribed, syncing parameters...") + self.connection_stage = ConnectionStage.SYNC_PARAMETERS + else: + logger.error("Status subscription failed") + await self.disconnect() + + elif self.connection_stage == ConnectionStage.SYNC_PARAMETERS: + if await self._send_parameters(): + logger.info("Parameters synced, connection complete") + # TODO: wait for ACK so we know device is ready + self.connection_stage = ConnectionStage.CONNECTED + else: + logger.error("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"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("start_updates called") + self.algorithm = algorithm + self.running = True + + future = None + if self._event_loop: + logger.info("scheduling update_loop in event loop") + future = asyncio.run_coroutine_threadsafe(self.update_loop(), self._event_loop) + else: + logger.error("No event loop present!") + + if future: + logger.info("Future scheduled") + else: + logger.warning("Update loop not scheduled") + + def stop_updates(self): + """Stop the update loop but maintain connection""" + logger.info("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"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("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 == 0xB1: + logger.debug(f"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 == 0x51: + logger.debug(f"Command acknowledged (seq={sequence_number})") + + elif command_id == 0x53: + if len(data) < 4: + logger.warning(f"Malformed active power notification: {list(data)}") + return + + power_a = data[2] + power_b = data[3] + + logger.debug(f"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.debug(f"Extra fields in 0x53 notification (undocumented): {list(extra)}") + + else: + logger.warning(f"Unknown notification type: 0x{command_id:02X} (seq={sequence_number})") + logger.debug(f"Raw notification: {list(data)}") + + async def _send_parameters(self): + """Send device parameters""" + logger.info( + f"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"Failed to sync parameters: {str(e)}") + return False + + async def _subscribe_to_notifications(self, char_uuid: str) -> bool: + """Subscribe to notifications for a characteristic""" + try: + 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"Failed to subscribe to {char_uuid}: {e}") + return False + + async def _scan_for_device(self): + """Scan for Coyote device""" + try: + logger.info(f"Scanning for device: {self.device_name}") + device = await BleakScanner.find_device_by_name(self.device_name) + if device: + logger.info(f"Found device: {device.name} ({device.address})") + self.client = BleakClient(device) + self.connection_stage = ConnectionStage.CONNECTING + return True + else: + logger.warning(f"Device not found: {self.device_name}") + await self.disconnect() + return False + except Exception as e: + logger.error(f"Scan error: {str(e)}") + await self.disconnect() + return False + + # async def connect_and_start(self, algorithm: CoyoteThreePhaseAlgorithm, params: CoyoteParams): + # """Connect to device and start operation""" + # self.algorithm = algorithm + # self.parameters = params + # self.is_connected = True # Set connected status + + # # Start the update loop if not already running + # if not self.running: + # self.running = True + # asyncio.create_task(self.update_loop()) + + 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("send_command called with no data") + return + + # Determine strength interpretation (default absolute set if new strength provided) + if strengths: + interp_a = 0b11 # Absolute set for Channel A + interp_b = 0b11 # Absolute set for Channel B + else: + interp_a = 0b00 # No change + interp_b = 0b00 # 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([ + 0xB0, # 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] * 16) # No pulses = zero padding + + # Log what we're sending + logger.info(f"Sending command (seq={self.sequence_number}): ") + # f"Channel A = {strengths.channel_a if strengths else self.strengths.channel_a}, " + # f"Channel B = {strengths.channel_b if strengths else 'N/A'}") + + if pulses: + pulses_a = "\n".join([f" Pulse {i+1}: Freq={a.frequency} Hz, Intensity={a.intensity}" for i, a in enumerate(pulses.channel_a)]) + pulses_b = "\n".join([f" Pulse {i+1}: Freq={b.frequency} Hz, Intensity={b.intensity}" for i, b in enumerate(pulses.channel_b)]) + logger.debug(f"Channel A ({self.strengths.channel_a}):\n{pulses_a}\nChannel 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) % 16 # Wrap seq at 4 bits (0-15) + except Exception as e: + logger.error(f"Failed to send command: {e}") + + async def disconnect(self): + """Disconnect from device""" + logger.info("Disconnecting from Coyote 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)] * 4, + channel_b=[CoyotePulse(frequency=0, intensity=0, duration=0)] * 4 + ) + 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"Starting update loop, running={self.running}, algorithm={self.algorithm}") + + try: + logger.info(f"Update loop started, running={self.running}") + + while self.running: + try: + if not self.algorithm: + logger.debug("Algorithm not yet set") + await asyncio.sleep(0.1) + continue + + current_time = time.time() + logger.debug(f"Update loop iteration at {current_time}") + + if current_time >= self.algorithm.next_update_time: + pulses = self.algorithm.generate_packet(current_time) + await self.send_command(pulses=pulses) + sleep_time = max(0.001, self.algorithm.next_update_time - time.time()) + else: + sleep_time = 0.01 + + await asyncio.sleep(sleep_time) + + except Exception as inner_e: + logger.exception(f"Exception inside update loop iteration: {inner_e}") + await asyncio.sleep(0.1) # prevent tight-crash-loop + + except Exception as outer_e: + logger.exception(f"Fatal exception in update_loop: {outer_e}") + + finally: + logger.info("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/qt_ui/algorithm_factory.py b/qt_ui/algorithm_factory.py index f31b50a..df66e4d 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.algorithm import FOCStimAlgorithm @@ -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') @@ -239,6 +242,82 @@ def create_neostim(self, device: DeviceConfiguration) -> NeoStimAlgorithm: ), ) return algorithm + + def create_coyote(self, device: DeviceConfiguration) -> AudioGenerationAlgorithm: + """ + Create direct algorithm for Coyote device with proper frequency handling. + + Each channel can take: + - Vibration axis if available (assumed already in reasonable range or scaled directly). + - Pulse frequency (1-100 mapped directly into channel's range). + - Carrier frequency (global, used if pulse frequency is missing). + - Fallback to constant 100 if none exist. + + Pulse width and rise time are **always required** by the algorithm, so fallbacks are handled here. + """ + + # Fetch frequency axes + carrier_frequency = self.get_axis_from_script_mapping(AxisEnum.CARRIER_FREQUENCY, limits=(0, 100)) + pulse_frequency = self.get_axis_from_script_mapping(AxisEnum.PULSE_FREQUENCY, limits=(0, 100)) + fallback_frequency = create_constant_axis(100) + + # Fetch pulse shape axes (always needed, so provide defaults: 100% width, rise: 0%) + # pulse_width = self.get_axis_from_script_mapping(AxisEnum.PULSE_WIDTH, limits=(0, 100)) or create_constant_axis(100) + # pulse_rise_time = self.get_axis_from_script_mapping(AxisEnum.PULSE_RISE_TIME, limits=(0, 100)) or create_constant_axis(0) + + # Vibration axes (optional, not yet used) + # vibration_1 = self.get_axis_from_script_mapping(AxisEnum.VIBRATION_1_FREQUENCY) # Consider for Channel A effects? + # vibration_2 = self.get_axis_from_script_mapping(AxisEnum.VIBRATION_2_FREQUENCY) # Consider for Channel B effects? + + # Prefer pulse frequency → fallback to carrier frequency → fallback to constant 100 + script_frequency = pulse_frequency or carrier_frequency or create_constant_axis(100) + + # 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) + + # 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(), + 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 + ) + return algorithm def get_axis_alpha(self): return self.get_axis_from_script_mapping(AxisEnum.POSITION_ALPHA) or self.mainwindow.alpha @@ -399,13 +478,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..b666ae0 --- /dev/null +++ b/qt_ui/coyote_settings_widget.py @@ -0,0 +1,1161 @@ +import asyncio +import time +import numpy as np +from PySide6 import QtCore, QtWidgets +from PySide6.QtWidgets import (QWidget, QVBoxLayout, QLabel, QSlider, QHBoxLayout, + QGraphicsView, QGraphicsScene, QGraphicsLineItem, QDoubleSpinBox, QSpinBox, + QGraphicsRectItem, QToolTip, QGraphicsItem, QGraphicsEllipseItem, QGraphicsPathItem) +from PySide6.QtCore import Qt, QTimer, QPointF, QRectF +from PySide6.QtGui import QPen, QColor, QBrush, QPainterPath +from device.coyote.device import CoyoteDevice, CoyotePulse, CoyotePulses, CoyoteStrengths +from qt_ui import settings + +# Channel color constants for use throughout the UI +CHANNEL_A_COLOR = QColor(160, 90, 255) # Purple +CHANNEL_B_COLOR = QColor(255, 170, 50) # Orange + +class PulseGraphContainer(QWidget): + def __init__(self, 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 = 3.0 # Match the graph's time window + + # Create layout + self.layout = QVBoxLayout(self) + + # Create plot widget + self.plot = PulseGraph(*args, **kwargs) + + # Create and setup label + self.label = QLabel("Intensity: 0%\nFrequency: 0 Hz") + self.label.setAlignment(Qt.AlignCenter) + + # Add widgets to layout + self.layout.addWidget(self.plot) + self.layout.addWidget(self.label) + + 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() + self.entries = [e for e in self.entries if current_time - e.timestamp <= self.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) + + # Update label with frequency and intensity information + self.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) + +# Create a custom graphics rect item with hover capability +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) + +class PulseGraph(QWidget): + def __init__(self, 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 = 3 # Show pulses from the last 3 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() + self.pulses = [p for p in self.pulses if current_time - p.timestamp <= self.time_window] + + # Also clean up old fingerprints + for fingerprint, timestamp in list(self.pulse_fingerprints.items()): + if current_time - timestamp > self.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() + oldest_time = now - self.time_window + newest_time = now + time_span_sec = self.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 EnvelopeGraph(QWidget): + """ + Displays a dynamic visualization of how the envelope pattern affects pulses. + """ + def __init__(self, parent=None): + super().__init__(parent) + self.setLayout(QVBoxLayout()) + + self.view = QGraphicsView() + self.scene = QGraphicsScene() + self.view.setScene(self.scene) + + # Disable scrolling and user interaction + self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.view.setInteractive(True) # Enable for tooltip hover events + self.view.setDragMode(QGraphicsView.NoDrag) + self.view.setViewportUpdateMode(QGraphicsView.SmartViewportUpdate) + + self.layout().addWidget(self.view) + + # Store envelope data + self.envelope_data = np.zeros(100) # Default empty envelope + self.envelope_period = 1.0 + + # Store recent pulses for overlay - increased to show more + self.recent_pulses = [] + self.max_pulses = 50 # Show plenty of pulses + self.max_pulse_age = 2.0 # Show a longer history (2 seconds) + + # Colors for visualization + self.envelope_color = QColor(0, 180, 255, 150) + self.pulse_colors = [ + CHANNEL_A_COLOR, + CHANNEL_B_COLOR + ] + + # Add margin to avoid clipping + self.margin = 20 + + # Simplified timer + self.timer = QTimer(self) + self.timer.timeout.connect(self.refresh) + self.timer.start(100) # 10fps + + # Initialize scene size + self.updateSceneRect() + + def resizeEvent(self, event): + """Handle resize events by updating the scene rectangle""" + super().resizeEvent(event) + self.updateSceneRect() + + 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 setEnvelopeData(self, envelope_data, envelope_period): + """ + Set new envelope data to display + + Args: + envelope_data: numpy array of envelope values (-1 to 1) + envelope_period: period of the envelope in seconds + """ + if envelope_data is None or not isinstance(envelope_data, np.ndarray) or len(envelope_data) == 0: + return + + # Make a copy to avoid reference issues + self.envelope_data = np.array(envelope_data).copy() + + # Make sure we have valid data + if np.isnan(self.envelope_data).any() or np.isinf(self.envelope_data).any(): + self.envelope_data = np.nan_to_num(self.envelope_data) + + # Validate period + if envelope_period > 0: + self.envelope_period = envelope_period + + def addPulse(self, channel_idx, intensity, duration, timestamp=None): + """ + Add a pulse to visualize + + Args: + channel_idx: 0 for channel A, 1 for channel B + intensity: Pulse intensity (0-100) + duration: Pulse duration in ms + timestamp: When the pulse occurred (defaults to now) + """ + if timestamp is None: + timestamp = time.time() + + # Create a new pulse entry + new_pulse = { + 'channel': channel_idx, + 'intensity': intensity, + 'duration': duration, + 'timestamp': timestamp + } + + # Add to beginning for efficient removal of old ones + self.recent_pulses.insert(0, new_pulse) + + # Limit the number of pulses + while len(self.recent_pulses) > self.max_pulses: + self.recent_pulses.pop() + + def refresh(self): + """Simple redraw method without any complex error handling""" + # Skip if not visible + if not self.isVisible(): + return + + # Get current time for pulse age calculations + current_time = time.time() + + # Clean old pulses first + self.recent_pulses = [p for p in self.recent_pulses if current_time - p['timestamp'] <= self.max_pulse_age] + + # Clear scene + self.scene.clear() + + # Get dimensions + width = self.view.viewport().width() + height = self.view.viewport().height() + center_y = height / 2 + + # Calculate scaling + vertical_scale = (height - 2 * self.margin) / 2 + + # Draw simple grid + self._drawGrid(width, height, center_y) + + # Draw envelope + self._drawEnvelope(width, height, center_y, vertical_scale) + + # Draw pulses + self._drawPulses(width, height, center_y, vertical_scale, current_time) + + # Draw frequency label + self._drawFrequencyLabel(width, height) + + def _drawGrid(self, width, height, center_y): + """Draw minimal grid lines""" + # Center line + center_line = QGraphicsLineItem(self.margin, center_y, width - self.margin, center_y) + center_line.setPen(QPen(QColor(200, 200, 200, 150), 1, Qt.DashLine)) + self.scene.addItem(center_line) + + def _drawEnvelope(self, width, height, center_y, vertical_scale): + """Draw envelope curve""" + # Ensure we have data + if len(self.envelope_data) == 0: + return + + # Calculate usable width + usable_width = width - 2 * self.margin + + # Create envelope path + path = QPainterPath() + + # Calculate number of points (1 point per 3 pixels for performance) + num_points = min(len(self.envelope_data), int(usable_width / 3)) + if num_points < 2: + return + + # Calculate step size for envelope data + step = (len(self.envelope_data) - 1) / (num_points - 1) + + # Start path + x = self.margin + y = center_y - (self.envelope_data[0] * vertical_scale) + path.moveTo(x, y) + + # Add points + for i in range(1, num_points): + x = self.margin + (i / (num_points - 1)) * usable_width + idx = int(i * step) + if idx >= len(self.envelope_data): + idx = len(self.envelope_data) - 1 + y = center_y - (self.envelope_data[idx] * vertical_scale) + path.lineTo(x, y) + + # Add path to scene + pen = QPen(self.envelope_color, 2) + self.scene.addPath(path, pen) + + def _drawPulses(self, width, height, center_y, vertical_scale, current_time): + """Draw pulse dots on the envelope""" + if not self.recent_pulses or len(self.envelope_data) == 0 or self.envelope_period <= 0: + return + + usable_width = width - 2 * self.margin + + # Draw each pulse + for pulse in self.recent_pulses: + # Calculate age + age = current_time - pulse['timestamp'] + + # Calculate position + phase = (age % self.envelope_period) / self.envelope_period + phase_reversed = 1.0 - phase # Newer pulses on right + + # Set x position + x = self.margin + phase_reversed * usable_width + + # Find envelope height at this position + if len(self.envelope_data) > 1: # Prevent div by zero + env_idx = min(int(phase_reversed * (len(self.envelope_data) - 1)), len(self.envelope_data) - 1) + env_value = self.envelope_data[env_idx] + y = center_y - (env_value * vertical_scale) + else: + y = center_y + + # Calculate dot size based on intensity + intensity = pulse['intensity'] + size = 4 + (12 * intensity / 100.0) + + # Create dot + channel = pulse['channel'] + dot = QGraphicsEllipseItem(x - size/2, y - size/2, size, size) + dot.setBrush(QBrush(self.pulse_colors[channel])) + dot.setPen(QPen(Qt.NoPen)) + + # Add tooltip + dot.setToolTip(f"Channel: {'A' if channel == 0 else 'B'}\n" + f"Intensity: {intensity}%\n" + f"Duration: {pulse['duration']} ms") + + # Add to scene + dot.setAcceptHoverEvents(True) + self.scene.addItem(dot) + + def _drawFrequencyLabel(self, width, height): + """Draw frequency information""" + if self.envelope_period > 0: + freq_hz = 1.0 / self.envelope_period + label_text = f"{freq_hz:.1f} Hz ({self.envelope_period*1000:.0f} ms)" + label = self.scene.addText(label_text) + label.setPos(width - 180, 5) + label.setDefaultTextColor(QColor(60, 60, 60)) + +class EnvelopeGraphContainer(QWidget): + """ + Container for the envelope graph with title and labels. + """ + def __init__(self, parent=None): + super().__init__(parent) + + # Create layout + self.layout = QVBoxLayout(self) + self.layout.setContentsMargins(0, 0, 0, 0) # Remove margins for better alignment + + # Add top controls row + top_row = QHBoxLayout() + + # Add description + self.description = QLabel("Envelope Pattern") + self.description.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + top_row.addWidget(self.description) + + # Add spacer + top_row.addStretch(1) + + # Add waveform selector + self.waveform_label = QLabel("Waveform:") + top_row.addWidget(self.waveform_label) + + self.waveform_selector = QtWidgets.QComboBox() + self.waveform_selector.addItem("Sine") + # Add more waveforms here when supported + top_row.addWidget(self.waveform_selector) + + self.layout.addLayout(top_row) + + # Create envelope graph + self.graph = EnvelopeGraph() + self.layout.addWidget(self.graph) + + # We don't need a complex buffer or cleanup timer since + # the graph now handles its own pulse cleanup and display + self.received_real_data = False + + def setEnvelopeData(self, envelope_data, envelope_period): + """Pass envelope data to the graph""" + self.received_real_data = True + + # Update envelope description based on real data + if envelope_period > 0: + freq_hz = 1.0 / envelope_period + self.description.setText(f"Envelope Pattern - {freq_hz:.1f} Hz ({envelope_period*1000:.0f} ms)") + + # Pass data to the graph + self.graph.setEnvelopeData(envelope_data, envelope_period) + + def addPulse(self, channel_id, intensity, duration, strength=100): + """ + Add a pulse to the visualization + + Args: + channel_id: 'A' or 'B' + intensity: Pulse intensity + duration: Pulse duration in ms + strength: Current channel strength (0-100) + """ + # Calculate effective intensity + effective_intensity = intensity * (strength / 100) + + # Skip very low intensity pulses + if effective_intensity < 1: + return + + # Convert channel ID to index (0 for A, 1 for B) + channel_idx = 0 if channel_id == 'A' else 1 + + # Add directly to the graph without buffering + self.graph.addPulse(channel_idx, intensity, duration) + +class CoyoteSettingsWidget(QtWidgets.QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + self.setupUi(self) + + # Always initialize volume sliders to 0 (non-persistent) + self.volume_a_slider.setValue(0) + self.volume_b_slider.setValue(0) + + # Connect signals + self.volume_a_slider.valueChanged.connect(self.update_channel_a) + self.volume_b_slider.valueChanged.connect(self.update_channel_b) + self.freq_min_a.valueChanged.connect(self.update_freq_min_a) + self.freq_max_a.valueChanged.connect(self.update_freq_max_a) + self.freq_min_b.valueChanged.connect(self.update_freq_min_b) + self.freq_max_b.valueChanged.connect(self.update_freq_max_b) + self.strength_max_a.valueChanged.connect(self.update_strength_max_a) + self.strength_max_b.valueChanged.connect(self.update_strength_max_b) + + def setupUi(self, CoyoteSettingsWidget): + self.setLayout(QVBoxLayout()) + + # Connection/Battery Status + self.label_connection_status = QLabel("Disconnected") + self.label_connection_stage = QLabel("") + self.label_battery_level = QLabel("") + 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) + + # Add envelope graph with matching layout to pulse graphs + envelope_section = QHBoxLayout() + + # Left section - make it the same width as channel controls + envelope_left = QVBoxLayout() + left_widget = QWidget() + left_widget.setMinimumWidth(130) # Increased width to match channel control sections + left_widget.setMaximumWidth(130) # Increased width to match channel control sections + envelope_left.addWidget(left_widget) + envelope_section.addLayout(envelope_left) + + # Add envelope graph in the center with stretch factor + self.envelope_graph = EnvelopeGraphContainer() + self.envelope_graph.setMinimumHeight(120) + envelope_section.addWidget(self.envelope_graph, 1) # Use stretch factor of 1 + + # Right side controls with proper width + envelope_right = QHBoxLayout() # Changed to horizontal layout + + # Add a visual legend for dots with proper size + legend_layout = QVBoxLayout() + legend_layout.setSpacing(2) + + # Use shared channel colors + channel_a_qcolor = CHANNEL_A_COLOR + channel_b_qcolor = CHANNEL_B_COLOR + + legend_label = QLabel("Dots:") + legend_label.setAlignment(Qt.AlignCenter) + legend_layout.addWidget(legend_label) + + channel_a_legend = QHBoxLayout() + channel_a_color = QLabel("●") + channel_a_color.setFont(channel_a_color.font()) + channel_a_palette = channel_a_color.palette() + channel_a_palette.setColor(channel_a_color.foregroundRole(), channel_a_qcolor) + channel_a_color.setPalette(channel_a_palette) + channel_a_color.setStyleSheet("font-size: 16px;") + channel_a_text = QLabel("Channel A") + channel_a_legend.addWidget(channel_a_color) + channel_a_legend.addWidget(channel_a_text) + legend_layout.addLayout(channel_a_legend) + + channel_b_legend = QHBoxLayout() + channel_b_color = QLabel("●") + channel_b_color.setFont(channel_b_color.font()) + channel_b_palette = channel_b_color.palette() + channel_b_palette.setColor(channel_b_color.foregroundRole(), channel_b_qcolor) + channel_b_color.setPalette(channel_b_palette) + channel_b_color.setStyleSheet("font-size: 16px;") + channel_b_text = QLabel("Channel B") + channel_b_legend.addWidget(channel_b_color) + channel_b_legend.addWidget(channel_b_text) + legend_layout.addLayout(channel_b_legend) + + size_legend = QHBoxLayout() + size_label = QLabel("Size = Intensity") + size_legend.addWidget(size_label) + legend_layout.addLayout(size_legend) + + # Create a widget to contain the legend with proper width + legend_widget = QWidget() + legend_widget.setLayout(legend_layout) + legend_widget.setMinimumWidth(130) # Match the width of volume sliders + envelope_right.addWidget(legend_widget) + + envelope_section.addLayout(envelope_right) + + self.layout().addLayout(envelope_section) + + # Channel A Row + channel_a_layout = QHBoxLayout() + + # Left side layout for Channel A (label and frequency controls) + channel_a_left = QVBoxLayout() + channel_a_label = QLabel("Channel A") + + # Frequency controls in horizontal layouts + freq_min_a_controls = QHBoxLayout() + self.freq_min_a = QSpinBox() + self.freq_min_a.setRange(10, 500) + self.freq_min_a.setValue(settings.coyote_channel_a_freq_min.get()) + self.freq_min_a.setSingleStep(10) + freq_min_a_controls.addWidget(QLabel("Min (Hz)")) + freq_min_a_controls.addWidget(self.freq_min_a) + + freq_max_a_controls = QHBoxLayout() + self.freq_max_a = QSpinBox() + self.freq_max_a.setRange(10, 500) + self.freq_max_a.setValue(settings.coyote_channel_a_freq_max.get()) + self.freq_max_a.setSingleStep(10) + freq_max_a_controls.addWidget(QLabel("Max (Hz)")) + freq_max_a_controls.addWidget(self.freq_max_a) + + # Max strength controls for Channel A + strength_max_a_controls = QHBoxLayout() + strength_max_a_controls.addWidget(QLabel("Max Strength")) + self.strength_max_a = QSpinBox() + self.strength_max_a.setRange(1, 200) + self.strength_max_a.setValue(settings.coyote_channel_a_strength_max.get()) + self.strength_max_a.setSingleStep(1) + self.strength_max_a.valueChanged.connect(self.update_strength_max_a) + strength_max_a_controls.addWidget(self.strength_max_a) + + channel_a_left.addWidget(channel_a_label) + channel_a_left.addLayout(freq_min_a_controls) + channel_a_left.addLayout(freq_max_a_controls) + channel_a_left.addLayout(strength_max_a_controls) + + # Pulse graph for Channel A + self.pulse_graph_a = PulseGraphContainer(self.freq_min_a, self.freq_max_a) + self.pulse_graph_a.plot.setMinimumHeight(100) + + # Volume slider layout for Channel A + volume_a_layout = QVBoxLayout() + self.volume_a_label = QLabel("0 (0%)") + self.volume_a_label.setAlignment(Qt.AlignHCenter) + self.volume_a_slider = QSlider(Qt.Vertical) + self.volume_a_slider.setRange(0, settings.coyote_channel_a_strength_max.get()) + self.volume_a_slider.valueChanged.connect(self.update_volume_a_label) + volume_a_layout.addWidget(self.volume_a_slider) + volume_a_layout.addWidget(self.volume_a_label) + + channel_a_layout.addLayout(channel_a_left) + channel_a_layout.addWidget(self.pulse_graph_a) + channel_a_layout.addLayout(volume_a_layout) + + self.layout().addLayout(channel_a_layout) + + # Channel B Row + channel_b_layout = QHBoxLayout() + + # Left side layout for Channel B (label and frequency controls) + channel_b_left = QVBoxLayout() + channel_b_label = QLabel("Channel B") + + # Frequency controls in horizontal layouts + freq_min_b_controls = QHBoxLayout() + self.freq_min_b = QSpinBox() + self.freq_min_b.setRange(10, 500) + self.freq_min_b.setValue(settings.coyote_channel_b_freq_min.get()) + self.freq_min_b.setSingleStep(10) + freq_min_b_controls.addWidget(QLabel("Min (Hz)")) + freq_min_b_controls.addWidget(self.freq_min_b) + + freq_max_b_controls = QHBoxLayout() + self.freq_max_b = QSpinBox() + self.freq_max_b.setRange(10, 500) + self.freq_max_b.setValue(settings.coyote_channel_b_freq_max.get()) + self.freq_max_b.setSingleStep(10) + freq_max_b_controls.addWidget(QLabel("Max (Hz)")) + freq_max_b_controls.addWidget(self.freq_max_b) + + # Max strength controls for Channel B + strength_max_b_controls = QHBoxLayout() + strength_max_b_controls.addWidget(QLabel("Max Strength")) + self.strength_max_b = QSpinBox() + self.strength_max_b.setRange(1, 200) + self.strength_max_b.setValue(settings.coyote_channel_b_strength_max.get()) + self.strength_max_b.setSingleStep(1) + self.strength_max_b.valueChanged.connect(self.update_strength_max_b) + strength_max_b_controls.addWidget(self.strength_max_b) + + channel_b_left.addWidget(channel_b_label) + channel_b_left.addLayout(freq_min_b_controls) + channel_b_left.addLayout(freq_max_b_controls) + channel_b_left.addLayout(strength_max_b_controls) + + # Pulse graph for Channel B + self.pulse_graph_b = PulseGraphContainer(self.freq_min_b, self.freq_max_b) + self.pulse_graph_b.plot.setMinimumHeight(100) + + # Volume slider layout for Channel B + volume_b_layout = QVBoxLayout() + self.volume_b_label = QLabel("0 (0%)") + self.volume_b_label.setAlignment(Qt.AlignHCenter) + self.volume_b_slider = QSlider(Qt.Vertical) + self.volume_b_slider.setRange(0, settings.coyote_channel_b_strength_max.get()) + self.volume_b_slider.valueChanged.connect(self.update_volume_b_label) + volume_b_layout.addWidget(self.volume_b_slider) + volume_b_layout.addWidget(self.volume_b_label) + + channel_b_layout.addLayout(channel_b_left) + channel_b_layout.addWidget(self.pulse_graph_b) + channel_b_layout.addLayout(volume_b_layout) + + self.layout().addLayout(channel_b_layout) + + def setup_device(self, device: CoyoteDevice): + self.device = device + + # Connect device signals + 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) + + # Initialize labels + self.update_volume_a_label(0) + self.update_volume_b_label(0) + + # If we are already connected to a device, initialize with its values + if device.strengths: + self.update_channel_a(0) + self.update_channel_b(0) + + # Set up timer to periodically fetch envelope data directly + self.envelope_timer = QTimer() + self.envelope_timer.timeout.connect(self.fetch_envelope_data) + self.envelope_timer.start(500) # Fetch every 500ms + + def update_channel_a(self, value): + """Update channel A strength (volume) in the device.""" + if self.device._event_loop: + # value is already the actual strength value (not a percentage) + asyncio.run_coroutine_threadsafe( + self.device.send_command(CoyoteStrengths(value, self.device.strengths.channel_b)), + self.device._event_loop + ) + + def update_channel_b(self, value): + """Update channel B strength (volume) in the device.""" + if self.device._event_loop: + # value is already the actual strength value (not a percentage) + asyncio.run_coroutine_threadsafe( + self.device.send_command(CoyoteStrengths(self.device.strengths.channel_a, value)), + self.device._event_loop + ) + + def on_connection_status_changed(self, connected: bool, stage: str = None): + """Update connection status and stage in UI""" + self.label_connection_status.setText("Connected" if connected else "Disconnected") + if stage: + self.label_connection_stage.setText(stage) + # Enable/disable sliders based on connection status + # self.volume_a_slider.setEnabled(connected) + # self.volume_b_slider.setEnabled(connected) + + def on_battery_level_changed(self, level: int): + """Update battery level display""" + self.label_battery_level.setText(f"Battery: {level}%") + + def on_parameters_changed(self): + """Update UI when device parameters change""" + self.volume_a_slider.blockSignals(True) + self.volume_b_slider.blockSignals(True) + + # self.volume_a_slider.setValue(self.device.parameters.channel_a_intensity_balance) + # self.volume_b_slider.setValue(self.device.parameters.channel_b_intensity_balance) + + self.volume_a_slider.blockSignals(False) + self.volume_b_slider.blockSignals(False) + + def on_power_levels_changed(self, strengths: CoyoteStrengths): + """Update sliders when device power levels change""" + self.volume_a_slider.blockSignals(True) + self.volume_b_slider.blockSignals(True) + + self.volume_a_slider.setValue(strengths.channel_a) + self.volume_b_slider.setValue(strengths.channel_b) + + self.volume_a_slider.blockSignals(False) + self.volume_b_slider.blockSignals(False) + + # Update labels + self.update_volume_a_label(strengths.channel_a) + self.update_volume_b_label(strengths.channel_b) + + def on_pulse_sent(self, pulses: CoyotePulses): + # Update Channel A + if pulses.channel_a: + # Get the actual strength value + strength_a = self.device.strengths.channel_a + # Get the max strength from settings + max_strength_a = settings.coyote_channel_a_strength_max.get() + + for pulse in pulses.channel_a: + # Calculate effective intensity + effective_intensity = pulse.intensity * (strength_a / 100) + + self.pulse_graph_a.add_pulse( + frequency=pulse.frequency, + intensity=pulse.intensity, + duration=pulse.duration, + current_strength=strength_a, + channel_limit=max_strength_a + ) + + # Add to envelope graph only if effective intensity is > 0 + if effective_intensity > 0: + self.envelope_graph.addPulse('A', pulse.intensity, pulse.duration, strength_a) + + # Update Channel B + if pulses.channel_b: + # Get the actual strength value + strength_b = self.device.strengths.channel_b + # Get the max strength from settings + max_strength_b = settings.coyote_channel_b_strength_max.get() + + for pulse in pulses.channel_b: + # Calculate effective intensity + effective_intensity = pulse.intensity * (strength_b / 100) + + self.pulse_graph_b.add_pulse( + frequency=pulse.frequency, + intensity=pulse.intensity, + duration=pulse.duration, + current_strength=strength_b, + channel_limit=max_strength_b + ) + + # Add to envelope graph only if effective intensity is > 0 + if effective_intensity > 0: + self.envelope_graph.addPulse('B', pulse.intensity, pulse.duration, strength_b) + + def update_freq_min_a(self, value): + """Update minimum frequency for channel A""" + if value >= self.freq_max_a.value(): + self.freq_min_a.setValue(self.freq_max_a.value() - 10) + else: + settings.coyote_channel_a_freq_min.set(value) + + def update_freq_max_a(self, value): + """Update maximum frequency for channel A""" + if value <= self.freq_min_a.value(): + self.freq_max_a.setValue(self.freq_min_a.value() + 10) + else: + settings.coyote_channel_a_freq_max.set(value) + + def update_freq_min_b(self, value): + """Update minimum frequency for channel B""" + if value >= self.freq_max_b.value(): + self.freq_min_b.setValue(self.freq_max_b.value() - 10) + else: + settings.coyote_channel_b_freq_min.set(value) + + def update_freq_max_b(self, value): + """Update maximum frequency for channel B""" + if value <= self.freq_min_b.value(): + self.freq_max_b.setValue(self.freq_min_b.value() + 10) + else: + settings.coyote_channel_b_freq_max.set(value) + + def update_volume_a_label(self, value): + # Calculate percentage based on max strength + percentage = int((value / max(1, settings.coyote_channel_a_strength_max.get())) * 100) + self.volume_a_label.setText(f"{value} ({percentage}%)") + + def update_volume_b_label(self, value): + # Calculate percentage based on max strength + percentage = int((value / max(1, settings.coyote_channel_b_strength_max.get())) * 100) + self.volume_b_label.setText(f"{value} ({percentage}%)") + + def update_strength_max_a(self, value): + """Update max strength for channel A and save to settings.""" + settings.coyote_channel_a_strength_max.set(value) + + # Update volume slider range + current_value = self.volume_a_slider.value() + self.volume_a_slider.setRange(0, value) + + # Update the volume label to reflect the new max strength + self.update_volume_a_label(current_value) + + # If the current value exceeds the new max, cap it + if current_value > value: + self.volume_a_slider.setValue(value) + + # Send updated strength to device + self.update_channel_a(self.volume_a_slider.value()) + + def update_strength_max_b(self, value): + """Update max strength for channel B and save to settings.""" + settings.coyote_channel_b_strength_max.set(value) + + # Update volume slider range + current_value = self.volume_b_slider.value() + self.volume_b_slider.setRange(0, value) + + # Update the volume label to reflect the new max strength + self.update_volume_b_label(current_value) + + # If the current value exceeds the new max, cap it + if current_value > value: + self.volume_b_slider.setValue(value) + + # Send updated strength to device + self.update_channel_b(self.volume_b_slider.value()) + + def fetch_envelope_data(self): + """Fetch shared envelope data and update the graph""" + if self.device is None or self.device.algorithm is None or not self.isVisible(): + return + + try: + env_data, env_period = self.device.algorithm.get_envelope_data() + if env_data is not None and env_data.size > 0 and env_period > 0: + self.envelope_graph.setEnvelopeData(env_data, env_period) + except Exception as e: + print(f"Error fetching envelope data: {str(e)}") \ No newline at end of file diff --git a/qt_ui/device_wizard/enums.py b/qt_ui/device_wizard/enums.py index 4928d74..eda564d 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): 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 9379ada..74eda03 100644 --- a/qt_ui/device_wizard/wizard.py +++ b/qt_ui/device_wizard/wizard.py @@ -71,6 +71,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_limits.value else: raise RuntimeError("unknown device type") @@ -91,6 +93,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() @@ -133,6 +137,12 @@ 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, + min_freq, max_freq + ) else: assert(False) @@ -147,6 +157,10 @@ 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) + config.min_frequency = 1 + config.max_frequency = 150 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 7cb0071..42a9372 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 @@ -202,6 +203,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, "") @@ -304,6 +308,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 6616ef8..3c85d23 100644 --- a/qt_ui/mainwindow.py +++ b/qt_ui/mainwindow.py @@ -29,6 +29,7 @@ from qt_ui.models.funscript_kit import FunscriptKitModel from device.focstim.focstim_device import FOCStimDevice from device.neostim.neostim_device import NeoStim +from device.coyote.device import CoyoteDevice, CoyoteParams from qt_ui.widgets.icon_with_connection_status import IconWithConnectionStatus from stim_math.axis import create_temporal_axis @@ -341,7 +342,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} @@ -364,6 +366,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) @@ -380,7 +385,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.comboBox_patternSelect.clear() @@ -399,6 +404,18 @@ 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(qt_ui.settings.coyote_device_name.get()) + 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) def pattern_selection_changed(self, index): pattern = self.comboBox_patternSelect.currentData() @@ -412,10 +429,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(), @@ -464,13 +482,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() diff --git a/qt_ui/preferences_dialog.py b/qt_ui/preferences_dialog.py index 4dd7653..deeee14 100644 --- a/qt_ui/preferences_dialog.py +++ b/qt_ui/preferences_dialog.py @@ -117,6 +117,15 @@ def loadSettings(self): # neostim settings self.neostim_port.setCurrentIndex(self.focstim_port.findData(qt_ui.settings.neostim_serial_port.get())) + # Coyote 3 + self.coyote_device_name.setText(qt_ui.settings.coyote_device_name.get()) + 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()) + # 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()) @@ -220,6 +229,15 @@ def saveSettings(self): # neoStim qt_ui.settings.neostim_serial_port.set(str(self.neostim_port.currentData())) + # Coyote 3 + qt_ui.settings.coyote_device_name.set(self.coyote_device_name.text()) + 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()) + # 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()) diff --git a/qt_ui/preferences_dialog_ui.py b/qt_ui/preferences_dialog_ui.py index 9763862..e668fba 100644 --- a/qt_ui/preferences_dialog_ui.py +++ b/qt_ui/preferences_dialog_ui.py @@ -350,6 +350,98 @@ 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.formLayout_coyote = QFormLayout() + self.formLayout_coyote.setObjectName(u"formLayout_coyote") + self.label_coyote_device_name = QLabel(self.tab_coyote) + self.label_coyote_device_name.setObjectName(u"label_coyote_device_name") + + self.formLayout_coyote.setWidget(0, QFormLayout.LabelRole, self.label_coyote_device_name) + + self.coyote_device_name = QLineEdit(self.tab_coyote) + self.coyote_device_name.setObjectName(u"coyote_device_name") + + self.formLayout_coyote.setWidget(0, QFormLayout.FieldRole, self.coyote_device_name) + + self.label_coyote_channel_a_limit = QLabel(self.tab_coyote) + self.label_coyote_channel_a_limit.setObjectName(u"label_coyote_channel_a_limit") + + self.formLayout_coyote.setWidget(1, QFormLayout.LabelRole, self.label_coyote_channel_a_limit) + + self.coyote_channel_a_limit = QSpinBox(self.tab_coyote) + 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.setWidget(1, QFormLayout.FieldRole, self.coyote_channel_a_limit) + + self.label_coyote_channel_b_limit = QLabel(self.tab_coyote) + self.label_coyote_channel_b_limit.setObjectName(u"label_coyote_channel_b_limit") + + self.formLayout_coyote.setWidget(2, QFormLayout.LabelRole, self.label_coyote_channel_b_limit) + + self.coyote_channel_b_limit = QSpinBox(self.tab_coyote) + 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.setWidget(2, QFormLayout.FieldRole, self.coyote_channel_b_limit) + + self.label_coyote_channel_a_freq_balance = QLabel(self.tab_coyote) + self.label_coyote_channel_a_freq_balance.setObjectName(u"label_coyote_channel_a_freq_balance") + + self.formLayout_coyote.setWidget(3, QFormLayout.LabelRole, self.label_coyote_channel_a_freq_balance) + + self.coyote_channel_a_freq_balance = QSpinBox(self.tab_coyote) + 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.setWidget(3, QFormLayout.FieldRole, self.coyote_channel_a_freq_balance) + + self.label_coyote_channel_b_freq_balance = QLabel(self.tab_coyote) + self.label_coyote_channel_b_freq_balance.setObjectName(u"label_coyote_channel_b_freq_balance") + + self.formLayout_coyote.setWidget(4, QFormLayout.LabelRole, self.label_coyote_channel_b_freq_balance) + + self.coyote_channel_b_freq_balance = QSpinBox(self.tab_coyote) + 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.setWidget(4, QFormLayout.FieldRole, self.coyote_channel_b_freq_balance) + + self.label_coyote_channel_a_intensity_balance = QLabel(self.tab_coyote) + self.label_coyote_channel_a_intensity_balance.setObjectName(u"label_coyote_channel_a_intensity_balance") + + self.formLayout_coyote.setWidget(5, QFormLayout.LabelRole, self.label_coyote_channel_a_intensity_balance) + + self.coyote_channel_a_intensity_balance = QSpinBox(self.tab_coyote) + 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.setWidget(5, QFormLayout.FieldRole, self.coyote_channel_a_intensity_balance) + + self.label_coyote_channel_b_intensity_balance = QLabel(self.tab_coyote) + self.label_coyote_channel_b_intensity_balance.setObjectName(u"label_coyote_channel_b_intensity_balance") + + self.formLayout_coyote.setWidget(6, QFormLayout.LabelRole, self.label_coyote_channel_b_intensity_balance) + + self.coyote_channel_b_intensity_balance = QSpinBox(self.tab_coyote) + 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.setWidget(6, QFormLayout.FieldRole, self.coyote_channel_b_intensity_balance) + + + self.verticalLayout_coyote.addLayout(self.formLayout_coyote) + + 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) @@ -627,6 +719,15 @@ def retranslateUi(self, PreferencesDialog): 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.label_coyote_device_name.setText(QCoreApplication.translate("PreferencesDialog", u"Device Name", None)) + self.coyote_device_name.setText(QCoreApplication.translate("PreferencesDialog", u"47L121000", 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.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 88c1536..d4438da 100644 --- a/qt_ui/settings.py +++ b/qt_ui/settings.py @@ -156,3 +156,17 @@ def set(self, value): focstim_teleplot_prefix = Setting("focstim/teleplot_prefix", "", str) neostim_serial_port = Setting("neostim/serial_port", '', str) + +coyote_device_name = Setting('coyote/device_name', '47L121000', 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", 100, int) +coyote_channel_a_freq_min = Setting("coyote/channel_a_freq_min", 50, 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", 100, int) +coyote_channel_b_freq_min = Setting("coyote/channel_b_freq_min", 20, int) +coyote_channel_b_freq_max = Setting("coyote/channel_b_freq_max", 50, int) diff --git a/stim_math/audio_gen/params.py b/stim_math/audio_gen/params.py index 7fe06c0..b900db8 100644 --- a/stim_math/audio_gen/params.py +++ b/stim_math/audio_gen/params.py @@ -170,6 +170,33 @@ 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 # raw pos (not Hz) + pulse_frequency: AbstractAxis # raw pos (not Hz) + pulse_width: AbstractAxis # carrier cycles + pulse_interval_random: AbstractAxis + pulse_rise_time: AbstractAxis + + channel_a: CoyoteChannelParams + channel_b: CoyoteChannelParams + + + @dataclass class SafetyParams: minimum_carrier_frequency: float From 9a1b96e168f26b07642bf8a20cf9c20c76eb159b Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Sun, 25 May 2025 19:03:19 +0700 Subject: [PATCH 02/47] Rework position intensity and envelope for Coyote --- device/coyote/algorithm.py | 481 +++++++++++++++++--------------- qt_ui/coyote_settings_widget.py | 76 ++--- 2 files changed, 277 insertions(+), 280 deletions(-) diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py index 13f7d1c..32fc8ef 100644 --- a/device/coyote/algorithm.py +++ b/device/coyote/algorithm.py @@ -128,6 +128,10 @@ def __init__(self, self.last_pulse_width = 0.0 self.last_pulse_rise_time = 0.0 + # Track current phase (0.0-1.0) within the envelope period for phase-locked pulse generation + self.envelope_phase = 0.0 # Always in [0.0, 1.0) + + @property def is_empty(self) -> bool: """ @@ -218,69 +222,60 @@ def frequency_to_duration(frequency: float) -> int: return result +def generate_discrete_envelope(num_pulses: int, attack: int, sustain: int, release: int) -> np.ndarray: + """ + Generate a discrete ADSR/trapezoidal envelope array (values in [0, 1]) sampled at the pulse frequency. + Args: + num_pulses: Number of pulses in one envelope cycle + attack: Number of pulses for attack (ramp up) + sustain: Number of pulses for sustain (max value) + release: Number of pulses for release (ramp down) + Returns: + Numpy array of length num_pulses, values in [0, 1] + """ + envelope = np.zeros(num_pulses) + # Attack + if attack > 0: + envelope[:attack] = np.linspace(0, 1, attack, endpoint=False) + # Sustain + if sustain > 0: + envelope[attack:attack+sustain] = 1.0 + # Release + if release > 0: + envelope[attack+sustain:] = np.linspace(1, 0, num_pulses - (attack + sustain)) + return envelope + +# Replace old generate_envelope with a new function that creates a discrete envelope for Coyote + def generate_envelope( - t: float, - pulse_freq: float, - pulse_width_cycles: float, - pulse_rise_time_cycles: float, - num_cycles: int = 1 + t: float, + pulse_freq: float, + carrier_freq: float, + num_points: int = 100, + preview_pulses: int = 6 ) -> Tuple[np.ndarray, float]: """ - Generate an envelope shape that determines how pulses' durations are modulated. - - This function is similar to the envelope generation in the pulse-based algorithm, - but it's used to modulate duration instead of amplitude. It creates a continuous - envelope shape that provides a wave-like sensation when applied to pulse durations. - - Note: The pulse_freq parameter that's passed in should already incorporate - any scaling from the carrier frequency. This allows the relationship between - pulse and carrier frequencies to affect the envelope's timing characteristics. - + Generate a high-resolution envelope for EnvelopeGraph, matching the audio-based UI. Args: - t: Current time in seconds (used for phase calculation) - pulse_freq: Base frequency for the envelope (Hz), already scaled by carrier frequency - pulse_width_cycles: Width of each pulse in carrier cycles (shape factor) - pulse_rise_time_cycles: Fade in/out time in carrier cycles (smoothness) - num_cycles: Number of complete cycles to generate in the envelope + t: Current time in seconds (for phase alignment) + pulse_freq: Pulse frequency (Hz) + carrier_freq: Carrier frequency (Hz) + num_points: Number of points for the preview graph + preview_pulses: Number of pulses to visualize in the preview window Returns: Tuple of (envelope array, period in seconds) """ - # Validate and clip parameters using the same limits as pulse-based algorithm - pulse_freq = np.clip(pulse_freq, limits.PulseFrequency.min, limits.PulseFrequency.max) - pulse_width_cycles = np.clip(pulse_width_cycles, limits.PulseWidth.min, limits.PulseWidth.max) - pulse_rise_time_cycles = np.clip(pulse_rise_time_cycles, limits.PulseRiseTime.min, limits.PulseRiseTime.max) - - # Calculate envelope period (seconds per cycle) - envelope_period = 1.0 / pulse_freq if pulse_freq > 0 else 1.0 - total_period = envelope_period * num_cycles - - # Use higher resolution for more accurate envelope shapes - points_per_cycle = max(100, int(envelope_period * 200)) - num_points = points_per_cycle * num_cycles - - t_points = np.linspace(0, total_period, num_points, endpoint=False) - - # Convert parameters to shaping factors - width_factor = pulse_width_cycles / 10.0 # Wider pulses = more time at peaks - rise_factor = pulse_rise_time_cycles / 10.0 # More rise time = smoother transitions - - # Create base sine wave across multiple cycles - envelope = np.sin(2 * np.pi * t_points / envelope_period) - - # Shape the envelope based on pulse width - envelope_sign = np.sign(envelope) - envelope = envelope_sign * np.power(np.abs(envelope), 1.0 / width_factor) - - # Apply smoothing based on rise time - if rise_factor > 0: - # Choose window size based on rise factor - window_size = int(points_per_cycle * rise_factor) - if window_size > 2: # Need at least 3 points for a valid window - window = np.hanning(window_size) - envelope = np.convolve(envelope, window / np.sum(window), mode='same') - envelope /= max(np.max(np.abs(envelope)), 1e-6) # Renormalize after smoothing - - return envelope, total_period + if num_points < 3: + num_points = 3 + preview_duration = preview_pulses / pulse_freq if pulse_freq > 0 else 1.0 + envelope = np.zeros(num_points) + for i in range(num_points): + t_i = t + (i / (num_points - 1)) * preview_duration + phase = 2 * np.pi * carrier_freq * t_i + envelope[i] = np.abs(np.sin(phase)) + period = preview_duration + return envelope, period + def compute_volume(media: AbstractMediaSync, volume_params: VolumeParams, t: float) -> float: """ @@ -455,34 +450,59 @@ def _compute_channel_intensity(self, Returns: Integer intensity 0-100 """ - # Two-channel bias mapping: beta partitions channels, total strength by volume & calibration - # Partition between A and B via beta (-1..+1 → A_frac=0..1) - A_frac = np.clip(0.5 + 0.5 * beta, 0, 1) - B_frac = 1.0 - A_frac - # Total stimulation strength (shared constant sum) + # Vertices + sqrt3 = np.sqrt(3) + x, y = alpha, beta + xN, yN = 1.0, 0.0 + xL, yL = -0.5, sqrt3 / 2 + xR, yR = -0.5, -sqrt3 / 2 + + # Barycentric coordinates + denom = (yL - yR) * (xN - xR) + (xR - xL) * (yN - yR) + w_N = ((yL - yR) * (x - xR) + (xR - xL) * (y - yR)) / denom + w_L = ((yR - yN) * (x - xR) + (xN - xR) * (y - yR)) / denom + w_R = 1.0 - w_N - w_L + + # Clamp + w_N = np.clip(w_N, 0, 1) + w_L = np.clip(w_L, 0, 1) + w_R = np.clip(w_R, 0, 1) + + if channel_id == 'A': + intensity = w_L + w_N + elif channel_id == 'B': + intensity = w_R + w_N + else: + intensity = w_N + + # Clamp to [0, 1] after sum + intensity = np.clip(intensity, 0, 1) + + # Apply volume and calibration scaling + intensity *= volume center_calib = ThreePhaseCenterCalibration(self.params.calibrate.center.last_value()) scale = center_calib.get_scale(alpha, beta) - total_strength = volume * scale - # Channel intensity based on partition fraction - intensity = (A_frac if channel_id == 'A' else B_frac) * total_strength - + intensity *= scale + # Convert to 0-100 range result = int(np.clip(intensity * 100, 0, 100)) return result def _generate_single_pulse(self, - base_time: float, - pulse_index: int, - base_intensity: int, - envelope: np.ndarray, - envelope_period: float, - pulse_freq: float, - carrier_freq: float, - min_duration: int, - max_duration: int, - pulse_interval_random: float, - carrier_norm: float = 0.5, - pulse_norm: float = 0.5) -> CoyotePulse: + base_time: float, + pulse_index: int, + base_intensity: int, + envelope: np.ndarray, + envelope_period: float, + pulse_freq: float, + carrier_freq: float, + pulse_width: float, + pulse_rise_time: float, + min_duration: int, + max_duration: int, + pulse_interval_random: float, + carrier_norm: float = 0.5, + pulse_norm: float = 0.5) -> CoyotePulse: """ Generate a single pulse with envelope-modulated duration. @@ -521,58 +541,90 @@ def _generate_single_pulse(self, pulse_time = base_time + pulse_index * pulse_interval_sec - # Find corresponding position in envelope - # Apply phase offset to shift the envelope (similar to pulse-based algorithm) - # phase = ((pulse_time % envelope_period) / envelope_period + phase_offset / (2 * np.pi)) % 1.0 - # env_idx = int(phase * len(envelope)) - # env_value = envelope[env_idx] - - # # Apply polarity to the envelope value - # # This emulates the effect of polarity in the pulse-based algorithm - # if pulse_polarity < 0: - # env_value = -env_value - - # Get envelope value at the current time - phase = ((pulse_time % envelope_period) / envelope_period / (2 * np.pi)) % 1.0 - env_idx = int(phase * len(envelope)) - env_value = envelope[env_idx] - - # Use pulse_norm to interpolate between min and max duration - # pulse_norm=0 (lowest frequency) → max_duration (longest pulses) - # pulse_norm=1 (highest frequency) → min_duration (shortest pulses) - if min_duration < max_duration: # Just to be safe - # First, establish base duration range based on pulse_norm - base_duration_range = (max_duration - min_duration) - base_min = max_duration - pulse_norm * base_duration_range - base_max = base_min + (base_duration_range * 0.5) # Half the original range - - # Now map envelope value (-1 to +1) to normalized [0, 1] - normalized_env = (env_value + 1.0) / 2.0 - - # Apply envelope modulation within the pulse_norm established range - effective_duration = int(base_min + normalized_env * (base_max - base_min)) - else: - effective_duration = min_duration # Fallback - - # Ensure we stay within device limits - effective_duration = np.clip(effective_duration, COYOTE_MIN_PULSE_DURATION, COYOTE_MAX_PULSE_DURATION) - - # Calculate equivalent frequency for the device output + # Sequence-based envelope: compute phase in envelope period and use attack envelope function + # Interpolate axes at pulse_time + rise_time = float(self.params.pulse_rise_time.interpolate(pulse_time)) + width_time = float(self.params.pulse_width.interpolate(pulse_time)) + # For now, set fall_time = rise_time (symmetrical attack/decay); can add separate fall axis if needed + fall_time = rise_time + envelope_period = rise_time + width_time + fall_time + if envelope_period <= 0: + envelope_period = 1e-6 # Prevent div by zero + # Compute phase in envelope period + phase = (pulse_time % envelope_period) / envelope_period + rise_frac = rise_time / envelope_period + width_frac = width_time / envelope_period + fall_frac = fall_time / envelope_period + def envelope_func(phase, rise, width, fall): + if phase < rise: + return phase / max(rise, 1e-6) + elif phase < rise + width: + return 1.0 + elif phase < rise + width + fall: + return 1.0 - (phase - rise - width) / max(fall, 1e-6) + else: + return 0.0 + env_value = envelope_func(phase, rise_frac, width_frac, fall_frac) + + # --- Duration calculation --- + # 1. Base duration from pulse_width and carrier_freq (classic TENS logic) + base_duration = pulse_width / carrier_freq * 1000 if carrier_freq > 0 else COYOTE_MIN_PULSE_DURATION + # 2. Add rise time shaping: treat rise time as a ramp proportion of the pulse + # For Coyote, we can't shape the pulse itself, but we can modulate intensity to simulate a ramp + # We'll scale intensity by a ramp factor if rise_time > 0 + ramp_factor = 1.0 + if pulse_rise_time > 0 and pulse_width > 0: + ramp_fraction = min(pulse_rise_time / pulse_width, 1.0) + # Simulate a linear ramp: average intensity over the pulse is reduced + ramp_factor = 1.0 - 0.5 * ramp_fraction # crude approximation + + # 3. Use envelope to modulate both duration and intensity (hybrid stereostim effect) + min_dur = max(min_duration, COYOTE_MIN_PULSE_DURATION) + max_dur = min(max_duration, COYOTE_MAX_PULSE_DURATION) + env_norm = np.clip(env_value, 0, 1) + + # Duration: envelope controls frequency/texture + effective_duration = int(np.clip( + min_dur + (1 - env_norm) * (max_dur - min_dur), + min_dur, max_dur + )) + effective_duration = int(np.clip(effective_duration, COYOTE_MIN_PULSE_DURATION, COYOTE_MAX_PULSE_DURATION)) + + # Intensity: modulate by envelope and ramp, always relative to base_intensity (from alpha/beta) + max_intensity = min(base_intensity, 100) + # Weighted blend: channel mapping dominates, envelope/ramp add texture + blend_weight = 0.3 # 0 = pure base_intensity, 1 = pure envelope/ramp + shaped = env_norm * ramp_factor + effective_intensity = int(np.clip( + max_intensity * (blend_weight * shaped + (1 - blend_weight)), + 0, max_intensity + )) + # This ensures base intensity is always recognizable and envelope/ramp provide subtle shaping + + # --- Frequency reporting (for UI/debug) --- effective_freq = 1000.0 / effective_duration if effective_duration > 0 else 100.0 - + + # --- Optionally: Add randomization to pulse interval (not duration) --- + # This is handled elsewhere, but can be used for advanced effects + + # --- Comments on packet timing --- + # Max pulse duration: COYOTE_MAX_PULSE_DURATION (240 ms) + # One packet (4 pulses) at max duration: 960 ms + # This is the slowest possible output: ~1 packet/sec + # At min duration (5 ms), one packet is 20 ms: fastest possible + # The FIFO buffer and update logic ensure smooth, continuous output + pulse = CoyotePulse( frequency=int(effective_freq), - intensity=base_intensity, + intensity=effective_intensity, duration=effective_duration ) - - # Log first few pulses and occasional ones after for debugging if pulse_index < 4 or pulse_index % 10 == 0: time_since_start = (pulse_time - self.start_time) * 1000 logger.debug(f"Pulse {pulse_index}: in {time_since_start:.1f}ms, env={env_value:.2f}, " - f"duration={effective_duration}ms, freq={effective_freq:.1f}Hz, intensity={base_intensity}%") - + f"duration={effective_duration}ms, freq={effective_freq:.1f}Hz, intensity={effective_intensity}% (env-modulated)") return pulse + def _fill_channel_buffer(self, channel_id: str, @@ -626,6 +678,7 @@ def _fill_channel_buffer(self, min_duration=0, # Will be calculated below max_duration=0 # Will be calculated below ) + state.envelope_phase = 0.0 # Start at phase 0 for new buffer # Calculate how many new pulses to generate if initialize: @@ -646,103 +699,71 @@ def _fill_channel_buffer(self, elapsed_time = sum(p.duration for p in state.pulse_buffer) / 1000.0 base_time = state.start_time + elapsed_time - # Generate new pulses with per-pulse parameter interpolation - current_pulse_time = base_time # Track the time for each pulse - for i in range(new_pulses_needed): - pulse_idx = pulse_idx_offset + i - - # Interpolate parameters at the exact time of this pulse - pulse_time = current_pulse_time - - # Interpolate parameters at the exact time of this pulse - carrier_freq = self.params.carrier_frequency.interpolate(pulse_time) - pulse_freq = self.params.pulse_frequency.interpolate(pulse_time) - pulse_width = self.params.pulse_width.interpolate(pulse_time) - pulse_rise_time = self.params.pulse_rise_time.interpolate(pulse_time) - pulse_interval_random = self.params.pulse_interval_random.interpolate(pulse_time) - - # Clip parameters - carrier_freq = np.clip(carrier_freq, - self.min_carrier_freq, - self.max_carrier_freq) - pulse_freq = np.clip(pulse_freq, - self.min_pulse_freq, - self.max_pulse_freq) - pulse_width = np.clip(pulse_width, limits.PulseWidth.min, limits.PulseWidth.max) - pulse_rise_time = np.clip(pulse_rise_time, limits.PulseRiseTime.min, limits.PulseRiseTime.max) - - # Normalize carrier and pulse frequencies within their respective ranges - if self.carrier_freq_range > 0: - carrier_norm = (carrier_freq - self.min_carrier_freq) / self.carrier_freq_range - else: - carrier_norm = 0.5 - - if self.pulse_freq_range > 0: - pulse_norm = (pulse_freq - self.min_pulse_freq) / self.pulse_freq_range - else: - pulse_norm = 0.5 - - # Calculate min and max durations from channel frequency limits - min_duration = frequency_to_duration(max_freq) # Shortest duration (highest frequency) - max_duration = frequency_to_duration(min_freq) # Longest duration (lowest frequency) - - # Ensure durations stay within device limits - min_duration = np.clip(min_duration, COYOTE_MIN_PULSE_DURATION, COYOTE_MAX_PULSE_DURATION) - max_duration = np.clip(max_duration, COYOTE_MIN_PULSE_DURATION, COYOTE_MAX_PULSE_DURATION) - - # Update state duration range - if i == 0: # Only need to update this once per buffer fill - state.min_duration = min_duration - state.max_duration = max_duration - state.last_pulse_freq = pulse_freq - state.last_pulse_width = pulse_width - state.last_pulse_rise_time = pulse_rise_time - - # Get or generate envelope - # Calculate modified pulse frequency based on carrier normalization - modified_pulse_freq = pulse_freq * (0.5 + carrier_norm) - - # Check if we need a new envelope - if (self.shared_envelope.size == 0 or - abs(modified_pulse_freq - self.last_shared_pulse_freq) > 0.1 or - abs(pulse_width - self.last_shared_pulse_width) > 0.01 or - abs(pulse_rise_time - self.last_shared_pulse_rise_time) > 0.01): - - logger.debug(f"Generating new envelope: carrier={carrier_freq:.1f}Hz, " - f"pulse_freq={pulse_freq:.1f}Hz, width={pulse_width:.2f}, " - f"rise={pulse_rise_time:.2f}") - - self.shared_envelope, self.shared_envelope_period = generate_envelope( - pulse_time, - modified_pulse_freq, - pulse_width, - pulse_rise_time, - num_cycles=4 + # --- Refactored: Phase-locked, envelope-synchronized pulse train generation --- + # 1. Determine envelope period and average pulse frequency + envelope = self.shared_envelope + envelope_period = self.shared_envelope_period + min_duration = frequency_to_duration(max_freq) + max_duration = frequency_to_duration(min_freq) + + # 2. Determine how many pulses fit in one envelope period + # Use the average pulse frequency (Hz) to determine N + avg_pulse_freq = self.params.pulse_frequency.interpolate(current_time) + if avg_pulse_freq <= 0: + avg_pulse_freq = 1.0 # Prevent div by zero + N = max(1, int(round(envelope_period * avg_pulse_freq))) + + # 3. Generate pulses for enough envelope periods to fill the buffer + pulses_to_generate = new_pulses_needed + period_idx = 0 + pulses_generated = 0 + while pulses_to_generate > 0: + envelope_start_time = base_time + period_idx * envelope_period + for i in range(N): + if pulses_to_generate <= 0: + break + # Phase-locked: continue from previous phase + phase = (state.envelope_phase + pulses_generated / N) % 1.0 + pulse_time = envelope_start_time + phase * envelope_period + subtle_jitter = 0.0 + pulse_time_jittered = pulse_time + subtle_jitter + carrier_freq = self.params.carrier_frequency.interpolate(pulse_time_jittered) + pulse_freq = self.params.pulse_frequency.interpolate(pulse_time_jittered) + pulse_width = self.params.pulse_width.interpolate(pulse_time_jittered) + pulse_rise_time = self.params.pulse_rise_time.interpolate(pulse_time_jittered) + pulse_interval_random = self.params.pulse_interval_random.interpolate(pulse_time_jittered) + carrier_freq = np.clip(carrier_freq, self.min_carrier_freq, self.max_carrier_freq) + pulse_freq = np.clip(pulse_freq, self.min_pulse_freq, self.max_pulse_freq) + pulse_width = np.clip(pulse_width, limits.PulseWidth.min, limits.PulseWidth.max) + pulse_rise_time = np.clip(pulse_rise_time, limits.PulseRiseTime.min, limits.PulseRiseTime.max) + carrier_norm = (carrier_freq - self.min_carrier_freq) / self.carrier_freq_range if self.carrier_freq_range > 0 else 0.5 + pulse_norm = (pulse_freq - self.min_pulse_freq) / self.pulse_freq_range if self.pulse_freq_range > 0 else 0.5 + env_idx = int(phase * (len(envelope) - 1)) + env_value = envelope[env_idx] if len(envelope) > 0 else 1.0 + pulse = self._generate_single_pulse( + base_time=pulse_time_jittered, + pulse_index=i, + base_intensity=intensity, + envelope=envelope, + envelope_period=envelope_period, + pulse_freq=pulse_freq, + carrier_freq=carrier_freq, + pulse_width=pulse_width, + pulse_rise_time=pulse_rise_time, + min_duration=min_duration, + max_duration=max_duration, + pulse_interval_random=pulse_interval_random, + carrier_norm=carrier_norm, + pulse_norm=pulse_norm ) - - # Store parameter values for comparison - self.last_shared_pulse_freq = modified_pulse_freq - self.last_shared_pulse_width = pulse_width - self.last_shared_pulse_rise_time = pulse_rise_time - - pulse = self._generate_single_pulse( - base_time=base_time, - pulse_index=pulse_idx, - base_intensity=intensity, - envelope=self.shared_envelope, - envelope_period=self.shared_envelope_period, - pulse_freq=pulse_freq, - carrier_freq=carrier_freq, - min_duration=min_duration, - max_duration=max_duration, - pulse_interval_random=pulse_interval_random, - carrier_norm=carrier_norm, - pulse_norm=pulse_norm - ) - state.pulse_buffer.append(pulse) - - # Update pulse time for next pulse using the actual duration - current_pulse_time += pulse.duration / 1000.0 + state.pulse_buffer.append(pulse) + pulses_to_generate -= 1 + pulses_generated += 1 + period_idx += 1 + # Update envelope phase for continuity + state.envelope_phase = (state.envelope_phase + pulses_generated / N) % 1.0 + # --- End refactor --- + if initialize: logger.info(f"Generated initial buffer with {len(state.pulse_buffer)} pulses") @@ -750,12 +771,8 @@ def _fill_channel_buffer(self, logger.debug(f"Added {new_pulses_needed} pulses to buffer, now has {len(state.pulse_buffer)} pulses") return state - - def _get_channel_packet(self, - channel_id: str, - current_time: float, - intensity: int, - channel_params) -> Tuple[List[CoyotePulse], float]: + + def _get_channel_packet(self, channel_id: str, current_time: float, intensity: int, channel_params): """ Get a packet of pulses for a channel, handling buffer management. @@ -833,8 +850,8 @@ def generate_packet(self, current_time: float) -> CoyotePulses: Generate one packet of pulses for both channels. This method is the main entry point for generating Coyote pulse packets. - It serves a similar role to the generate_audio method in the pulse-based - algorithm, but adapted for the Coyote's packet-based protocol. + It serves a similar role to the generate_audio method in the pulse-based algorithm, + but adapted for the Coyote's packet-based protocol. Args: current_time: Current system time in seconds @@ -903,4 +920,18 @@ def get_envelope_data(self) -> Tuple[np.ndarray, float]: Tuple of (envelope array, envelope period in seconds) If no data is available, returns (empty array, 0) """ - return self.shared_envelope, self.shared_envelope_period \ No newline at end of file + # Always return a high-resolution preview envelope for UI widgets + # Use current time and interpolated parameters for preview + t = time.time() + pulse_freq = self.params.pulse_frequency.interpolate(t) + carrier_freq = self.params.carrier_frequency.interpolate(t) + preview_points = 100 + preview_pulses = 6 + envelope, period = generate_envelope( + t=t, + pulse_freq=pulse_freq, + carrier_freq=carrier_freq, + num_points=preview_points, + preview_pulses=preview_pulses + ) + return envelope, period \ No newline at end of file diff --git a/qt_ui/coyote_settings_widget.py b/qt_ui/coyote_settings_widget.py index b666ae0..035edf3 100644 --- a/qt_ui/coyote_settings_widget.py +++ b/qt_ui/coyote_settings_widget.py @@ -469,8 +469,9 @@ def setEnvelopeData(self, envelope_data, envelope_period): Set new envelope data to display Args: - envelope_data: numpy array of envelope values (-1 to 1) - envelope_period: period of the envelope in seconds + envelope_data: numpy array of envelope values (0 to 1) + 0 = minimum envelope, 1 = maximum envelope + envelope_period: period of the envelope in seconds (duration of one full envelope cycle) """ if envelope_data is None or not isinstance(envelope_data, np.ndarray) or len(envelope_data) == 0: return @@ -532,111 +533,76 @@ def refresh(self): # Get dimensions width = self.view.viewport().width() height = self.view.viewport().height() - center_y = height / 2 - # Calculate scaling - vertical_scale = (height - 2 * self.margin) / 2 + # Calculate scaling for [0, 1] envelope (y=0 at bottom, y=1 at top) + graph_top = self.margin + graph_bottom = height - self.margin + graph_height = graph_bottom - graph_top # Draw simple grid - self._drawGrid(width, height, center_y) + self._drawGrid(width, height, graph_top, graph_bottom) # Draw envelope - self._drawEnvelope(width, height, center_y, vertical_scale) + self._drawEnvelope(width, height, graph_top, graph_bottom, graph_height) # Draw pulses - self._drawPulses(width, height, center_y, vertical_scale, current_time) + self._drawPulses(width, height, graph_top, graph_bottom, graph_height, current_time) # Draw frequency label self._drawFrequencyLabel(width, height) - def _drawGrid(self, width, height, center_y): - """Draw minimal grid lines""" - # Center line - center_line = QGraphicsLineItem(self.margin, center_y, width - self.margin, center_y) - center_line.setPen(QPen(QColor(200, 200, 200, 150), 1, Qt.DashLine)) - self.scene.addItem(center_line) + def _drawGrid(self, width, height, graph_top, graph_bottom): + # No axes, no grid lines, nothing drawn + pass - def _drawEnvelope(self, width, height, center_y, vertical_scale): - """Draw envelope curve""" - # Ensure we have data + def _drawEnvelope(self, width, height, graph_top, graph_bottom, graph_height): + """Draw envelope curve (0 at bottom, 1 at top)""" if len(self.envelope_data) == 0: return - - # Calculate usable width usable_width = width - 2 * self.margin - - # Create envelope path path = QPainterPath() - - # Calculate number of points (1 point per 3 pixels for performance) num_points = min(len(self.envelope_data), int(usable_width / 3)) if num_points < 2: return - - # Calculate step size for envelope data step = (len(self.envelope_data) - 1) / (num_points - 1) - - # Start path x = self.margin - y = center_y - (self.envelope_data[0] * vertical_scale) + y = graph_bottom - (self.envelope_data[0] * graph_height) path.moveTo(x, y) - - # Add points for i in range(1, num_points): x = self.margin + (i / (num_points - 1)) * usable_width idx = int(i * step) if idx >= len(self.envelope_data): idx = len(self.envelope_data) - 1 - y = center_y - (self.envelope_data[idx] * vertical_scale) + y = graph_bottom - (self.envelope_data[idx] * graph_height) path.lineTo(x, y) - - # Add path to scene pen = QPen(self.envelope_color, 2) self.scene.addPath(path, pen) - def _drawPulses(self, width, height, center_y, vertical_scale, current_time): + def _drawPulses(self, width, height, graph_top, graph_bottom, graph_height, current_time): """Draw pulse dots on the envelope""" if not self.recent_pulses or len(self.envelope_data) == 0 or self.envelope_period <= 0: return - usable_width = width - 2 * self.margin - - # Draw each pulse for pulse in self.recent_pulses: - # Calculate age age = current_time - pulse['timestamp'] - - # Calculate position phase = (age % self.envelope_period) / self.envelope_period phase_reversed = 1.0 - phase # Newer pulses on right - - # Set x position x = self.margin + phase_reversed * usable_width - - # Find envelope height at this position - if len(self.envelope_data) > 1: # Prevent div by zero + if len(self.envelope_data) > 1: env_idx = min(int(phase_reversed * (len(self.envelope_data) - 1)), len(self.envelope_data) - 1) env_value = self.envelope_data[env_idx] - y = center_y - (env_value * vertical_scale) + y = graph_bottom - (env_value * graph_height) else: - y = center_y - - # Calculate dot size based on intensity + y = (graph_top + graph_bottom) / 2 intensity = pulse['intensity'] size = 4 + (12 * intensity / 100.0) - - # Create dot channel = pulse['channel'] dot = QGraphicsEllipseItem(x - size/2, y - size/2, size, size) dot.setBrush(QBrush(self.pulse_colors[channel])) dot.setPen(QPen(Qt.NoPen)) - - # Add tooltip dot.setToolTip(f"Channel: {'A' if channel == 0 else 'B'}\n" f"Intensity: {intensity}%\n" f"Duration: {pulse['duration']} ms") - - # Add to scene dot.setAcceptHoverEvents(True) self.scene.addItem(dot) From 5814e2940e5ecb978fe751df17672934ac2d00a6 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Tue, 15 Jul 2025 16:03:02 +0700 Subject: [PATCH 03/47] Experiment with new Coyote algorithm --- device/coyote/algorithm.py | 1276 +++++++++++------------------------- device/coyote/device.py | 3 +- qt_ui/algorithm_factory.py | 11 +- 3 files changed, 380 insertions(+), 910 deletions(-) diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py index 32fc8ef..083b3f3 100644 --- a/device/coyote/algorithm.py +++ b/device/coyote/algorithm.py @@ -1,937 +1,407 @@ """ -DG-LAB Coyote 3.0 E-Stim Algorithm Implementation - -This algorithm controls a Coyote 3.0 dual-channel e-stim device by emulating the behavior -of the pulse-based audio algorithm (used for traditional audio e-stim) while working -within the constraints of the Coyote hardware protocol. - -NOTE: This algorithm is designed specifically for the Coyote 3.0 device. -Other versions are not supported. - -The Coyote 3.0 is a dual-channel e-stim device with the following characteristics: -- Two independent channels (A and B) -- Each channel accepts pulses with: - - Intensity (0-100%) - - Duration (5-240ms) - - The device plays these pulses sequentially -- Protocol accepts 4 pulses per packet -- Device repeats the last received packet until a new one is sent - -Frequency Handling: ------------------ -Both carrier and pulse frequencies are user-configurable with ranges defined in -funscript configuration and constrained by safety limits: - -- Each frequency has its own user-defined range from funscript configurations -- Carrier frequency (typically 500-1000 Hz) affects pulse durations -- Pulse frequency (typically 0-100 Hz) controls pulse repetition rate - -Their relationship: -- Higher carrier frequencies result in shorter pulse durations (inversely related) -- Carrier frequency also modulates the effective pulse frequency -- Channel-specific frequency limits determine valid pulse duration ranges -- All frequencies are normalized within ranges before being applied - -This approach ensures all values stay within valid ranges and changes to either -frequency produce intuitive results that adapt to user settings. - -Key Differences Between Audio E-Stim and Coyote: ------------------------------------------------- -1. Protocol Constraints: - - Audio: Continuous stream of audio samples (44.1kHz) with full waveform control - - Coyote: Packets of exactly 4 pulses per channel with limited parameter control (intensity, duration) - -2. Parameter Control: - - Audio: Direct control over carrier frequency, pulse width, pulse shape, polarity, etc. - - Coyote: Only control over intensity (0-100%) and duration (5-240ms per pulse) - -3. Timing: - - Audio: Microsecond-level precision with continuous buffer - - Coyote: Packet-based with potential gaps between updates - -Emulation Approach: ------------------- -This algorithm bridges these differences by: - -1. Buffer Abstraction: - - Maintains a FIFO buffer of pulses that abstract away the packet-based nature - - Similar to audio algorithm's sample buffer, but at a higher level - -2. Parameter Mapping: - - Maps pulse-based algorithm parameters to Coyote parameters: - - Carrier and pulse frequencies → Duration (inversely related) - - Alpha/Beta position → Channel intensity split - - Pulse polarity → Inverted envelope value - - Envelope shape → Duration modulation - -3. Timing Management: - - Uses a predictive timing model to request new packets before the - current one completes, ensuring smooth playback - -The result is an algorithm that behaves as similarly as possible to the -pulse-based audio algorithm while working within the hardware constraints. +New Coyote 3.0 E-Stim Algorithm - Direct Control Model + +This algorithm is a from-scratch redesign inspired by the Neostim architecture, +but tailored specifically for the DG-LAB Coyote 3.0's hardware constraints. + +It abandons the previous audio-emulation approach in favor of direct parameter +control, aiming for a more faithful and responsive funscript experience. """ import logging +import time import numpy as np from collections import deque -from typing import List, Tuple, Dict, Deque +from typing import List, Tuple, Deque + +from stim_math.axis import AbstractMediaSync, AbstractAxis +from stim_math.threephase import ThreePhaseCenterCalibration +from stim_math.audio_gen.params import CoyoteAlgorithmParams, VolumeParams, SafetyParams from stim_math.audio_gen.various import ThreePhasePosition -from stim_math.axis import AbstractMediaSync from device.coyote.device import CoyotePulse, CoyotePulses -from stim_math.audio_gen.params import SafetyParams, CoyoteAlgorithmParams, VolumeParams -from stim_math.threephase import ThreePhaseCenterCalibration -from stim_math import limits -import time logger = logging.getLogger('restim.coyote') -# Protocol constraints -COYOTE_PULSES_PER_PACKET = 4 # Coyote protocol requires exactly 4 pulses per packet -COYOTE_MIN_PULSE_DURATION = 5 # Minimum pulse duration in ms -COYOTE_MAX_PULSE_DURATION = 240 # Maximum pulse duration in ms - -# ===== Channel State Tracking ===== - -class ChannelState: - """ - Tracks the state of a single channel's pulse buffer. - - This class serves a similar purpose to the audio buffer in the pulse-based algorithm, - but at a higher level of abstraction (pulses instead of audio samples). It maintains - a FIFO queue of pulses that are consumed over time, abstracting away the - packet-based nature of the Coyote protocol. - - The buffer is continuously refilled as pulses are consumed, ensuring smooth playback - and allowing for dynamic parameter changes during operation. - """ - def __init__(self, - pulse_buffer: deque, - start_time: float, - elapsed_duration_ms: float, - - min_freq: float, # Minimum frequency in Hz - max_freq: float, # Maximum frequency in Hz - min_duration: int, # Corresponds to max_freq - max_duration: int): # Corresponds to min_freq - self.pulse_buffer = pulse_buffer - self.start_time = start_time - self.elapsed_duration_ms = elapsed_duration_ms - - self.min_freq = min_freq - self.max_freq = max_freq - self.min_duration = min_duration - self.max_duration = max_duration - - # Track last parameters for detecting changes - self.last_pulse_freq = 0.0 - self.last_pulse_width = 0.0 - self.last_pulse_rise_time = 0.0 - - # Track current phase (0.0-1.0) within the envelope period for phase-locked pulse generation - self.envelope_phase = 0.0 # Always in [0.0, 1.0) - - - @property - def is_empty(self) -> bool: - """ - Check if this channel's pulse buffer is empty. - - Returns: - bool: True if the buffer has no pulses, False otherwise - """ - return len(self.pulse_buffer) == 0 - - def advance_time(self, elapsed_time_ms: float) -> bool: - """ - Advance this channel's state by consuming pulses based on elapsed time. - - This method is similar to how the pulse-based algorithm consumes samples - from its audio buffer. Pulses whose duration has passed are removed from - the buffer, and the elapsed time is adjusted accordingly. - - Args: - elapsed_time_ms: Time elapsed since last update in milliseconds - - Returns: - bool: True if the buffer needs more pulses, False otherwise - """ - if self.is_empty: - logger.debug("Channel buffer is empty when advancing time") - return True - - self.elapsed_duration_ms += elapsed_time_ms - - # Consume pulses that have completed - accumulated_duration = 0 - consumed_count = 0 - while self.pulse_buffer and accumulated_duration + self.pulse_buffer[0].duration <= self.elapsed_duration_ms: - pulse = self.pulse_buffer.popleft() - accumulated_duration += pulse.duration - consumed_count += 1 - - if consumed_count > 0: - logger.debug(f"Consumed {consumed_count} pulses, total duration {accumulated_duration:.1f}ms") - - # Adjust elapsed time to account for consumed pulses - self.elapsed_duration_ms -= accumulated_duration - - # Buffer needs refilling if it's getting low (less than 2 packets worth) - buffer_low = len(self.pulse_buffer) < COYOTE_PULSES_PER_PACKET * 2 - if buffer_low: - logger.debug(f"Buffer running low: {len(self.pulse_buffer)} pulses remaining") - return buffer_low - -# ===== Utility Functions ===== - -def frequency_to_duration(frequency: float) -> int: - """ - Convert frequency to Coyote pulse duration using the device's specific mapping. - - This is a key function for emulating the pulse-based algorithm's frequency control. - In the pulse-based algorithm, frequency directly controls the waveform. - For Coyote, we must convert frequency to duration (they're inversely related). - - The Coyote uses a non-linear mapping for durations: - - 5-100ms: Direct 1:1 mapping from period - - 100-600ms: Compressed 5:1 mapping - - 600-1000ms: Compressed 10:1 mapping - - Args: - frequency: Input frequency in Hz - Returns: - Duration in milliseconds (5-240ms range) - """ - # Frequency must be positive - if frequency <= 0: - logger.warning(f"Invalid frequency {frequency}Hz, using default") - frequency = 10.0 # Default fallback - - period = 1000.0 / frequency # Convert Hz to period in ms - - if 5.0 <= period <= 100.0: - calculated = period - elif 100.0 < period <= 600.0: - calculated = (period - 100) / 5.0 + 100 - elif 600.0 < period <= 1000.0: - calculated = (period - 600) / 10.0 + 200 - else: - calculated = 10.0 # Default fallback - - result = int(np.clip(round(calculated), COYOTE_MIN_PULSE_DURATION, COYOTE_MAX_PULSE_DURATION)) - - return result - -def generate_discrete_envelope(num_pulses: int, attack: int, sustain: int, release: int) -> np.ndarray: - """ - Generate a discrete ADSR/trapezoidal envelope array (values in [0, 1]) sampled at the pulse frequency. - Args: - num_pulses: Number of pulses in one envelope cycle - attack: Number of pulses for attack (ramp up) - sustain: Number of pulses for sustain (max value) - release: Number of pulses for release (ramp down) - Returns: - Numpy array of length num_pulses, values in [0, 1] - """ - envelope = np.zeros(num_pulses) - # Attack - if attack > 0: - envelope[:attack] = np.linspace(0, 1, attack, endpoint=False) - # Sustain - if sustain > 0: - envelope[attack:attack+sustain] = 1.0 - # Release - if release > 0: - envelope[attack+sustain:] = np.linspace(1, 0, num_pulses - (attack + sustain)) - return envelope - -# Replace old generate_envelope with a new function that creates a discrete envelope for Coyote - -def generate_envelope( - t: float, - pulse_freq: float, - carrier_freq: float, - num_points: int = 100, - preview_pulses: int = 6 -) -> Tuple[np.ndarray, float]: - """ - Generate a high-resolution envelope for EnvelopeGraph, matching the audio-based UI. - Args: - t: Current time in seconds (for phase alignment) - pulse_freq: Pulse frequency (Hz) - carrier_freq: Carrier frequency (Hz) - num_points: Number of points for the preview graph - preview_pulses: Number of pulses to visualize in the preview window - Returns: - Tuple of (envelope array, period in seconds) - """ - if num_points < 3: - num_points = 3 - preview_duration = preview_pulses / pulse_freq if pulse_freq > 0 else 1.0 - envelope = np.zeros(num_points) - for i in range(num_points): - t_i = t + (i / (num_points - 1)) * preview_duration - phase = 2 * np.pi * carrier_freq * t_i - envelope[i] = np.abs(np.sin(phase)) - period = preview_duration - return envelope, period +COYOTE_PULSES_PER_PACKET = 4 +COYOTE_MIN_PULSE_DURATION = 5 +COYOTE_MAX_PULSE_DURATION = 240 def compute_volume(media: AbstractMediaSync, volume_params: VolumeParams, t: float) -> float: - """ - Calculate the overall volume multiplier from all volume sources. - - This function matches the volume calculation in the pulse-based algorithm, - combining multiple volume sources into a single multiplier. - - Args: - media: Media sync object to check playback status - volume_params: Volume parameters - t: Current time in seconds - Returns: - Volume multiplier (0-1) - """ + """Calculate the overall volume multiplier from all volume sources.""" if not media.is_playing(): - return 0 - - master_vol = np.clip(volume_params.master.last_value(), 0, 1) - api_vol = np.clip(volume_params.api.interpolate(t), 0, 1) - inactivity_vol = np.clip(volume_params.inactivity.last_value(), 0, 1) - external_vol = np.clip(volume_params.external.last_value(), 0, 1) - - if inactivity_vol == 0: - logger.warning("Inactivity volume is 0, using 1") - inactivity_vol = 1 - - volume = master_vol * api_vol * inactivity_vol * external_vol - + return 0.0 + + master = np.clip(volume_params.master.last_value(), 0, 1) + api = np.clip(volume_params.api.interpolate(t), 0, 1) + inactivity = np.clip(volume_params.inactivity.last_value(), 0, 1) + external = np.clip(volume_params.external.last_value(), 0, 1) + + if inactivity == 0: + inactivity = 1.0 + + volume = master * api * inactivity * external + return volume -class CoyoteAlgorithm: - """ - Coyote pulse generation algorithm that emulates the pulse-based audio algorithm. - - This class maintains a buffer of pulses for each channel, dynamically generating - new pulses as needed and packaging them into packets for the Coyote device. - It closely follows the design pattern of the pulse-based algorithm while - adapting to the constraints of the Coyote protocol. - - Frequency Handling: - ------------------ - Both carrier and pulse frequencies are user-configurable parameters with their own - ranges defined in the funscript configuration and constrained by safety limits: - - - Carrier frequency: Typically ranges from 500-1000 Hz, primarily affects pulse timing - and spacing, but not directly their durations - - Pulse frequency: Typically ranges from 0-100 Hz, controls pulse repetition rate - and is the primary factor determining pulse durations - - Their relationship: - - Pulse frequency directly controls the duration of pulses (higher freq = shorter durations) - - Carrier frequency primarily modifies the effective pulse frequency for timing purposes - - The specific channel frequency limits determine the valid range of pulse durations - - All frequencies are normalized within their respective ranges before being applied - - This approach ensures: - - All values stay within their valid ranges - - Changes to either frequency produce intuitive and predictable results - - The algorithm adapts to different user settings and funscript configurations - - Key similarities with pulse-based algorithm: - - Uses a buffer abstraction (pulses instead of audio samples) - - Dynamically generates pulses based on current parameters - - Handles parameter interpolation over time - - Supports per-pulse polarity and phase control - - Maps position coordinates to output intensities - - Key adaptations for Coyote: - - Works with packets of 4 pulses instead of continuous audio - - Maps frequency to duration (inversely related) - - Updates based on packet timing rather than sample count - """ - def __init__(self, media: AbstractMediaSync, params: CoyoteAlgorithmParams, safety_limits: SafetyParams, - carrier_freq_limits=(0, 100), pulse_freq_limits=(0, 100)): - """ - Initialize the Coyote algorithm. - - Args: - media: Media synchronization object - params: Algorithm parameters - safety_limits: Safety constraints for parameters - carrier_freq_limits: Tuple of (min, max) for carrier frequency range - pulse_freq_limits: Tuple of (min, max) for pulse frequency range - """ - self.media = media - self.params = params - self.safety_limits = safety_limits - self.position_params = ThreePhasePosition(params.position, params.transform) - self.seq = 0 # Sequence counter (for phase increment) - self.next_update_time = 0 # When to request next packet - self.last_pulses = None # Last generated packet - self.start_time = 0 # Reference time for relative logging - - # Get carrier frequency range from parameters and kit limits - carrier_min, carrier_max = carrier_freq_limits - self.min_carrier_freq = carrier_min - self.max_carrier_freq = carrier_max - - # Apply safety limits as a final constraint - # self.min_carrier_freq = max(carrier_min, safety_limits.minimum_carrier_frequency) - # self.max_carrier_freq = min(carrier_max, safety_limits.maximum_carrier_frequency) - self.carrier_freq_range = self.max_carrier_freq - self.min_carrier_freq - - # Get pulse frequency range from kit limits - self.min_pulse_freq, self.max_pulse_freq = pulse_freq_limits - self.pulse_freq_range = self.max_pulse_freq - self.min_pulse_freq - - # Initialize per-channel state - self.channel_states = { - 'A': None, # Will be initialized on first packet generation - 'B': None # Will be initialized on first packet generation - } - - # Buffer size (number of pulses to generate ahead) - self.buffer_size = COYOTE_PULSES_PER_PACKET * 4 # Buffer 4 packets worth of pulses - - logger.info("Initialized CoyoteAlgorithm") - logger.info(f"Safety limits: {safety_limits.minimum_carrier_frequency}-{safety_limits.maximum_carrier_frequency}Hz") - logger.info(f"Carrier frequency range: {self.min_carrier_freq}-{self.max_carrier_freq}Hz") - logger.info(f"Pulse frequency range: {self.min_pulse_freq}-{self.max_pulse_freq}Hz") - - # Initialize shared envelope data (used by all channels) - self.shared_envelope = np.array([]) - self.shared_envelope_period = 0.0 - self.last_shared_pulse_freq = 0.0 - self.last_shared_pulse_width = 0.0 - self.last_shared_pulse_rise_time = 0.0 - - # Set start time for relative time logging - self.start_time = np.float64(time.time()) - - def compute_volume(self, t: float) -> float: - """ - Calculate the current volume setting from all sources. - - Args: - t: Current time in seconds - Returns: - Volume multiplier (0-1) - """ - return compute_volume(self.media, self.params.volume, t) - - def _rel_time(self, t: float) -> float: - """ - Convert absolute timestamp to relative time in milliseconds. - - Args: - t: Absolute timestamp in seconds - Returns: - Relative time in milliseconds - """ - return (t - self.start_time) * 1000.0 - - def _compute_channel_intensity(self, - channel_id: str, - alpha: float, - beta: float, - volume: float) -> int: - """ - Convert position coordinates to channel intensity. - - This method is similar to how the pulse-based algorithm maps position - coordinates to channel intensities, but adapted for the Coyote's - dual-channel architecture. - - Args: - channel_id: 'A' or 'B' channel identifier - alpha: +1 to -1 coordinate (top-bottom) where +1 is top, -1 is bottom - beta: +1 to -1 coordinate (left-right) where +1 is left, -1 is right - volume: 0 to 1 volume multiplier - Returns: - Integer intensity 0-100 - """ - # Vertices - sqrt3 = np.sqrt(3) - x, y = alpha, beta - xN, yN = 1.0, 0.0 - xL, yL = -0.5, sqrt3 / 2 - xR, yR = -0.5, -sqrt3 / 2 - - # Barycentric coordinates - denom = (yL - yR) * (xN - xR) + (xR - xL) * (yN - yR) - w_N = ((yL - yR) * (x - xR) + (xR - xL) * (y - yR)) / denom - w_L = ((yR - yN) * (x - xR) + (xN - xR) * (y - yR)) / denom - w_R = 1.0 - w_N - w_L - - # Clamp - w_N = np.clip(w_N, 0, 1) - w_L = np.clip(w_L, 0, 1) - w_R = np.clip(w_R, 0, 1) - - if channel_id == 'A': - intensity = w_L + w_N - elif channel_id == 'B': - intensity = w_R + w_N - else: - intensity = w_N - # Clamp to [0, 1] after sum - intensity = np.clip(intensity, 0, 1) - # Apply volume and calibration scaling - intensity *= volume - center_calib = ThreePhaseCenterCalibration(self.params.calibrate.center.last_value()) - scale = center_calib.get_scale(alpha, beta) - intensity *= scale - - # Convert to 0-100 range - result = int(np.clip(intensity * 100, 0, 100)) - return result - - def _generate_single_pulse(self, - base_time: float, - pulse_index: int, - base_intensity: int, - envelope: np.ndarray, - envelope_period: float, - pulse_freq: float, - carrier_freq: float, - pulse_width: float, - pulse_rise_time: float, - min_duration: int, - max_duration: int, - pulse_interval_random: float, - carrier_norm: float = 0.5, - pulse_norm: float = 0.5) -> CoyotePulse: +class ChannelState: + """Holds the state for a single channel's pulse packet and timing.""" + def __init__(self): + self.current_packet: Deque[CoyotePulse] = deque() + self.time_in_packet_ms = 0.0 + self.total_packet_duration_ms = 0.0 + self.packet_start_time_s = 0.0 + self.packet_finish_time_s = 0.0 + + def set_new_packet(self, t: float, packet: List[CoyotePulse], time_to_finish_s: float): + """Updates the channel with a new packet and its timing information.""" + self.current_packet = deque(packet) + self.packet_start_time_s = t + self.packet_finish_time_s = self.packet_start_time_s + time_to_finish_s + self.total_packet_duration_ms = sum(p.duration for p in packet) + self.time_in_packet_ms = 0.0 + + def advance_time(self, delta_time_ms: float): + self.time_in_packet_ms += delta_time_ms + + def is_ready_for_next_packet(self) -> bool: + """Returns True if the current packet has finished playing.""" + return self.get_remaining_time_ms() <= 0 + + def get_remaining_time_ms(self) -> float: + if self.total_packet_duration_ms == 0: + return 0.0 # Ready for first packet + return max(0.0, self.total_packet_duration_ms - self.time_in_packet_ms) + + +class WaveGenerator: + """Generates a continuous, asymmetric triangle wave based on pulse frequency and rise time.""" + def __init__(self, pulse_frequency_axis: AbstractAxis, pulse_rise_time_axis: AbstractAxis, + pulse_rise_time_limits: Tuple[float, float]): + self.pulse_frequency_axis = pulse_frequency_axis + self.pulse_rise_time_axis = pulse_rise_time_axis + self.pulse_rise_time_limits = pulse_rise_time_limits + self.phase = 0.0 + self.rise_pct = 0.5 # Default to a symmetric wave + + def advance(self, t: float, delta_time_ms: float): + pulse_freq_hz = self.pulse_frequency_axis.interpolate(t) + if pulse_freq_hz <= 0: + self.phase = 0.0 + return + + # Advance the phase based on the pulse frequency + self.phase += (delta_time_ms / 1000.0) * pulse_freq_hz + self.phase %= 1.0 + + # Update the rise percentage based on the pulse_rise_time axis + rise_time_val = self.pulse_rise_time_axis.interpolate(t) + min_rise, max_rise = self.pulse_rise_time_limits + rise_range = max_rise - min_rise + + # Normalize rise time to a 0-1 percentage for the wave shape + normalized_rise = (rise_time_val - min_rise) / rise_range if rise_range > 0 else 0.5 + normalized_rise = np.clip(normalized_rise, 0.0, 1.0) + + # We don't want the rise/fall to be instantaneous, so map 0-1 to a safer range, e.g., 0.01 to 0.99 + self.rise_pct = 0.01 + normalized_rise * 0.98 + + def get_value(self) -> float: + """Returns the current wave value, from -1.0 to 1.0.""" + if self.rise_pct <= 0.0: return -1.0 + if self.rise_pct >= 1.0: return 1.0 + + if self.phase < self.rise_pct: + # Rising part of the wave + return -1.0 + (self.phase / self.rise_pct) * 2.0 + else: + # Falling part of the wave + fall_phase = self.phase - self.rise_pct + fall_duration_pct = 1.0 - self.rise_pct + if fall_duration_pct <= 0: return -1.0 + return 1.0 - (fall_phase / fall_duration_pct) * 2.0 + + +class ContinuousSignal: + """Models the complete, time-aware signal for a single channel based on the 'wave' model.""" + def __init__(self, params: CoyoteAlgorithmParams, channel_params: 'CoyoteChannelParams', + carrier_freq_limits: Tuple[float, float], pulse_width_limits: Tuple[float, float], + pulse_rise_time_limits: Tuple[float, float]): + self.params = params + self.channel_params = channel_params + self.pulse_rise_time_limits = pulse_rise_time_limits + self.pulse_width_limits = pulse_width_limits + self.wave = WaveGenerator(params.pulse_frequency, params.pulse_rise_time, pulse_rise_time_limits) + self.last_pulse_time_s = 0.0 + self.start_time = None + + def get_pulse_at(self, t: float, base_intensity: float, pulse_index: int = 0) -> CoyotePulse: """ - Generate a single pulse with envelope-modulated duration. - - This method is the Coyote equivalent of the pulse generation in the - pulse-based algorithm. It creates a single pulse with parameters determined - by the current envelope value, applying randomization and polarity as needed. - - Note: Parameters are already interpolated and clipped to appropriate ranges. - - Args: - base_time: Starting time for this pulse sequence (seconds) - pulse_index: Index of this pulse within the sequence - base_intensity: Base intensity value (0-100) - envelope: The envelope array for duration modulation - envelope_period: Period of the envelope in seconds - pulse_freq: Pulse frequency parameter (Hz) - already interpolated - carrier_freq: Carrier frequency parameter (Hz) - already interpolated - min_duration: Minimum pulse duration (ms) - max_duration: Maximum pulse duration (ms) - pulse_interval_random: Random factor for pulse interval (0-1) - already interpolated - carrier_norm: Normalized carrier frequency (0-1) - pre-calculated - pulse_norm: Normalized pulse frequency (0-1) - pre-calculated - Returns: - A CoyotePulse object + Generate a pulse for this channel at time t. + base_intensity should be in the range 0-100 (int or float), as per position/intensity logic. + This method normalizes to [0,1] internally for all calculations. """ - # The carrier frequency only affects pulse timing, not durations - # Higher carrier frequencies = faster pulse intervals - modified_pulse_freq = pulse_freq * (0.5 + carrier_norm) - - # Calculate pulse interval based on the modified frequency - pulse_interval_sec = 1.0 / modified_pulse_freq if modified_pulse_freq > 0 else 1.0 - - # Apply random interval variation if specified (same as pulse-based algorithm) - if pulse_interval_random != 0: - pulse_interval_sec = pulse_interval_sec * np.random.uniform(1 - pulse_interval_random, 1 + pulse_interval_random) - - pulse_time = base_time + pulse_index * pulse_interval_sec - - # Sequence-based envelope: compute phase in envelope period and use attack envelope function - # Interpolate axes at pulse_time - rise_time = float(self.params.pulse_rise_time.interpolate(pulse_time)) - width_time = float(self.params.pulse_width.interpolate(pulse_time)) - # For now, set fall_time = rise_time (symmetrical attack/decay); can add separate fall axis if needed - fall_time = rise_time - envelope_period = rise_time + width_time + fall_time - if envelope_period <= 0: - envelope_period = 1e-6 # Prevent div by zero - # Compute phase in envelope period - phase = (pulse_time % envelope_period) / envelope_period - rise_frac = rise_time / envelope_period - width_frac = width_time / envelope_period - fall_frac = fall_time / envelope_period - def envelope_func(phase, rise, width, fall): - if phase < rise: - return phase / max(rise, 1e-6) - elif phase < rise + width: - return 1.0 - elif phase < rise + width + fall: - return 1.0 - (phase - rise - width) / max(fall, 1e-6) - else: - return 0.0 - env_value = envelope_func(phase, rise_frac, width_frac, fall_frac) - - # --- Duration calculation --- - # 1. Base duration from pulse_width and carrier_freq (classic TENS logic) - base_duration = pulse_width / carrier_freq * 1000 if carrier_freq > 0 else COYOTE_MIN_PULSE_DURATION - # 2. Add rise time shaping: treat rise time as a ramp proportion of the pulse - # For Coyote, we can't shape the pulse itself, but we can modulate intensity to simulate a ramp - # We'll scale intensity by a ramp factor if rise_time > 0 - ramp_factor = 1.0 - if pulse_rise_time > 0 and pulse_width > 0: - ramp_fraction = min(pulse_rise_time / pulse_width, 1.0) - # Simulate a linear ramp: average intensity over the pulse is reduced - ramp_factor = 1.0 - 0.5 * ramp_fraction # crude approximation - - # 3. Use envelope to modulate both duration and intensity (hybrid stereostim effect) - min_dur = max(min_duration, COYOTE_MIN_PULSE_DURATION) - max_dur = min(max_duration, COYOTE_MAX_PULSE_DURATION) - env_norm = np.clip(env_value, 0, 1) - - # Duration: envelope controls frequency/texture - effective_duration = int(np.clip( - min_dur + (1 - env_norm) * (max_dur - min_dur), - min_dur, max_dur - )) - effective_duration = int(np.clip(effective_duration, COYOTE_MIN_PULSE_DURATION, COYOTE_MAX_PULSE_DURATION)) - - # Intensity: modulate by envelope and ramp, always relative to base_intensity (from alpha/beta) - max_intensity = min(base_intensity, 100) - # Weighted blend: channel mapping dominates, envelope/ramp add texture - blend_weight = 0.3 # 0 = pure base_intensity, 1 = pure envelope/ramp - shaped = env_norm * ramp_factor - effective_intensity = int(np.clip( - max_intensity * (blend_weight * shaped + (1 - blend_weight)), - 0, max_intensity - )) - # This ensures base intensity is always recognizable and envelope/ramp provide subtle shaping - - # --- Frequency reporting (for UI/debug) --- - effective_freq = 1000.0 / effective_duration if effective_duration > 0 else 100.0 - - # --- Optionally: Add randomization to pulse interval (not duration) --- - # This is handled elsewhere, but can be used for advanced effects - - # --- Comments on packet timing --- - # Max pulse duration: COYOTE_MAX_PULSE_DURATION (240 ms) - # One packet (4 pulses) at max duration: 960 ms - # This is the slowest possible output: ~1 packet/sec - # At min duration (5 ms), one packet is 20 ms: fastest possible - # The FIFO buffer and update logic ensure smooth, continuous output + delta_time_ms = (t - self.last_pulse_time_s) * 1000.0 if self.last_pulse_time_s > 0 else 0 + self.last_pulse_time_s = t + self.wave.advance(t, delta_time_ms) + + if self.start_time is None: + self.start_time = t + # --- Get base parameters from axes --- + carrier_freq = self.params.carrier_frequency.interpolate(t) + pulse_width = self.params.pulse_width.interpolate(t) + random_strength = self.params.pulse_interval_random.interpolate(t) + + # --- Calculate channel-specific frequency parameters --- + min_freq = self.channel_params.minimum_frequency.get() + max_freq = self.channel_params.maximum_frequency.get() + midpoint_freq = (min_freq + max_freq) / 2.0 + max_deviation = (max_freq - min_freq) / 2.0 + + # --- Build the wave that modulates the sensation frequency --- + # 1. Normalize the raw carrier frequency (e.g., 500-1000Hz) to a 0-1 percentage + min_carrier, max_carrier = self.pulse_width_limits + carrier_range = max_carrier - min_carrier + carrier_pct = (carrier_freq - min_carrier) / carrier_range if carrier_range > 0 else 0 + carrier_pct = np.clip(carrier_pct, 0.0, 1.0) + + # 2. Amplitude of the wave is controlled by this correct percentage + amplitude = max_deviation * carrier_pct + + # 3. Get current position on the wave (-1 to 1) + wave_value = self.wave.get_value() + + # 4. The final modulated frequency is the midpoint offset by the wave + sensation_freq = midpoint_freq + (wave_value * amplitude) + + # --- Calculate Final Pulse Parameters --- + # The final frequency is determined by the raw sensation frequency, scaled by the duty cycle (pulse_width) + # and random jitter. This effective frequency is then clipped to the channel's limits. + + # 1. Calculate scaling factors + min_pw, max_pw = self.pulse_width_limits + pw_range = max_pw - min_pw + norm_pulse_width = (pulse_width - min_pw) / pw_range if pw_range > 0 else 0.5 + norm_pulse_width = np.clip(norm_pulse_width, 0.0, 1.0) + + # Remap the normalized pulse width to a scaler that modulates frequency. A wider pulse (higher norm_pulse_width) + # should result in a lower frequency (longer duration), so it needs a larger scaler. + # We map [0,1] to [0.75, 1.25] for a +/- 25% modulation. This could be a user-configurable parameter. + pulse_width_scaler = 0.75 + (norm_pulse_width * 0.5) + + random_multiplier = 1.0 + if random_strength > 0: + random_pct = random_strength / 100.0 + random_jitter = (np.random.rand() - 0.5) * 2 * random_pct + random_multiplier = 1.0 + random_jitter + + # 2. Calculate the effective frequency after scaling. A shorter pulse width (duty cycle) leads to a higher frequency. + effective_freq = sensation_freq / (pulse_width_scaler * random_multiplier) + + # 3. Clip the effective frequency to the channel's configured min/max range. + target_freq = np.clip(effective_freq, min_freq, max_freq) + target_freq = max(0.1, target_freq) + + # 4. Calculate the duration from the target frequency, then clip it to the hardware's absolute limits. + target_duration_ms = 1000.0 / target_freq + final_duration = int(np.clip(target_duration_ms, COYOTE_MIN_PULSE_DURATION, COYOTE_MAX_PULSE_DURATION)) + + # 5. Recalculate the final frequency from the actual final duration. This ensures perfect consistency + # between the frequency and duration values, respecting hardware limits above all. + final_freq = 1000.0 / final_duration if final_duration > 0 else 0 + + effective_intensity = int(np.clip(base_intensity, 0, 100)) pulse = CoyotePulse( - frequency=int(effective_freq), + frequency=int(final_freq), intensity=effective_intensity, - duration=effective_duration + duration=final_duration ) - if pulse_index < 4 or pulse_index % 10 == 0: - time_since_start = (pulse_time - self.start_time) * 1000 - logger.debug(f"Pulse {pulse_index}: in {time_since_start:.1f}ms, env={env_value:.2f}, " - f"duration={effective_duration}ms, freq={effective_freq:.1f}Hz, intensity={effective_intensity}% (env-modulated)") return pulse - - def _fill_channel_buffer(self, - channel_id: str, - current_time: float, - intensity: int, - channel_params, - initialize: bool = False) -> ChannelState: - """ - Fill or initialize a channel's pulse buffer with pulses. - - This unified method replaces both _initialize_buffer and _update_buffer, - eliminating redundancy and ensuring consistent parameter handling. - - Args: - channel_id: 'A' or 'B' channel identifier - current_time: Current system time in seconds - intensity: Intensity for this channel (0-100) - channel_params: Channel-specific parameters - initialize: If True, create a new buffer; if False, update existing one - Returns: - ChannelState object (new or updated) - """ - if initialize: - logger.info(f"Initializing pulse buffer for channel {channel_id} (in {(current_time - self.start_time) * 1000:.1f}ms)") - state = None - else: - state = self.channel_states[channel_id] - logger.debug(f"Filling buffer for channel {channel_id} (currently has {len(state.pulse_buffer)} pulses)") - - # Get channel frequency limits - these are used to calculate duration range - min_freq = channel_params.minimum_frequency.get() - max_freq = channel_params.maximum_frequency.get() - - # Apply global safety limits to channel limits - # min_freq = np.clip(min_freq, self.safety_limits.minimum_carrier_frequency, - # self.safety_limits.maximum_carrier_frequency) - # max_freq = np.clip(max_freq, self.safety_limits.minimum_carrier_frequency, - # self.safety_limits.maximum_carrier_frequency) - - # Calculate or reuse duration range based on frequency limits - if initialize: - # Initialize empty buffer - pulse_buffer = deque() - # Set up initial state - state = ChannelState( - pulse_buffer=pulse_buffer, - start_time=current_time, - elapsed_duration_ms=0.0, - min_freq=min_freq, - max_freq=max_freq, - min_duration=0, # Will be calculated below - max_duration=0 # Will be calculated below - ) - state.envelope_phase = 0.0 # Start at phase 0 for new buffer - - # Calculate how many new pulses to generate - if initialize: - new_pulses_needed = self.buffer_size - pulse_idx_offset = 0 - else: - new_pulses_needed = max(0, self.buffer_size - len(state.pulse_buffer)) - pulse_idx_offset = len(state.pulse_buffer) - - if new_pulses_needed <= 0: - return state - - # Calculate base time for next pulse - base_time = current_time - if not initialize and state.pulse_buffer: - # If buffer is not empty, start after the last pulse - # Calculate how long since first pulse for accurate alignment - elapsed_time = sum(p.duration for p in state.pulse_buffer) / 1000.0 - base_time = state.start_time + elapsed_time - - # --- Refactored: Phase-locked, envelope-synchronized pulse train generation --- - # 1. Determine envelope period and average pulse frequency - envelope = self.shared_envelope - envelope_period = self.shared_envelope_period - min_duration = frequency_to_duration(max_freq) - max_duration = frequency_to_duration(min_freq) - - # 2. Determine how many pulses fit in one envelope period - # Use the average pulse frequency (Hz) to determine N - avg_pulse_freq = self.params.pulse_frequency.interpolate(current_time) - if avg_pulse_freq <= 0: - avg_pulse_freq = 1.0 # Prevent div by zero - N = max(1, int(round(envelope_period * avg_pulse_freq))) - - # 3. Generate pulses for enough envelope periods to fill the buffer - pulses_to_generate = new_pulses_needed - period_idx = 0 - pulses_generated = 0 - while pulses_to_generate > 0: - envelope_start_time = base_time + period_idx * envelope_period - for i in range(N): - if pulses_to_generate <= 0: - break - # Phase-locked: continue from previous phase - phase = (state.envelope_phase + pulses_generated / N) % 1.0 - pulse_time = envelope_start_time + phase * envelope_period - subtle_jitter = 0.0 - pulse_time_jittered = pulse_time + subtle_jitter - carrier_freq = self.params.carrier_frequency.interpolate(pulse_time_jittered) - pulse_freq = self.params.pulse_frequency.interpolate(pulse_time_jittered) - pulse_width = self.params.pulse_width.interpolate(pulse_time_jittered) - pulse_rise_time = self.params.pulse_rise_time.interpolate(pulse_time_jittered) - pulse_interval_random = self.params.pulse_interval_random.interpolate(pulse_time_jittered) - carrier_freq = np.clip(carrier_freq, self.min_carrier_freq, self.max_carrier_freq) - pulse_freq = np.clip(pulse_freq, self.min_pulse_freq, self.max_pulse_freq) - pulse_width = np.clip(pulse_width, limits.PulseWidth.min, limits.PulseWidth.max) - pulse_rise_time = np.clip(pulse_rise_time, limits.PulseRiseTime.min, limits.PulseRiseTime.max) - carrier_norm = (carrier_freq - self.min_carrier_freq) / self.carrier_freq_range if self.carrier_freq_range > 0 else 0.5 - pulse_norm = (pulse_freq - self.min_pulse_freq) / self.pulse_freq_range if self.pulse_freq_range > 0 else 0.5 - env_idx = int(phase * (len(envelope) - 1)) - env_value = envelope[env_idx] if len(envelope) > 0 else 1.0 - pulse = self._generate_single_pulse( - base_time=pulse_time_jittered, - pulse_index=i, - base_intensity=intensity, - envelope=envelope, - envelope_period=envelope_period, - pulse_freq=pulse_freq, - carrier_freq=carrier_freq, - pulse_width=pulse_width, - pulse_rise_time=pulse_rise_time, - min_duration=min_duration, - max_duration=max_duration, - pulse_interval_random=pulse_interval_random, - carrier_norm=carrier_norm, - pulse_norm=pulse_norm - ) - state.pulse_buffer.append(pulse) - pulses_to_generate -= 1 - pulses_generated += 1 - period_idx += 1 - # Update envelope phase for continuity - state.envelope_phase = (state.envelope_phase + pulses_generated / N) % 1.0 - # --- End refactor --- - - - if initialize: - logger.info(f"Generated initial buffer with {len(state.pulse_buffer)} pulses") - else: - logger.debug(f"Added {new_pulses_needed} pulses to buffer, now has {len(state.pulse_buffer)} pulses") - - return state - - def _get_channel_packet(self, channel_id: str, current_time: float, intensity: int, channel_params): - """ - Get a packet of pulses for a channel, handling buffer management. - - This method is conceptually similar to how the pulse-based algorithm - gets audio samples from its buffer, but adapted for the packet-based - nature of the Coyote protocol. - - Args: - channel_id: 'A' or 'B' channel identifier - current_time: Current system time in seconds - intensity: Intensity for this channel (0-100) - channel_params: Channel-specific parameters - Returns: - Tuple of (list of pulses for this packet, next update time in seconds) - """ - channel_state = self.channel_states[channel_id] - - # Initialize channel state if needed - if channel_state is None: - logger.info(f"First initialization for channel {channel_id}") - channel_state = self._fill_channel_buffer( - channel_id, current_time, intensity, channel_params, initialize=True - ) - self.channel_states[channel_id] = channel_state + +class CoyoteAlgorithm: + """New Coyote pulse generation algorithm using a continuous signal model.""" + def __init__(self, media: AbstractMediaSync, params: CoyoteAlgorithmParams, safety_limits: SafetyParams, + carrier_freq_limits: Tuple[float, float], pulse_width_limits: Tuple[float, float], + pulse_rise_time_limits: Tuple[float, float]): + self.media = media + self.params = params + self.calibration = ThreePhaseCenterCalibration(params.calibrate) + self.position = ThreePhasePosition(params.position, params.transform) + + self.signal_a = ContinuousSignal(params, params.channel_a, carrier_freq_limits, pulse_width_limits, + pulse_rise_time_limits) + self.signal_b = ContinuousSignal(params, params.channel_b, carrier_freq_limits, pulse_width_limits, + pulse_rise_time_limits) + + self.channel_a = ChannelState() + self.channel_b = ChannelState() + + self.start_time = None + self.last_update_time_s = 0.0 + self.next_update_time = 0.0 + + def _get_positional_intensities(self, t: float, volume: float) -> Tuple[int, int]: + """Barycentric phase diagram mapping: (beta, alpha) with left=+1, right=-1, neutral=+1 (top).""" + alpha, beta = self.position.get_position(t) + + # Barycentric weights for triangle corners + w_L = max(0.0, (beta + 1) / 2) + w_R = max(0.0, (1 - beta) / 2) + w_N = max(0.0, alpha) + sum_w = w_L + w_R + w_N + if sum_w > 0: + w_L /= sum_w + w_R /= sum_w + w_N /= sum_w else: - # Calculate elapsed time since last update - elapsed_time_ms = (current_time - channel_state.start_time) * 1000.0 - logger.debug(f"Channel {channel_id}: advancing time by {elapsed_time_ms:.1f}ms") - - # Update buffer based on elapsed time - needs_more_pulses = channel_state.advance_time(elapsed_time_ms) - - # Reset start time for future calculations - channel_state.start_time = current_time - - # If buffer is low, fill it - if needs_more_pulses: - if channel_state.is_empty: - logger.warning(f"Channel {channel_id}: Buffer is empty! Reinitializing.") - channel_state = self._fill_channel_buffer( - channel_id, current_time, intensity, channel_params, initialize=True - ) - self.channel_states[channel_id] = channel_state - else: - logger.debug(f"Channel {channel_id}: Filling buffer (currently has {len(channel_state.pulse_buffer)} pulses)") - channel_state = self._fill_channel_buffer( - channel_id, current_time, intensity, channel_params, initialize=False - ) - - # Ensure we have enough pulses for a packet - available_pulses = len(channel_state.pulse_buffer) - - assert available_pulses >= COYOTE_PULSES_PER_PACKET, \ - f"Not enough pulses available for channel {channel_id}: have {available_pulses}, need {COYOTE_PULSES_PER_PACKET}" - - # Take pulses for this packet - packet_pulses = [channel_state.pulse_buffer.popleft() for _ in range(COYOTE_PULSES_PER_PACKET)] - - # Calculate when we should check back based on the pulse durations - packet_duration_ms = sum(p.duration for p in packet_pulses) - - # We want to update before the packet is completely played - # This ensures smooth transitions between packets - margin_factor = 0.8 # Update after 80% of the packet duration - next_update = current_time + (packet_duration_ms * margin_factor / 1000.0) - next_update_in_ms = packet_duration_ms * margin_factor - - logger.debug(f"Channel {channel_id}: Packet with {len(packet_pulses)} pulses, " - f"duration={packet_duration_ms:.1f}ms, next update in {next_update_in_ms:.1f}ms") - - return packet_pulses, next_update - + w_L = w_R = w_N = 0.0 + + # Calibration scaling + center_val = self.params.calibrate.center.last_value() + center_calib = ThreePhaseCenterCalibration(center_val) + scale = center_calib.get_scale(alpha, beta) + + # Channel mapping: A = left+neutral, B = right+neutral + intensity_a = (w_L + w_N) * volume * scale + intensity_b = (w_R + w_N) * volume * scale + + result_a = int(np.clip(intensity_a * 100, 0, 100)) + result_b = int(np.clip(intensity_b * 100, 0, 100)) + + return result_a, result_b + + def generate_packet(self, current_time: float) -> CoyotePulses: """ Generate one packet of pulses for both channels. - - This method is the main entry point for generating Coyote pulse packets. - It serves a similar role to the generate_audio method in the pulse-based algorithm, - but adapted for the Coyote's packet-based protocol. - - Args: - current_time: Current system time in seconds - Returns: - CoyotePulses object containing pulses for both channels + + This function is called periodically to generate a new set of pulses for both channels (A and B). + It advances the channel state, checks if a new packet is needed, and then generates and logs pulse details. """ - self.seq += 1 - - # Set start time if this is the first call - if self.start_time == 0: - self.start_time = current_time - - time_since_start_ms = (current_time - self.start_time) * 1000 - logger.debug(f"\n=== Generating packet #{self.seq} in {time_since_start_ms:.1f}ms ===") - - # Get position and volume (same as pulse-based algorithm) - alpha, beta = self.position_params.get_position(current_time) - volume = compute_volume(self.media, self.params.volume, current_time) - - # Process each channel independently - channel_pulses = {} - channel_next_updates = {} - - for channel_id, channel_params in [('A', self.params.channel_a), ('B', self.params.channel_b)]: - # Calculate intensity for this channel based on position - intensity = self._compute_channel_intensity( - channel_id, - alpha, - beta, - volume - ) - - # Get pulses for this channel - pulses, next_update = self._get_channel_packet( - channel_id, - current_time, - intensity, - channel_params - ) - - # Store results - channel_pulses[channel_id] = pulses - channel_next_updates[channel_id] = next_update - - # Calculate next update time based on shortest channel duration (earliest next update) - next_update_time = min(channel_next_updates.values()) - update_in_ms = (next_update_time - current_time) * 1000 - - # Create final pulse packet - result = CoyotePulses(channel_pulses['A'], channel_pulses['B']) - - # Log details using relative time - logger.debug(f" Position: alpha={alpha:.2f}, beta={beta:.2f}, volume={volume:.2f}") - logger.debug(f" Next update in {update_in_ms:.1f}ms") - - # Store for later reference - self.last_pulses = result - self.next_update_time = next_update_time - - return result + # --- Timing Management --- + margin = 0.8 # Request next packet after 80% of this one has played + + # Initialize last update time if first call + if self.last_update_time_s == 0.0: + self.last_update_time_s = current_time + + # Calculate elapsed time since last packet + delta_time_s = current_time - self.last_update_time_s + self.last_update_time_s = current_time + + # --- Channel State Advancement --- + # Advance the state of each channel by the elapsed time (in ms) + self.channel_a.advance_time(delta_time_s * 1000.0) + self.channel_b.advance_time(delta_time_s * 1000.0) + + # --- Packet Generation Condition --- + # Generate a new packet only if either channel is ready + if self.channel_a.is_ready_for_next_packet() or self.channel_b.is_ready_for_next_packet(): + t = current_time + use_media_time = False + media_type = 'media' + + if hasattr(self.media, 'media_type'): + media_type = str(getattr(self.media, 'media_type')) + elif self.media.__class__.__name__.lower().startswith('internal'): + media_type = 'internal' + elif 'vlc' in self.media.__class__.__name__.lower(): + media_type = 'vlc' + elif 'mpv' in self.media.__class__.__name__.lower(): + media_type = 'mpv' + else: + media_type = self.media.__class__.__name__.lower() + comment = media_type + + try: + if hasattr(self.media, 'is_playing') and self.media.is_playing() and hasattr(self.media, 'map_timestamp') and media_type != 'internal': + rel_time_s = self.media.map_timestamp(time.time()) + if rel_time_s is not None and rel_time_s >= 0: + use_media_time = True + except Exception: + pass + + if use_media_time: + # rel_time_s is set from map_timestamp + hours = int(rel_time_s // 3600) + minutes = int((rel_time_s % 3600) // 60) + seconds = int(rel_time_s % 60) + millis = int((rel_time_s - int(rel_time_s)) * 1000) + elif media_type == 'internal': + now = time.localtime() + hours = now.tm_hour + minutes = now.tm_min + seconds = now.tm_sec + millis = int((time.time() - int(time.time())) * 1000) + else: + if self.start_time is None: + self.start_time = t + rel_time_s = t - self.start_time + hours = int(rel_time_s // 3600) + minutes = int((rel_time_s % 3600) // 60) + seconds = int(rel_time_s % 60) + millis = int((rel_time_s - int(rel_time_s)) * 1000) + + alpha, beta = self.position.get_position(t) + volume = compute_volume(self.media, self.params.volume, t) + intensity_a, intensity_b = self._get_positional_intensities(t, volume) + + # --- Channel A Pulse Generation --- + pulses_a: List[CoyotePulse] = [] + time_advanced_in_packet_ms = 0.0 + for i in range(COYOTE_PULSES_PER_PACKET): + t_pulse = t + (time_advanced_in_packet_ms / 1000.0) + pulse = self.signal_a.get_pulse_at(t_pulse, intensity_a, i+1) + pulses_a.append(pulse) + time_advanced_in_packet_ms += pulse.duration + total_a = sum(p.duration for p in pulses_a) + + # --- Channel B Pulse Generation --- + pulses_b: List[CoyotePulse] = [] + time_advanced_in_packet_ms = 0.0 + for i in range(COYOTE_PULSES_PER_PACKET): + t_pulse = t + (time_advanced_in_packet_ms / 1000.0) + pulse = self.signal_b.get_pulse_at(t_pulse, intensity_b, i+1) + pulses_b.append(pulse) + time_advanced_in_packet_ms += pulse.duration + total_b = sum(p.duration for p in pulses_b) + + # Build the entire debug log as a single string + log_lines = [] + log_lines.append("") + log_lines.append(f"=== Generating packet at {hours:02}:{minutes:02}:{seconds:02}:{millis:03} === [{comment}]") + log_lines.append(f" Position: alpha={alpha:.2f}, beta={beta:.2f}, volume={volume:.2f}") + log_lines.append(f"Channel A ({total_a} ms):") + for i, pulse in enumerate(pulses_a): + log_lines.append(f" Pulse {i+1}: duration={pulse.duration} ms, freq={pulse.frequency} Hz, intensity={pulse.intensity}%") + log_lines.append("") + log_lines.append(f"Channel B ({total_b} ms):") + for i, pulse in enumerate(pulses_b): + log_lines.append(f" Pulse {i+1}: duration={pulse.duration} ms, freq={pulse.frequency} Hz, intensity={pulse.intensity}%") + logger.debug("\n".join(log_lines)) + + # --- Schedule next update based on shortest channel duration --- + # Use a margin to ensure timely updates before packet end + time_a = sum(p.duration / 1000.0 for p in pulses_a) + time_b = sum(p.duration / 1000.0 for p in pulses_b) + + self.next_update_time = t + min(time_a, time_b) * margin + + return CoyotePulses(pulses_a, pulses_b) + else: + # Not ready: schedule next update based on remaining time + next_update_in_ms = min(self.channel_a.get_remaining_time_ms(), self.channel_b.get_remaining_time_ms()) + self.next_update_time = current_time + (next_update_in_ms / 1000.0) * margin + + return CoyotePulses([], []) + + + def get_next_update_time(self) -> float: + return self.next_update_time def get_envelope_data(self) -> Tuple[np.ndarray, float]: - """ - Get the current (shared) envelope data for both channels. - Returns: - Tuple of (envelope array, envelope period in seconds) - If no data is available, returns (empty array, 0) - """ - # Always return a high-resolution preview envelope for UI widgets - # Use current time and interpolated parameters for preview - t = time.time() - pulse_freq = self.params.pulse_frequency.interpolate(t) - carrier_freq = self.params.carrier_frequency.interpolate(t) - preview_points = 100 - preview_pulses = 6 - envelope, period = generate_envelope( - t=t, - pulse_freq=pulse_freq, - carrier_freq=carrier_freq, - num_points=preview_points, - preview_pulses=preview_pulses - ) - return envelope, period \ No newline at end of file + return np.array([]), 0.0 diff --git a/device/coyote/device.py b/device/coyote/device.py index c845561..1fde790 100644 --- a/device/coyote/device.py +++ b/device/coyote/device.py @@ -443,8 +443,7 @@ async def update_loop(self): continue current_time = time.time() - logger.debug(f"Update loop iteration at {current_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) await self.send_command(pulses=pulses) diff --git a/qt_ui/algorithm_factory.py b/qt_ui/algorithm_factory.py index 1f1f1c3..5487961 100644 --- a/qt_ui/algorithm_factory.py +++ b/qt_ui/algorithm_factory.py @@ -259,9 +259,8 @@ def create_coyote(self, device: DeviceConfiguration) -> AudioGenerationAlgorithm """ # Fetch frequency axes - carrier_frequency = self.get_axis_from_script_mapping(AxisEnum.CARRIER_FREQUENCY, limits=(0, 100)) - pulse_frequency = self.get_axis_from_script_mapping(AxisEnum.PULSE_FREQUENCY, limits=(0, 100)) - fallback_frequency = create_constant_axis(100) + carrier_frequency = self.get_axis_from_script_mapping(AxisEnum.CARRIER_FREQUENCY)#, limits=(0, 100)) + pulse_frequency = self.get_axis_from_script_mapping(AxisEnum.PULSE_FREQUENCY)#, limits=(0, 100)) # Fetch pulse shape axes (always needed, so provide defaults: 100% width, rise: 0%) # pulse_width = self.get_axis_from_script_mapping(AxisEnum.PULSE_WIDTH, limits=(0, 100)) or create_constant_axis(100) @@ -276,7 +275,8 @@ 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( @@ -317,7 +317,8 @@ def create_coyote(self, device: DeviceConfiguration) -> AudioGenerationAlgorithm 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 From 039400f8da9560db782af9007005e9b5b2e0dbaf Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Tue, 12 Aug 2025 14:29:30 +0700 Subject: [PATCH 04/47] Update Coyote algorithm (experimental) --- device/coyote/algorithm.py | 759 ++++++++++++++++++++++------------ qt_ui/algorithm_factory.py | 29 +- requirements.txt | 1 + stim_math/audio_gen/params.py | 4 +- 4 files changed, 502 insertions(+), 291 deletions(-) diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py index 083b3f3..bbb4beb 100644 --- a/device/coyote/algorithm.py +++ b/device/coyote/algorithm.py @@ -1,11 +1,18 @@ """ -New Coyote 3.0 E-Stim Algorithm - Direct Control Model +DG-LAB Coyote 3.0 E-Stim Algorithm Implementation -This algorithm is a from-scratch redesign inspired by the Neostim architecture, -but tailored specifically for the DG-LAB Coyote 3.0's hardware constraints. +This algorithm controls a Coyote 3.0 dual-channel e-stim device using a symmetric ramp envelope +model that matches the pulse-based audio algorithm behavior while working within hardware constraints. + +Hardware Specifications: +----------------------- +- Two independent channels (A and B) +- Each pulse: intensity (0-100%), duration (5-240ms) +- Protocol: 4 pulses per packet +- Device repeats last packet until new one arrives + + -It abandons the previous audio-emulation approach in favor of direct parameter -control, aiming for a more faithful and responsive funscript experience. """ import logging @@ -76,166 +83,261 @@ def get_remaining_time_ms(self) -> float: return max(0.0, self.total_packet_duration_ms - self.time_in_packet_ms) -class WaveGenerator: - """Generates a continuous, asymmetric triangle wave based on pulse frequency and rise time.""" - def __init__(self, pulse_frequency_axis: AbstractAxis, pulse_rise_time_axis: AbstractAxis, - pulse_rise_time_limits: Tuple[float, float]): - self.pulse_frequency_axis = pulse_frequency_axis - self.pulse_rise_time_axis = pulse_rise_time_axis - self.pulse_rise_time_limits = pulse_rise_time_limits - self.phase = 0.0 - self.rise_pct = 0.5 # Default to a symmetric wave - - def advance(self, t: float, delta_time_ms: float): - pulse_freq_hz = self.pulse_frequency_axis.interpolate(t) - if pulse_freq_hz <= 0: - self.phase = 0.0 - return - - # Advance the phase based on the pulse frequency - self.phase += (delta_time_ms / 1000.0) * pulse_freq_hz - self.phase %= 1.0 - - # Update the rise percentage based on the pulse_rise_time axis - rise_time_val = self.pulse_rise_time_axis.interpolate(t) - min_rise, max_rise = self.pulse_rise_time_limits - rise_range = max_rise - min_rise +# --- Envelope Generators --- - # Normalize rise time to a 0-1 percentage for the wave shape - normalized_rise = (rise_time_val - min_rise) / rise_range if rise_range > 0 else 0.5 - normalized_rise = np.clip(normalized_rise, 0.0, 1.0) +def generate_ramp_envelope(attack: float, plateau: float, release: float, num_points: int) -> np.ndarray: + """Generate a symmetric ramp (triangle/trapezoid) envelope 0→1→0. - # We don't want the rise/fall to be instantaneous, so map 0-1 to a safer range, e.g., 0.01 to 0.99 - self.rise_pct = 0.01 + normalized_rise * 0.98 + attack: seconds from 0→1 + plateau: seconds at level 1 between attack and release (may be 0) + release: seconds from 1→0 + """ + total = attack + plateau + release + if total <= 0 or num_points < 2: + return np.zeros(num_points) - def get_value(self) -> float: - """Returns the current wave value, from -1.0 to 1.0.""" - if self.rise_pct <= 0.0: return -1.0 - if self.rise_pct >= 1.0: return 1.0 + a_pts = max(1, int(round(num_points * attack / total))) + p_pts = max(0, int(round(num_points * plateau / total))) + r_pts = num_points - a_pts - p_pts + ascent = np.linspace(0, 1, a_pts, endpoint=False) + plateau_arr = np.ones(p_pts) + descent = np.linspace(1, 0, r_pts, endpoint=True) + return np.concatenate([ascent, plateau_arr, descent]) - if self.phase < self.rise_pct: - # Rising part of the wave - return -1.0 + (self.phase / self.rise_pct) * 2.0 - else: - # Falling part of the wave - fall_phase = self.phase - self.rise_pct - fall_duration_pct = 1.0 - self.rise_pct - if fall_duration_pct <= 0: return -1.0 - return 1.0 - (fall_phase / fall_duration_pct) * 2.0 +def _normalize_axis(value: float, limits: Tuple[float, float]) -> float: + """Normalize a raw axis value to a 0-100 scale based on its limits.""" + min_val, max_val = limits + if max_val <= min_val: + return 0.0 + return (value - min_val) / (max_val - min_val) * 100.0 + + +def _get_normalized_parameters(params: CoyoteAlgorithmParams, t: float, + carrier_freq_limits: Tuple[float, float], + pulse_freq_limits: Tuple[float, float]) -> Tuple[float, float, float, float]: + """Get parameter values: normalize frequency axes (Hz), return raw cycle values.""" + carrier_freq_raw = params.carrier_frequency.interpolate(t) + pulse_freq_raw = params.pulse_frequency.interpolate(t) + + # Normalize frequency axes from Hz ranges + carrier_freq = _normalize_axis(carrier_freq_raw, carrier_freq_limits) + pulse_freq = _normalize_axis(pulse_freq_raw, pulse_freq_limits) + + # Return raw cycle values for pulse_width and pulse_rise_time + pulse_width_cycles = params.pulse_width.interpolate(t) + pulse_rise_time_cycles = params.pulse_rise_time.interpolate(t) + + return carrier_freq, pulse_freq, pulse_width_cycles, pulse_rise_time_cycles class ContinuousSignal: - """Models the complete, time-aware signal for a single channel based on the 'wave' model.""" + """Models a single channel's pulse generation using symmetric ramp envelopes. + + Generates pulses whose frequency *and intensity* are driven by a shared sine-wave LFO. + The LFO parameters come from the `pulse_*` axes so UI preview and runtime stay in sync. + Base intensity from volume/position is multiplied by an LFO factor (±50 % max) to + create a smooth pulsing effect while frequency receives the same LFO swing. + """ + + ENVELOPE_RESOLUTION = 200 # Number of points in envelope lookup table + + def __init__(self, params: CoyoteAlgorithmParams, channel_params: 'CoyoteChannelParams', - carrier_freq_limits: Tuple[float, float], pulse_width_limits: Tuple[float, float], - pulse_rise_time_limits: Tuple[float, float]): + 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]): self.params = params self.channel_params = channel_params - self.pulse_rise_time_limits = pulse_rise_time_limits + self.carrier_freq_limits = carrier_freq_limits + self.pulse_freq_limits = pulse_freq_limits self.pulse_width_limits = pulse_width_limits - self.wave = WaveGenerator(params.pulse_frequency, params.pulse_rise_time, pulse_rise_time_limits) - self.last_pulse_time_s = 0.0 - self.start_time = None - - def get_pulse_at(self, t: float, base_intensity: float, pulse_index: int = 0) -> CoyotePulse: - """ - Generate a pulse for this channel at time t. - base_intensity should be in the range 0-100 (int or float), as per position/intensity logic. - This method normalizes to [0,1] internally for all calculations. + self.pulse_rise_time_limits = pulse_rise_time_limits + + # Timing state + self._last_pulse_time = 0.0 + self._start_time = None + self.modulation_phase = 0.0 + + # Envelope cache + self._envelope_lookup_table = None + self._cached_envelope_params = None + self._envelope_period = 0.0 + + def _calculate_effective_frequency_limits(self) -> Tuple[float, float]: + """Calculate effective frequency limits considering hardware constraints.""" + channel_min = self.channel_params.minimum_frequency.get() + channel_max = self.channel_params.maximum_frequency.get() + hardware_max = 1000.0 / COYOTE_MIN_PULSE_DURATION # ~200 Hz + hardware_min = 1000.0 / COYOTE_MAX_PULSE_DURATION # ~4.17 Hz + + effective_min = max(channel_min, hardware_min) + effective_max = min(channel_max, hardware_max) + + # Fallback to hardware limits if channel limits are invalid + if effective_min >= effective_max: + effective_min = hardware_min + effective_max = hardware_max + + return effective_min, effective_max + + def _apply_frequency_randomization(self, base_frequency: float, randomization_strength: float, + min_freq: float, max_freq: float) -> float: + """Apply limited randomization to frequency.""" + if randomization_strength <= 0: + return base_frequency + + # Limit randomization to 10% of the setting + random_percentage = randomization_strength / 100.0 * 0.1 + random_factor = 1.0 + (np.random.rand() - 0.5) * 2 * random_percentage + randomized_freq = base_frequency * random_factor + + return np.clip(randomized_freq, min_freq, max_freq) + + def get_pulse_at(self, current_time: float, base_intensity: float, pulse_index: int = 0) -> CoyotePulse: + """Generate a pulse using sine-wave frequency modulation. + + - carrier_frequency: Sets the base frequency. + - pulse_frequency: Controls the speed of the frequency modulation (modulation frequency). + - pulse_width: Controls the depth of the frequency modulation. + - pulse_rise_time: Adds jitter/randomness to the frequency. + - intensity: Starts with volume × position distribution and is then multiplied by the + same sine-wave factor used for frequency, giving ±50 % pulsing around the base value. """ - delta_time_ms = (t - self.last_pulse_time_s) * 1000.0 if self.last_pulse_time_s > 0 else 0 - self.last_pulse_time_s = t - self.wave.advance(t, delta_time_ms) - - if self.start_time is None: - self.start_time = t - - # --- Get base parameters from axes --- - carrier_freq = self.params.carrier_frequency.interpolate(t) - pulse_width = self.params.pulse_width.interpolate(t) - random_strength = self.params.pulse_interval_random.interpolate(t) - - # --- Calculate channel-specific frequency parameters --- - min_freq = self.channel_params.minimum_frequency.get() - max_freq = self.channel_params.maximum_frequency.get() - midpoint_freq = (min_freq + max_freq) / 2.0 - max_deviation = (max_freq - min_freq) / 2.0 - - # --- Build the wave that modulates the sensation frequency --- - # 1. Normalize the raw carrier frequency (e.g., 500-1000Hz) to a 0-1 percentage - min_carrier, max_carrier = self.pulse_width_limits - carrier_range = max_carrier - min_carrier - carrier_pct = (carrier_freq - min_carrier) / carrier_range if carrier_range > 0 else 0 - carrier_pct = np.clip(carrier_pct, 0.0, 1.0) - - # 2. Amplitude of the wave is controlled by this correct percentage - amplitude = max_deviation * carrier_pct - - # 3. Get current position on the wave (-1 to 1) - wave_value = self.wave.get_value() - - # 4. The final modulated frequency is the midpoint offset by the wave - sensation_freq = midpoint_freq + (wave_value * amplitude) - - # --- Calculate Final Pulse Parameters --- - # The final frequency is determined by the raw sensation frequency, scaled by the duty cycle (pulse_width) - # and random jitter. This effective frequency is then clipped to the channel's limits. - - # 1. Calculate scaling factors - min_pw, max_pw = self.pulse_width_limits - pw_range = max_pw - min_pw - norm_pulse_width = (pulse_width - min_pw) / pw_range if pw_range > 0 else 0.5 - norm_pulse_width = np.clip(norm_pulse_width, 0.0, 1.0) - - # Remap the normalized pulse width to a scaler that modulates frequency. A wider pulse (higher norm_pulse_width) - # should result in a lower frequency (longer duration), so it needs a larger scaler. - # We map [0,1] to [0.75, 1.25] for a +/- 25% modulation. This could be a user-configurable parameter. - pulse_width_scaler = 0.75 + (norm_pulse_width * 0.5) - - random_multiplier = 1.0 - if random_strength > 0: - random_pct = random_strength / 100.0 - random_jitter = (np.random.rand() - 0.5) * 2 * random_pct - random_multiplier = 1.0 + random_jitter - - # 2. Calculate the effective frequency after scaling. A shorter pulse width (duty cycle) leads to a higher frequency. - effective_freq = sensation_freq / (pulse_width_scaler * random_multiplier) - - # 3. Clip the effective frequency to the channel's configured min/max range. - target_freq = np.clip(effective_freq, min_freq, max_freq) - target_freq = max(0.1, target_freq) - - # 4. Calculate the duration from the target frequency, then clip it to the hardware's absolute limits. - target_duration_ms = 1000.0 / target_freq - final_duration = int(np.clip(target_duration_ms, COYOTE_MIN_PULSE_DURATION, COYOTE_MAX_PULSE_DURATION)) - - # 5. Recalculate the final frequency from the actual final duration. This ensures perfect consistency - # between the frequency and duration values, respecting hardware limits above all. - final_freq = 1000.0 / final_duration if final_duration > 0 else 0 - - effective_intensity = int(np.clip(base_intensity, 0, 100)) - pulse = CoyotePulse( - frequency=int(final_freq), - intensity=effective_intensity, - duration=final_duration + # --- Timing and State Update --- + if self._start_time is None: + self._start_time = current_time + delta_time = current_time - self._last_pulse_time + self._last_pulse_time = current_time + + # Advance modulation phase by the elapsed time since the previous pulse + _, pulse_freq_norm, _, _ = _get_normalized_parameters( + self.params, current_time, self.carrier_freq_limits, self.pulse_freq_limits) + phase_increment = delta_time * 2 * np.pi * (pulse_freq_norm / 100.0) * 10.0 # same scaling as scheduler + self.modulation_phase += phase_increment + + # --- Get Parameters --- + # carrier_frequency: raw Hz value (normalized to 0-100% range) + # pulse_frequency: raw Hz value (normalized to 0-100% range) + # pulse_width: raw carrier cycles (used directly) + # pulse_rise_time: raw carrier cycles (used directly) + carrier_freq_norm, mod_speed_norm, _, _ = _get_normalized_parameters( + self.params, current_time, self.carrier_freq_limits, self.pulse_freq_limits) + + # Use raw carrier cycle values directly + mod_depth_cycles = self.params.pulse_width.interpolate(current_time) + jitter_cycles = self.params.pulse_rise_time.interpolate(current_time) + + # Debug parameter values + print(f"[DEBUG] RAW: carrier={self.params.carrier_frequency.interpolate(current_time):.2f}Hz " + f"pulse_freq={self.params.pulse_frequency.interpolate(current_time):.2f}Hz " + f"pulse_width={mod_depth_cycles:.2f}cycles " + f"pulse_rise_time={jitter_cycles:.2f}cycles") + print(f"[DEBUG] NORMALIZED: carrier={carrier_freq_norm:.1f}% pulse_freq={mod_speed_norm:.1f}%") + randomization_strength = self.params.pulse_interval_random.interpolate(current_time) + + # --- Base Frequency Calculation --- + min_freq, max_freq = self._calculate_effective_frequency_limits() + base_frequency = min_freq + (carrier_freq_norm / 100.0) * (max_freq - min_freq) + base_frequency = np.clip(base_frequency, min_freq, max_freq) + + # --- Duration Modulation (Sine Wave) --- + # We now drive duration directly so every pulse has at least 1 ms difference. + base_duration = int(1000.0 / base_frequency) + # Channel-specific duration limits derived from frequency limits + min_dur = max(COYOTE_MIN_PULSE_DURATION, int(round(1000.0 / max_freq))) + max_dur = min(COYOTE_MAX_PULSE_DURATION, int(round(1000.0 / min_freq))) + + # --- Duration Modulation (Sine Wave) --- + # Use raw carrier cycles directly for modulation depth + # Each cycle = 1ms swing, so 5 cycles = 5ms swing + # This gives us the full range based on actual cycle count + + mod_value = np.sin(self.modulation_phase) + + # Use the full duration range for maximum swing + base_duration = int(round((min_dur + max_dur) / 2)) # centre of the channel range + max_swing = max_dur - base_duration # distance to max boundary + min_swing = min_dur - base_duration # distance to min boundary + + # Ensure full frequency range coverage from min_dur to max_dur + # Map the sine wave directly to the full duration range + + # Calculate the actual swing needed to reach boundaries + # Use the actual distances to min and max boundaries + if mod_value >= 0: + # Positive modulation: scale to max boundary + scaled_swing = max_swing * mod_value + else: + # Negative modulation: scale to min boundary + scaled_swing = min_swing * mod_value + + # The raw cycles are now used for intensity modulation, not range limitation + + # Ensure we hit the exact boundaries + if mod_value >= 0.98: # near maximum + scaled_swing = max_swing + elif mod_value <= -0.98: # near minimum + scaled_swing = min_swing + + # Allow swing to use the full available range regardless of cycle count + # The cycle count now determines modulation depth, not range limitation + + pulse_duration = base_duration + int(round(scaled_swing)) + + # Debug log every value for investigation + print(f"[DEBUG] freq_limits: min={min_freq:.1f} max={max_freq:.1f}") + print(f"[DEBUG] dur_limits: min={min_dur} max={max_dur}") + print(f"[DEBUG] base={base_duration} min_swing={min_swing:.1f} max_swing={max_swing:.1f} depth={mod_depth_cycles:.2f}cycles") + print(f"[DEBUG] mod_value={mod_value:.2f} scaled_swing={scaled_swing:.1f} final_dur={pulse_duration}") + print(f"[DEBUG] final_freq={int(1000.0/pulse_duration)} phase={self.modulation_phase:.2f}") + print("---") + + # Ensure we stay within the channel-specific duration window + pulse_duration = int(np.clip(pulse_duration, min_dur, max_dur)) + + # Clip to hardware limits + pulse_duration = np.clip(pulse_duration, COYOTE_MIN_PULSE_DURATION, COYOTE_MAX_PULSE_DURATION) + + # Derive frequency only for diagnostics + final_frequency = int(1000.0 / pulse_duration) + + # Debug log so we can see the modulation values + print(f"[DEBUG] pulse={pulse_index} base={base_duration} swing={int(round(scaled_swing))} final={pulse_duration} depth={mod_depth_cycles:.2f}cycles phase={self.modulation_phase:.2f}") + + # --- Intensity Modulation (subtle additive modulation) --- + # Apply subtle sine wave modulation as additive to positional intensity + # Use much smaller scaling (1% per cycle instead of 10%) + intensity_mod_depth = mod_depth_cycles * 0.01 # 1% per cycle for subtlety + raw_mod_value = np.sin(self.modulation_phase) # Continuous sine wave + modulation_amount = raw_mod_value * intensity_mod_depth * base_intensity + final_intensity = int(np.clip(base_intensity + modulation_amount, 1, 100)) + + return CoyotePulse( + duration=pulse_duration, + intensity=final_intensity, + frequency=final_frequency ) - return pulse + + class CoyoteAlgorithm: - """New Coyote pulse generation algorithm using a continuous signal model.""" + """Coyote 3.0 pulse generation algorithm with symmetric ramp envelope modulation. + + Coordinates dual-channel pulse generation using ContinuousSignal instances. + Handles packet timing, positional intensity distribution, and envelope preview. + """ def __init__(self, media: AbstractMediaSync, params: CoyoteAlgorithmParams, safety_limits: SafetyParams, - carrier_freq_limits: Tuple[float, float], pulse_width_limits: Tuple[float, float], - pulse_rise_time_limits: Tuple[float, float]): + 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]): self.media = media self.params = params self.calibration = ThreePhaseCenterCalibration(params.calibrate) self.position = ThreePhasePosition(params.position, params.transform) - self.signal_a = ContinuousSignal(params, params.channel_a, carrier_freq_limits, pulse_width_limits, - pulse_rise_time_limits) - self.signal_b = ContinuousSignal(params, params.channel_b, carrier_freq_limits, pulse_width_limits, - pulse_rise_time_limits) + self.signal_a = ContinuousSignal(params, params.channel_a, carrier_freq_limits, pulse_freq_limits, + pulse_width_limits, pulse_rise_time_limits) + self.signal_b = ContinuousSignal(params, params.channel_b, carrier_freq_limits, pulse_freq_limits, + pulse_width_limits, pulse_rise_time_limits) self.channel_a = ChannelState() self.channel_b = ChannelState() @@ -244,6 +346,10 @@ def __init__(self, media: AbstractMediaSync, params: CoyoteAlgorithmParams, safe self.last_update_time_s = 0.0 self.next_update_time = 0.0 + # UI Preview Cache + self._cached_envelope = np.full(200, 0.5) # Default to a flat line + self._cached_envelope_period = 0.0 + def _get_positional_intensities(self, t: float, volume: float) -> Tuple[int, int]: """Barycentric phase diagram mapping: (beta, alpha) with left=+1, right=-1, neutral=+1 (top).""" alpha, beta = self.position.get_position(t) @@ -266,142 +372,271 @@ def _get_positional_intensities(self, t: float, volume: float) -> Tuple[int, int scale = center_calib.get_scale(alpha, beta) # Channel mapping: A = left+neutral, B = right+neutral - intensity_a = (w_L + w_N) * volume * scale - intensity_b = (w_R + w_N) * volume * scale + intensity_a = int((w_L + w_N) * volume * scale * 100.0) + intensity_b = int((w_R + w_N) * volume * scale * 100.0) + + return intensity_a, intensity_b + + def _generate_channel_pulses(self, t: float, signal: 'ContinuousSignal', + channel_name: str) -> Tuple[List[CoyotePulse], float]: + """Generate pulses for a single channel, eliminating code duplication.""" + pulses: List[CoyotePulse] = [] + time_advanced_in_packet_ms = 0.0 + + for i in range(COYOTE_PULSES_PER_PACKET): + t_pulse = t + (time_advanced_in_packet_ms / 1000.0) + volume = compute_volume(self.media, self.params.volume, t_pulse) + intensity_a, intensity_b = self._get_positional_intensities(t_pulse, volume) + + # Select appropriate intensity based on channel + intensity = intensity_a if channel_name == 'A' else intensity_b + + pulse = signal.get_pulse_at(t_pulse, intensity, i + 1) + pulses.append(pulse) + time_advanced_in_packet_ms += pulse.duration + + total_duration = sum(p.duration for p in pulses) + return pulses, total_duration + + def _get_media_type(self) -> str: + """Determine the media type for logging purposes.""" + if hasattr(self.media, 'media_type'): + return str(getattr(self.media, 'media_type')) + + class_name = self.media.__class__.__name__.lower() + if class_name.startswith('internal'): + return 'internal' + elif 'vlc' in class_name: + return 'vlc' + elif 'mpv' in class_name: + return 'mpv' + else: + return class_name + + def _get_display_time(self, current_time: float) -> Tuple[int, int, int, int]: + """Get formatted time for debug logging.""" + media_type = self._get_media_type() + + # Try to use media timestamp if available + if (media_type != 'internal' and + hasattr(self.media, 'is_playing') and self.media.is_playing() and + hasattr(self.media, 'map_timestamp')): + try: + rel_time_s = self.media.map_timestamp(time.time()) + if rel_time_s is not None and rel_time_s >= 0: + return self._seconds_to_time_components(rel_time_s) + except Exception: + pass + + # Use local time for internal media + if media_type == 'internal': + now = time.localtime() + millis = int((time.time() - int(time.time())) * 1000) + return now.tm_hour, now.tm_min, now.tm_sec, millis + + # Use relative time from start + if self.start_time is None: + self.start_time = current_time + rel_time_s = current_time - self.start_time + return self._seconds_to_time_components(rel_time_s) + + def _seconds_to_time_components(self, seconds: float) -> Tuple[int, int, int, int]: + """Convert seconds to (hours, minutes, seconds, milliseconds).""" + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + millis = int((seconds - int(seconds)) * 1000) + return hours, minutes, secs, millis + + def _advance_channel_states(self, current_time: float, delta_time_ms: float) -> None: + """Advance channel timing and modulation phase for the next packet.""" + self.channel_a.advance_time(delta_time_ms) + self.channel_b.advance_time(delta_time_ms) + + # Get normalized modulation speed + # We use signal_a's limits, assuming they are the same for both channels. + _, mod_speed_norm, _, _ = _get_normalized_parameters( + self.params, current_time, self.signal_a.carrier_freq_limits, self.signal_a.pulse_freq_limits) + + # Calculate phase change based on elapsed time and modulation speed + # This logic is now centralized here from its previous location in get_pulse_at. + delta_time_s = delta_time_ms / 1000.0 + phase_change = (delta_time_s * mod_speed_norm / 10.0) * 2 * np.pi # Simplified from / 50.0 * 5.0 + + # Apply the same phase change to both signals to keep them in sync + self.signal_a.modulation_phase = (self.signal_a.modulation_phase + phase_change) % (2 * np.pi) + self.signal_b.modulation_phase = (self.signal_b.modulation_phase + phase_change) % (2 * np.pi) + + def _is_packet_generation_needed(self) -> bool: + """Check if either channel needs a new packet.""" + return (self.channel_a.is_ready_for_next_packet() or + self.channel_b.is_ready_for_next_packet()) + + def _schedule_next_update(self, current_time: float, packet_duration_a: float, + packet_duration_b: float, margin: float = 0.8) -> None: + """Schedule the next update time based on packet durations.""" + min_duration = min(packet_duration_a, packet_duration_b) + self.next_update_time = current_time + min_duration * margin + + def _log_packet_debug(self, current_time: float, alpha: float, beta: float, + pulses_a: List[CoyotePulse], pulses_b: List[CoyotePulse], + total_duration_a: float, total_duration_b: float) -> None: + """Log debug information for generated packet.""" + hours, minutes, seconds, millis = self._get_display_time(current_time) + media_type = self._get_media_type() + + # Calculate volume for logging (using first pulse time) + volume = compute_volume(self.media, self.params.volume, current_time) + + log_lines = [ + "", + f"=== Generating packet at {hours:02}:{minutes:02}:{seconds:02}:{millis:03} === [{media_type}]", + f" Position: alpha={alpha:.2f}, beta={beta:.2f}, volume={volume:.2f}", + f"Channel A ({total_duration_a:.0f} ms):" + ] + + for i, pulse in enumerate(pulses_a): + log_lines.append(f" Pulse {i+1}: duration={pulse.duration} ms, freq={pulse.frequency} Hz, intensity={pulse.intensity}%") + + log_lines.extend([ + "", + f"Channel B ({total_duration_b:.0f} ms):" + ]) + + for i, pulse in enumerate(pulses_b): + log_lines.append(f" Pulse {i+1}: duration={pulse.duration} ms, freq={pulse.frequency} Hz, intensity={pulse.intensity}%") + + logger.debug("\n".join(log_lines)) + + def _update_envelope_preview(self, t: float): + """ + Generates and caches the UI envelope preview. + This must be called from the same state as pulse generation to ensure sync. + """ + # Get normalized modulation speed and depth + _, pulse_freq, pulse_width, _ = _get_normalized_parameters( + self.params, t, self.signal_a.carrier_freq_limits, self.signal_a.pulse_freq_limits) - result_a = int(np.clip(intensity_a * 100, 0, 100)) - result_b = int(np.clip(intensity_b * 100, 0, 100)) + # The period is the inverse of the modulation frequency (speed). + self._cached_envelope_period = 1.0 / pulse_freq if pulse_freq > 0 else 0.0 - return result_a, result_b + # This visualization must precisely match the runtime logic in get_pulse_at. + num_points = 200 + modulation_depth = pulse_width # This is already normalized to [0, 1] + # To sync the preview with the dots, we must use the phase of the *next* packet. + # The state has already been advanced for the current time `t` in generate_packet. + current_phase = self.signal_a.modulation_phase - def generate_packet(self, current_time: float) -> CoyotePulses: - """ - Generate one packet of pulses for both channels. + # Generate the sine wave for the plot starting from the current phase. + x = np.linspace(current_phase, current_phase + 2 * np.pi, num_points) + modulation_wave = np.sin(x) # Range [-1, 1] - This function is called periodically to generate a new set of pulses for both channels (A and B). - It advances the channel state, checks if a new packet is needed, and then generates and logs pulse details. - """ - # --- Timing Management --- - margin = 0.8 # Request next packet after 80% of this one has played + # Calculate the frequency multiplier, exactly as in get_pulse_at. + frequency_multiplier = 1.0 + modulation_wave * modulation_depth + + # Normalize the multiplier from its runtime range to the [0, 1] range for the UI. + min_mult = 1.0 - modulation_depth + max_mult = 1.0 + modulation_depth + range_mult = max_mult - min_mult + + if range_mult == 0: + # If there's no modulation, the envelope is flat at the midpoint. + self._cached_envelope = np.full(num_points, 0.5) + return + + self._cached_envelope = (frequency_multiplier - min_mult) / range_mult - # Initialize last update time if first call + def generate_packet(self, current_time: float) -> CoyotePulses: + """Generate one packet of pulses for both channels.""" + PACKET_MARGIN = 0.8 # Request next packet after 80% of current one has played + + # Initialize timing on first call if self.last_update_time_s == 0.0: self.last_update_time_s = current_time - # Calculate elapsed time since last packet - delta_time_s = current_time - self.last_update_time_s + # Advance channel states + delta_time_ms = (current_time - self.last_update_time_s) * 1000.0 self.last_update_time_s = current_time + self._advance_channel_states(current_time, delta_time_ms) + + # Check if packet generation is needed + if not self._is_packet_generation_needed(): + # Schedule next update based on remaining time + remaining_time_ms = min( + self.channel_a.get_remaining_time_ms(), + self.channel_b.get_remaining_time_ms() + ) + self.next_update_time = current_time + (remaining_time_ms / 1000.0) * PACKET_MARGIN + return CoyotePulses([], []) - # --- Channel State Advancement --- - # Advance the state of each channel by the elapsed time (in ms) - self.channel_a.advance_time(delta_time_s * 1000.0) - self.channel_b.advance_time(delta_time_s * 1000.0) - - # --- Packet Generation Condition --- - # Generate a new packet only if either channel is ready - if self.channel_a.is_ready_for_next_packet() or self.channel_b.is_ready_for_next_packet(): - t = current_time - use_media_time = False - media_type = 'media' - - if hasattr(self.media, 'media_type'): - media_type = str(getattr(self.media, 'media_type')) - elif self.media.__class__.__name__.lower().startswith('internal'): - media_type = 'internal' - elif 'vlc' in self.media.__class__.__name__.lower(): - media_type = 'vlc' - elif 'mpv' in self.media.__class__.__name__.lower(): - media_type = 'mpv' - else: - media_type = self.media.__class__.__name__.lower() - comment = media_type + # Update the UI preview cache from the current state + self._update_envelope_preview(current_time) - try: - if hasattr(self.media, 'is_playing') and self.media.is_playing() and hasattr(self.media, 'map_timestamp') and media_type != 'internal': - rel_time_s = self.media.map_timestamp(time.time()) - if rel_time_s is not None and rel_time_s >= 0: - use_media_time = True - except Exception: - pass + # Generate pulses for both channels + alpha, beta = self.position.get_position(current_time) + pulses_a, duration_a = self._generate_channel_pulses(current_time, self.signal_a, 'A') + pulses_b, duration_b = self._generate_channel_pulses(current_time, self.signal_b, 'B') - if use_media_time: - # rel_time_s is set from map_timestamp - hours = int(rel_time_s // 3600) - minutes = int((rel_time_s % 3600) // 60) - seconds = int(rel_time_s % 60) - millis = int((rel_time_s - int(rel_time_s)) * 1000) - elif media_type == 'internal': - now = time.localtime() - hours = now.tm_hour - minutes = now.tm_min - seconds = now.tm_sec - millis = int((time.time() - int(time.time())) * 1000) - else: - if self.start_time is None: - self.start_time = t - rel_time_s = t - self.start_time - hours = int(rel_time_s // 3600) - minutes = int((rel_time_s % 3600) // 60) - seconds = int(rel_time_s % 60) - millis = int((rel_time_s - int(rel_time_s)) * 1000) - - alpha, beta = self.position.get_position(t) - volume = compute_volume(self.media, self.params.volume, t) - intensity_a, intensity_b = self._get_positional_intensities(t, volume) - - # --- Channel A Pulse Generation --- - pulses_a: List[CoyotePulse] = [] - time_advanced_in_packet_ms = 0.0 - for i in range(COYOTE_PULSES_PER_PACKET): - t_pulse = t + (time_advanced_in_packet_ms / 1000.0) - pulse = self.signal_a.get_pulse_at(t_pulse, intensity_a, i+1) - pulses_a.append(pulse) - time_advanced_in_packet_ms += pulse.duration - total_a = sum(p.duration for p in pulses_a) - - # --- Channel B Pulse Generation --- - pulses_b: List[CoyotePulse] = [] - time_advanced_in_packet_ms = 0.0 - for i in range(COYOTE_PULSES_PER_PACKET): - t_pulse = t + (time_advanced_in_packet_ms / 1000.0) - pulse = self.signal_b.get_pulse_at(t_pulse, intensity_b, i+1) - pulses_b.append(pulse) - time_advanced_in_packet_ms += pulse.duration - total_b = sum(p.duration for p in pulses_b) - - # Build the entire debug log as a single string - log_lines = [] - log_lines.append("") - log_lines.append(f"=== Generating packet at {hours:02}:{minutes:02}:{seconds:02}:{millis:03} === [{comment}]") - log_lines.append(f" Position: alpha={alpha:.2f}, beta={beta:.2f}, volume={volume:.2f}") - log_lines.append(f"Channel A ({total_a} ms):") - for i, pulse in enumerate(pulses_a): - log_lines.append(f" Pulse {i+1}: duration={pulse.duration} ms, freq={pulse.frequency} Hz, intensity={pulse.intensity}%") - log_lines.append("") - log_lines.append(f"Channel B ({total_b} ms):") - for i, pulse in enumerate(pulses_b): - log_lines.append(f" Pulse {i+1}: duration={pulse.duration} ms, freq={pulse.frequency} Hz, intensity={pulse.intensity}%") - logger.debug("\n".join(log_lines)) - - # --- Schedule next update based on shortest channel duration --- - # Use a margin to ensure timely updates before packet end - time_a = sum(p.duration / 1000.0 for p in pulses_a) - time_b = sum(p.duration / 1000.0 for p in pulses_b) - - self.next_update_time = t + min(time_a, time_b) * margin - - return CoyotePulses(pulses_a, pulses_b) - else: - # Not ready: schedule next update based on remaining time - next_update_in_ms = min(self.channel_a.get_remaining_time_ms(), self.channel_b.get_remaining_time_ms()) - self.next_update_time = current_time + (next_update_in_ms / 1000.0) * margin + # Log debug information + self._log_packet_debug(current_time, alpha, beta, pulses_a, pulses_b, duration_a, duration_b) - return CoyotePulses([], []) + # Schedule next update + self._schedule_next_update(current_time, duration_a / 1000.0, duration_b / 1000.0, PACKET_MARGIN) + + return CoyotePulses(pulses_a, pulses_b) def get_next_update_time(self) -> float: return self.next_update_time def get_envelope_data(self) -> Tuple[np.ndarray, float]: - return np.array([]), 0.0 + """Returns the current envelope shape and its period for UI visualization.""" + t = time.time() + + # Get normalized modulation speed and depth + # Note: We no longer need carrier_freq or rise_time for the preview + _, pulse_freq, pulse_width, _ = _get_normalized_parameters( + self.params, t, self.signal_a.carrier_freq_limits, self.signal_a.pulse_freq_limits) + + # The UI envelope now visualizes the sine-wave frequency modulation. + # The period is the inverse of the modulation frequency (speed). + period = 1.0 / pulse_freq if pulse_freq > 0 else 0.0 + + # This visualization must precisely match the runtime logic in get_pulse_at. + num_points = 200 + modulation_depth = pulse_width # This is already normalized to [0, 1] + + # To sync the preview with the dots, we must predict the phase for the *next* packet. + # This involves simulating the state advancement that happens at the start of generate_packet(). + t = time.time() + delta_time_ms = (t - self.last_update_time_s) * 1000.0 + _, pulse_freq, _, _ = _get_normalized_parameters( + self.params, t, self.signal_a.carrier_freq_limits, self.signal_a.pulse_freq_limits) + + # 1. Predict the phase increment. + phase_increment = (delta_time_ms / 1000.0) * pulse_freq * 2 * np.pi + + # 2. Calculate the predicted starting phase for the next packet. + predicted_phase = self.signal_a.modulation_phase + phase_increment + + # 3. Generate the sine wave for the plot starting from the predicted phase. + x = np.linspace(predicted_phase, predicted_phase + 2 * np.pi, num_points) + modulation_wave = np.sin(x) # Range [-1, 1] + + # 2. Calculate the frequency multiplier, exactly as in get_pulse_at. + # The result is in the range [1 - depth, 1 + depth]. + frequency_multiplier = 1.0 + modulation_wave * modulation_depth + + # 3. Normalize the multiplier from its runtime range to the [0, 1] range for the UI. + min_mult = 1.0 - modulation_depth + max_mult = 1.0 + modulation_depth + range_mult = max_mult - min_mult + + if range_mult == 0: + # If there's no modulation, the envelope is flat at the midpoint. + return np.full(num_points, 0.5), period + + envelope = (frequency_multiplier - min_mult) / range_mult + + return envelope, period diff --git a/qt_ui/algorithm_factory.py b/qt_ui/algorithm_factory.py index 5487961..9141f89 100644 --- a/qt_ui/algorithm_factory.py +++ b/qt_ui/algorithm_factory.py @@ -246,35 +246,9 @@ def create_neostim(self, device: DeviceConfiguration) -> NeoStimAlgorithm: return algorithm def create_coyote(self, device: DeviceConfiguration) -> AudioGenerationAlgorithm: - """ - Create direct algorithm for Coyote device with proper frequency handling. - - Each channel can take: - - Vibration axis if available (assumed already in reasonable range or scaled directly). - - Pulse frequency (1-100 mapped directly into channel's range). - - Carrier frequency (global, used if pulse frequency is missing). - - Fallback to constant 100 if none exist. - - Pulse width and rise time are **always required** by the algorithm, so fallbacks are handled here. - """ - - # Fetch frequency axes - carrier_frequency = self.get_axis_from_script_mapping(AxisEnum.CARRIER_FREQUENCY)#, limits=(0, 100)) - pulse_frequency = self.get_axis_from_script_mapping(AxisEnum.PULSE_FREQUENCY)#, limits=(0, 100)) - - # Fetch pulse shape axes (always needed, so provide defaults: 100% width, rise: 0%) - # pulse_width = self.get_axis_from_script_mapping(AxisEnum.PULSE_WIDTH, limits=(0, 100)) or create_constant_axis(100) - # pulse_rise_time = self.get_axis_from_script_mapping(AxisEnum.PULSE_RISE_TIME, limits=(0, 100)) or create_constant_axis(0) - - # Vibration axes (optional, not yet used) - # vibration_1 = self.get_axis_from_script_mapping(AxisEnum.VIBRATION_1_FREQUENCY) # Consider for Channel A effects? - # vibration_2 = self.get_axis_from_script_mapping(AxisEnum.VIBRATION_2_FREQUENCY) # Consider for Channel B effects? - - # Prefer pulse frequency → fallback to carrier frequency → fallback to constant 100 - script_frequency = pulse_frequency or carrier_frequency or create_constant_axis(100) - # 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) @@ -317,6 +291,7 @@ def create_coyote(self, device: DeviceConfiguration) -> AudioGenerationAlgorithm 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 ) diff --git a/requirements.txt b/requirements.txt index 7db05c1..aafa4b7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ msdparser # protoletariat # dev only protobuf>=6.0.0 pystream-protobuf +bleak # Coyote only \ No newline at end of file diff --git a/stim_math/audio_gen/params.py b/stim_math/audio_gen/params.py index 0b6fefb..93237d8 100644 --- a/stim_math/audio_gen/params.py +++ b/stim_math/audio_gen/params.py @@ -186,8 +186,8 @@ class CoyoteAlgorithmParams: transform: ThreephasePositionTransformParams calibrate: ThreephaseCalibrationParams volume: VolumeParams - carrier_frequency: AbstractAxis # raw pos (not Hz) - pulse_frequency: AbstractAxis # raw pos (not Hz) + carrier_frequency: AbstractAxis # Hz + pulse_frequency: AbstractAxis # Hz pulse_width: AbstractAxis # carrier cycles pulse_interval_random: AbstractAxis pulse_rise_time: AbstractAxis From 209f155c9e5a41af34463811f6a72b6b3838a0a3 Mon Sep 17 00:00:00 2001 From: diglet48 Date: Fri, 29 Aug 2025 17:15:02 +0200 Subject: [PATCH 05/47] Update t-code defaults --- qt_ui/models/funscript_kit.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/qt_ui/models/funscript_kit.py b/qt_ui/models/funscript_kit.py index 83c479b..20f694a 100644 --- a/qt_ui/models/funscript_kit.py +++ b/qt_ui/models/funscript_kit.py @@ -10,14 +10,14 @@ AxisEnum.POSITION_ALPHA: ('alpha', 'L0', -1, 1, True, True), AxisEnum.POSITION_BETA: ('beta', 'L1', -1, 1, True, True), AxisEnum.POSITION_GAMMA: ('gamma', '', -1, 1, True, True), - AxisEnum.VOLUME_API: ('volume', '', 0, 1, True, True), + AxisEnum.VOLUME_API: ('volume', 'V0', 0, 1, True, True), AxisEnum.VOLUME_EXTERNAL: ('', '', 0, 1, False, False), - AxisEnum.CARRIER_FREQUENCY: ('frequency', '', 500, 1000, True, True), + AxisEnum.CARRIER_FREQUENCY: ('frequency', 'C0', 500, 1000, True, True), - AxisEnum.PULSE_FREQUENCY: ('pulse_frequency', '', 0, 100, True, True), - AxisEnum.PULSE_WIDTH: ('pulse_width', '', 4, 10, True, True), - AxisEnum.PULSE_INTERVAL_RANDOM: ('pulse_interval_random', '', 0, 1, True, True), - AxisEnum.PULSE_RISE_TIME: ('pulse_rise_time', '', 2, 20, True, True), + AxisEnum.PULSE_FREQUENCY: ('pulse_frequency', 'P0', 0, 100, True, True), + AxisEnum.PULSE_WIDTH: ('pulse_width', 'P1', 4, 10, True, True), + AxisEnum.PULSE_INTERVAL_RANDOM: ('pulse_interval_random', 'P2', 0, 1, True, True), + AxisEnum.PULSE_RISE_TIME: ('pulse_rise_time', 'P3', 2, 20, True, True), AxisEnum.VIBRATION_1_FREQUENCY: ('vib1_frequency', '', 0, 100, True, True), AxisEnum.VIBRATION_1_STRENGTH: ('vib1_strength', '', 0, 1, True, True), From b039a4b4433c203e2b8e72f289af144d0d9d5a41 Mon Sep 17 00:00:00 2001 From: abacaba-100 Date: Mon, 1 Sep 2025 20:42:08 -0600 Subject: [PATCH 06/47] Add pattern system enhancement - Add 13 new threephase patterns - Implement pattern service architecture - Fourphase moved to same design but not extended - Add pattern preferences UI to Preferences with enable/disable controls and .ini storage - Enhance volume widget with red line master volume indicator - Add Windows run.bat script for easy startup - Added a (currently unused) amplitude system for pattern shape scaling - default=1.0 which is the full -1 to +1 ranges --- qt_ui/patterns/fourphase/__init__.py | 8 + qt_ui/patterns/fourphase/base.py | 16 ++ qt_ui/patterns/fourphase/mouse.py | 32 +++ qt_ui/patterns/fourphase/orbit.py | 23 ++ qt_ui/patterns/fourphase/sequence.py | 32 +++ qt_ui/patterns/fourphase/spiral.py | 29 +++ qt_ui/patterns/fourphase_patterns.py | 144 ++---------- qt_ui/patterns/threephase/__init__.py | 29 +++ qt_ui/patterns/threephase/base.py | 64 +++++ qt_ui/patterns/threephase/butterfly.py | 33 +++ qt_ui/patterns/threephase/circle.py | 19 ++ qt_ui/patterns/threephase/deep_throb.py | 62 +++++ qt_ui/patterns/threephase/figure_eight.py | 23 ++ qt_ui/patterns/threephase/jerky_stroke.py | 47 ++++ qt_ui/patterns/threephase/lightning_strike.py | 57 +++++ qt_ui/patterns/threephase/micro_circles.py | 46 ++++ qt_ui/patterns/threephase/mouse.py | 30 +++ qt_ui/patterns/threephase/orbiting_circles.py | 53 +++++ qt_ui/patterns/threephase/random_walk.py | 46 ++++ qt_ui/patterns/threephase/rose_curve.py | 30 +++ qt_ui/patterns/threephase/spirograph.py | 34 +++ qt_ui/patterns/threephase/tremor_circle.py | 52 +++++ .../threephase/vertical_oscillation.py | 24 ++ qt_ui/patterns/threephase/w_shape.py | 68 ++++++ qt_ui/patterns/threephase_patterns.py | 140 +++++------ qt_ui/preferences_dialog.py | 114 ++++++++- qt_ui/preferences_dialog_ui.py | 59 ++++- qt_ui/services/__init__.py | 1 + qt_ui/services/fourphase_pattern_service.py | 157 +++++++++++++ qt_ui/services/pattern_service.py | 219 ++++++++++++++++++ qt_ui/settings.py | 28 +++ qt_ui/volume_control_widget.py | 10 +- qt_ui/widgets/volume_widget.py | 43 ++++ run.bat | 17 ++ 34 files changed, 1573 insertions(+), 216 deletions(-) create mode 100644 qt_ui/patterns/fourphase/__init__.py create mode 100644 qt_ui/patterns/fourphase/base.py create mode 100644 qt_ui/patterns/fourphase/mouse.py create mode 100644 qt_ui/patterns/fourphase/orbit.py create mode 100644 qt_ui/patterns/fourphase/sequence.py create mode 100644 qt_ui/patterns/fourphase/spiral.py create mode 100644 qt_ui/patterns/threephase/__init__.py create mode 100644 qt_ui/patterns/threephase/base.py create mode 100644 qt_ui/patterns/threephase/butterfly.py create mode 100644 qt_ui/patterns/threephase/circle.py create mode 100644 qt_ui/patterns/threephase/deep_throb.py create mode 100644 qt_ui/patterns/threephase/figure_eight.py create mode 100644 qt_ui/patterns/threephase/jerky_stroke.py create mode 100644 qt_ui/patterns/threephase/lightning_strike.py create mode 100644 qt_ui/patterns/threephase/micro_circles.py create mode 100644 qt_ui/patterns/threephase/mouse.py create mode 100644 qt_ui/patterns/threephase/orbiting_circles.py create mode 100644 qt_ui/patterns/threephase/random_walk.py create mode 100644 qt_ui/patterns/threephase/rose_curve.py create mode 100644 qt_ui/patterns/threephase/spirograph.py create mode 100644 qt_ui/patterns/threephase/tremor_circle.py create mode 100644 qt_ui/patterns/threephase/vertical_oscillation.py create mode 100644 qt_ui/patterns/threephase/w_shape.py create mode 100644 qt_ui/services/__init__.py create mode 100644 qt_ui/services/fourphase_pattern_service.py create mode 100644 qt_ui/services/pattern_service.py create mode 100644 run.bat diff --git a/qt_ui/patterns/fourphase/__init__.py b/qt_ui/patterns/fourphase/__init__.py new file mode 100644 index 0000000..0472cf3 --- /dev/null +++ b/qt_ui/patterns/fourphase/__init__.py @@ -0,0 +1,8 @@ +# Import patterns for manual instantiation (no automatic registration) +from .mouse import MousePattern +from .orbit import OrbitPattern +from .sequence import SequencePattern +from .spiral import SpiralPattern + +# Make patterns available at package level +__all__ = ['MousePattern', 'OrbitPattern', 'SequencePattern', 'SpiralPattern'] diff --git a/qt_ui/patterns/fourphase/base.py b/qt_ui/patterns/fourphase/base.py new file mode 100644 index 0000000..c54de3e --- /dev/null +++ b/qt_ui/patterns/fourphase/base.py @@ -0,0 +1,16 @@ +""" +Base class for Fourphase patterns (matching original implementation) +""" +from abc import ABC, abstractmethod + +class FourphasePattern(ABC): + def __init__(self): + pass + + @abstractmethod + def name(self): + ... + + @abstractmethod + def update(self, dt: float): + ... diff --git a/qt_ui/patterns/fourphase/mouse.py b/qt_ui/patterns/fourphase/mouse.py new file mode 100644 index 0000000..a6d88db --- /dev/null +++ b/qt_ui/patterns/fourphase/mouse.py @@ -0,0 +1,32 @@ +""" +Mouse Pattern for Fourphase +""" +from qt_ui.patterns.fourphase.base import FourphasePattern +from stim_math.axis import AbstractAxis + +class MousePattern(FourphasePattern): + def __init__(self, alpha: AbstractAxis, beta: AbstractAxis, gamma: AbstractAxis): + super().__init__() + self.alpha = alpha + self.beta = beta + self.gamma = gamma + self.x = 0.00001 # hack to force display update on load + self.y = 0 + self.z = 0 + + def name(self): + return "mouse" + + def mouse_event(self, x, y, z): + self.alpha.add(x) + self.beta.add(y) + self.gamma.add(z) + self.x = x + self.y = y + self.z = z + + def update(self, dt: float): + return self.x, self.y, self.z + + def last_position_is_mouse_position(self): + return (self.x, self.y) == (self.alpha.last_value(), self.beta.last_value()) diff --git a/qt_ui/patterns/fourphase/orbit.py b/qt_ui/patterns/fourphase/orbit.py new file mode 100644 index 0000000..e96707f --- /dev/null +++ b/qt_ui/patterns/fourphase/orbit.py @@ -0,0 +1,23 @@ +""" +Orbit Pattern for Fourphase +""" +import numpy as np +from qt_ui.patterns.fourphase.base import FourphasePattern + +class OrbitPattern(FourphasePattern): + def __init__(self, name, axis): + super().__init__() + self._name = name + self.axis = axis + self.dir1 = np.linalg.cross(axis, [.5, .7, .999]) + self.dir2 = np.linalg.cross(axis, self.dir1) + self.dir1 /= np.linalg.norm(self.dir1) + self.dir2 /= np.linalg.norm(self.dir2) + self.angle = 0 + + def name(self): + return self._name + + def update(self, dt: float): + self.angle = self.angle + dt * 1 + return self.dir1 * np.cos(self.angle) + self.dir2 * np.sin(self.angle) diff --git a/qt_ui/patterns/fourphase/sequence.py b/qt_ui/patterns/fourphase/sequence.py new file mode 100644 index 0000000..82daf79 --- /dev/null +++ b/qt_ui/patterns/fourphase/sequence.py @@ -0,0 +1,32 @@ +""" +Sequence Pattern for Fourphase +""" +import numpy as np +from qt_ui.patterns.fourphase.base import FourphasePattern + +class SequencePattern(FourphasePattern): + def __init__(self, name, sequence: list): + super().__init__() + self._name = name + self.sequence = np.vstack((sequence, sequence, sequence[0])) + self.index = 0 + + for i in range(1, len(self.sequence)): + dist_1 = np.linalg.norm(self.sequence[i] - self.sequence[i-1]) + dist_2 = np.linalg.norm(self.sequence[i] + self.sequence[i-1]) + if dist_2 < dist_1: + self.sequence[i] *= -1 + + def name(self): + return self._name + + def update(self, dt: float): + self.index = (self.index + dt / 2) % (len(self.sequence) - 1) + index = self.index + x = np.interp(index, np.arange(len(self.sequence)), self.sequence[:, 0]) + y = np.interp(index, np.arange(len(self.sequence)), self.sequence[:, 1]) + z = np.interp(index, np.arange(len(self.sequence)), self.sequence[:, 2]) + + xyz = np.array([x, y, z]) + xyz /= np.linalg.norm(xyz) + return xyz diff --git a/qt_ui/patterns/fourphase/spiral.py b/qt_ui/patterns/fourphase/spiral.py new file mode 100644 index 0000000..4712503 --- /dev/null +++ b/qt_ui/patterns/fourphase/spiral.py @@ -0,0 +1,29 @@ +""" +Spiral Pattern for Fourphase +""" +import numpy as np +from qt_ui.patterns.fourphase.base import FourphasePattern + +class SpiralPattern(FourphasePattern): + def __init__(self, name, axis): + super().__init__() + self._name = name + self.axis = axis / np.linalg.norm(axis) + self.dir1 = np.linalg.cross(axis, [.5, .7, .999]) + self.dir2 = np.linalg.cross(axis, self.dir1) + self.dir1 /= np.linalg.norm(self.dir1) + self.dir2 /= np.linalg.norm(self.dir2) + self.angle = 0 + self.angle2 = 0 + + def name(self): + return self._name + + def update(self, dt: float): + self.angle = self.angle + dt * 4 + self.angle2 = self.angle2 + dt * .23 + radius = np.sin(self.angle2) * 0.6 + vec = (self.axis + + self.dir1 * np.cos(self.angle) * radius + + self.dir2 * np.sin(self.angle) * radius) + return vec / np.linalg.norm(vec) diff --git a/qt_ui/patterns/fourphase_patterns.py b/qt_ui/patterns/fourphase_patterns.py index 8b6b977..e6c015f 100644 --- a/qt_ui/patterns/fourphase_patterns.py +++ b/qt_ui/patterns/fourphase_patterns.py @@ -1,127 +1,16 @@ -from abc import ABC, abstractmethod - -import numpy as np import time - +import logging +import numpy as np from PySide6 import QtCore - import qt_ui.settings from stim_math.axis import AbstractAxis, WriteProtectedAxis - +from qt_ui.patterns.fourphase.mouse import MousePattern +from qt_ui.patterns.fourphase.orbit import OrbitPattern +from qt_ui.patterns.fourphase.sequence import SequencePattern +from qt_ui.patterns.fourphase.spiral import SpiralPattern from qt_ui.widgets.fourphase_widget_stereographic import v1, v2, v3, v4 - -class FourphasePattern(ABC): - def __init__(self): - pass - - @abstractmethod - def name(self): - ... - - @abstractmethod - def update(self, dt: float): - ... - - -class MousePattern(FourphasePattern): - def __init__(self, alpha: AbstractAxis, beta: AbstractAxis, gamma: AbstractAxis): - super().__init__() - self.alpha = alpha - self.beta = beta - self.gamma = gamma - self.x = 0.00001 # hack to force display update on load - self.y = 0 - self.z = 0 - - def name(self): - return "mouse" - - def mouse_event(self, x, y, z): - self.alpha.add(x) - self.beta.add(y) - self.gamma.add(z) - self.x = x - self.y = y - self.z = z - - def update(self, dt: float): - return self.x, self.y, self.z - - def last_position_is_mouse_position(self): - return (self.x, self.y) == (self.alpha.last_value(), self.beta.last_value()) - - -class OrbitPattern(FourphasePattern): - def __init__(self, name, axis): - super().__init__() - self._name = name - self.axis = axis - self.dir1 = np.linalg.cross(axis, [.5, .7, .999]) - self.dir2 = np.linalg.cross(axis, self.dir1) - self.dir1 /= np.linalg.norm(self.dir1) - self.dir2 /= np.linalg.norm(self.dir2) - self.angle = 0 - - def name(self): - return self._name - - def update(self, dt: float): - self.angle = self.angle + dt * 1 - return self.dir1 * np.cos(self.angle) + self.dir2 * np.sin(self.angle) - - -class SequencePattern(FourphasePattern): - def __init__(self, name, sequence: list): - super().__init__() - self._name = name - self.sequence = np.vstack((sequence, sequence, sequence[0])) - self.index = 0 - - for i in range(1, len(self.sequence)): - dist_1 = np.linalg.norm(self.sequence[i] - self.sequence[i-1]) - dist_2 = np.linalg.norm(self.sequence[i] + self.sequence[i-1]) - if dist_2 < dist_1: - self.sequence[i] *= -1 - - def name(self): - return self._name - - def update(self, dt: float): - self.index = (self.index + dt / 2) % (len(self.sequence) - 1) - index = self.index - x = np.interp(index, np.arange(len(self.sequence)), self.sequence[:, 0]) - y = np.interp(index, np.arange(len(self.sequence)), self.sequence[:, 1]) - z = np.interp(index, np.arange(len(self.sequence)), self.sequence[:, 2]) - - xyz = np.array([x, y, z]) - xyz /= np.linalg.norm(xyz) - return xyz - - -class SpiralPattern(FourphasePattern): - def __init__(self, name, axis): - super().__init__() - self._name = name - self.axis = axis / np.linalg.norm(axis) - self.dir1 = np.linalg.cross(axis, [.5, .7, .999]) - self.dir2 = np.linalg.cross(axis, self.dir1) - self.dir1 /= np.linalg.norm(self.dir1) - self.dir2 /= np.linalg.norm(self.dir2) - self.angle = 0 - self.angle2 = 0 - - def name(self): - return self._name - - def update(self, dt: float): - self.angle = self.angle + dt * 4 - self.angle2 = self.angle2 + dt * .23 - radius = np.sin(self.angle2) * 0.6 - vec = (self.axis + - self.dir1 * np.cos(self.angle) * radius + - self.dir2 * np.sin(self.angle) * radius) - return vec / np.linalg.norm(vec) +logger = logging.getLogger('restim.motion_generation') class FourphaseMotionGenerator(QtCore.QObject): @@ -135,7 +24,10 @@ def __init__(self, parent, alpha: AbstractAxis, beta: AbstractAxis, gamma: Abstr self.script_beta = None self.script_gamma = None + # Instantiate MousePattern with axes self.mouse_pattern = MousePattern(alpha, beta, gamma) + + # Create patterns like the original implementation self.patterns = [ self.mouse_pattern, SequencePattern('seq ABCD', [v1, v2, v3, v4]), @@ -172,13 +64,15 @@ def set_enable(self, enable): self.timer.stop() def set_pattern(self, pattern): - if issubclass(pattern.__class__, FourphasePattern): + if isinstance(pattern, MousePattern): + self.pattern = self.mouse_pattern + elif pattern in self.patterns: self.pattern = pattern def set_scripts(self, alpha, beta, gamma): - self.script_alpha = alpha if issubclass(alpha.__class__, WriteProtectedAxis) else None - self.script_beta = beta if issubclass(beta.__class__, WriteProtectedAxis) else None - self.script_gamma = gamma if issubclass(gamma.__class__, WriteProtectedAxis) else None + self.script_alpha = alpha if isinstance(alpha, WriteProtectedAxis) else None + self.script_beta = beta if isinstance(beta, WriteProtectedAxis) else None + self.script_gamma = gamma if isinstance(gamma, WriteProtectedAxis) else None def any_scripts_loaded(self): return (self.script_alpha, self.script_beta, self.script_gamma) != (None, None, None) @@ -191,18 +85,15 @@ def timeout(self): self.last_update_time = time.time() if not self.any_scripts_loaded(): - if issubclass(self.pattern.__class__, MousePattern): + if isinstance(self.pattern, MousePattern): if self.pattern.last_position_is_mouse_position(): - # mouse position, display update already handled. pass else: - # tcode position, send lagged position a = self.alpha.interpolate(time.time() - self.latency) b = self.beta.interpolate(time.time() - self.latency) c = self.gamma.interpolate(time.time() - self.latency) self.position_updated.emit(a, b, c) else: - # update pattern, display lagged position a, b, c = self.pattern.update(dt * self.velocity) self.alpha.add(a) self.beta.add(b) @@ -212,7 +103,6 @@ def timeout(self): c = self.gamma.interpolate(time.time() - self.latency) self.position_updated.emit(a, b, c) else: - # update display with data from funscript if self.script_alpha: a = self.script_alpha.interpolate(time.time() - self.latency) else: diff --git a/qt_ui/patterns/threephase/__init__.py b/qt_ui/patterns/threephase/__init__.py new file mode 100644 index 0000000..6aaa2ef --- /dev/null +++ b/qt_ui/patterns/threephase/__init__.py @@ -0,0 +1,29 @@ +# Automatically import all patterns to register them +# Priority patterns first (Mouse and Circle), then rest alphabetically +from .mouse import MousePattern +from .circle import CirclePattern + +# Additional patterns in alphabetical order +from .butterfly import ButterflyPattern +from .deep_throb import DeepThrobPattern +from .figure_eight import FigureEightPattern +from .jerky_stroke import JerkyStrokePattern +from .lightning_strike import LightningStrikePattern +from .micro_circles import MicroCirclesPattern +from .orbiting_circles import OrbitingCirclesPattern +from .random_walk import RandomWalkPattern +from .rose_curve import RoseCurvePattern +from .spirograph import SpirographPattern +from .tremor_circle import TremorCirclePattern +from .vertical_oscillation import VerticalOscillationPattern +from .w_shape import WShapePattern + +# Make patterns available at package level +__all__ = [ + 'MousePattern', 'CirclePattern', # Priority patterns first + 'ButterflyPattern', 'DeepThrobPattern', 'FigureEightPattern', + 'JerkyStrokePattern', 'LightningStrikePattern', 'MicroCirclesPattern', + 'OrbitingCirclesPattern', 'RandomWalkPattern', 'RoseCurvePattern', + 'SpirographPattern', 'TremorCirclePattern', 'VerticalOscillationPattern', + 'WShapePattern' +] diff --git a/qt_ui/patterns/threephase/base.py b/qt_ui/patterns/threephase/base.py new file mode 100644 index 0000000..4c68f2b --- /dev/null +++ b/qt_ui/patterns/threephase/base.py @@ -0,0 +1,64 @@ +""" +Pattern base and registry system with decorator registration +""" +import logging +from abc import ABC, abstractmethod +from typing import Dict, Type, Set, Any + +logger = logging.getLogger('restim.patterns.base') + +_pattern_registry: Dict[str, Type['ThreephasePattern']] = {} +_pattern_categories: Dict[str, Set[str]] = {} + +def register_pattern(category: str = "basic"): + def decorator(pattern_class: Type['ThreephasePattern']): + if not issubclass(pattern_class, ThreephasePattern): + raise TypeError(f"Pattern {pattern_class.__name__} must inherit from ThreephasePattern") + if not hasattr(pattern_class, 'display_name') or not pattern_class.display_name: + raise ValueError(f"Pattern {pattern_class.__name__} must define display_name") + display_name = pattern_class.display_name + _pattern_registry[display_name] = pattern_class + if category not in _pattern_categories: + _pattern_categories[category] = set() + _pattern_categories[category].add(display_name) + pattern_class.category = category + logger.debug(f"Registered pattern '{display_name}' in category '{category}'") + return pattern_class + return decorator + +def get_registered_patterns() -> Dict[str, Type['ThreephasePattern']]: + return _pattern_registry.copy() + +def get_patterns_by_category(category: str) -> Dict[str, Type['ThreephasePattern']]: + if category not in _pattern_categories: + return {} + return {name: _pattern_registry[name] for name in _pattern_categories[category] if name in _pattern_registry} + +def get_all_categories() -> Set[str]: + return set(_pattern_categories.keys()) + +def clear_registry(): + global _pattern_registry, _pattern_categories + _pattern_registry.clear() + _pattern_categories.clear() + +class ThreephasePattern(ABC): + display_name = "Abstract Pattern" + description = "Base pattern class" + category = "base" + def __init__(self, amplitude: float = 1.0, velocity: float = 1.0): + self.amplitude = amplitude + self.velocity = velocity + def name(self) -> str: + return self.display_name + @classmethod + def get_metadata(cls) -> Dict[str, Any]: + return { + "name": cls.display_name, + "description": cls.description, + "category": cls.category, + "class": cls + } + @abstractmethod + def update(self, dt: float) -> tuple[float, float]: + pass diff --git a/qt_ui/patterns/threephase/butterfly.py b/qt_ui/patterns/threephase/butterfly.py new file mode 100644 index 0000000..205883a --- /dev/null +++ b/qt_ui/patterns/threephase/butterfly.py @@ -0,0 +1,33 @@ +""" +Butterfly Curve Pattern - Artistic butterfly wing mathematical pattern +""" +import numpy as np +from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern + + +@register_pattern(category="mathematical") +class ButterflyPattern(ThreephasePattern): + display_name = "Butterfly Curve" + description = "Artistic butterfly wing pattern based on mathematical curves. Complex, elegant motion with wing-like symmetry. Creates sophisticated, artistic stimulation paths." + + def __init__(self, amplitude=1.0, velocity=1.0): + super().__init__(amplitude, velocity) + self.time = 0 + + def update(self, dt: float): + """Update butterfly pattern using classic butterfly curve equations""" + self.time = self.time + dt * self.velocity * 0.5 # Slower for detail + + # Classic butterfly curve parametric equations + t = self.time + exp_cos = np.exp(np.cos(t)) + alpha = np.sin(t) * (exp_cos - 2 * np.cos(4 * t) - np.sin(t/12)**5) + beta = np.cos(t) * (exp_cos - 2 * np.cos(4 * t) - np.sin(t/12)**5) + + # Normalize to fit in -1 to 1 range + scale = 0.15 + alpha = alpha * scale + beta = beta * scale + + return alpha * self.amplitude, beta * self.amplitude + diff --git a/qt_ui/patterns/threephase/circle.py b/qt_ui/patterns/threephase/circle.py new file mode 100644 index 0000000..7c3f49a --- /dev/null +++ b/qt_ui/patterns/threephase/circle.py @@ -0,0 +1,19 @@ +""" +Circle Pattern - simple circular motion +""" +import numpy as np +from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern + +@register_pattern(category="mathematical") +class CirclePattern(ThreephasePattern): + display_name = "Circle" + description = "Simple circular motion pattern" + category = "mathematical" + def __init__(self, amplitude: float = 1.0, velocity: float = 1.0): + super().__init__(amplitude=amplitude, velocity=velocity) + self.angle = 0 + def update(self, dt: float): + self.angle += dt * self.velocity + x = np.cos(self.angle) * self.amplitude + y = np.sin(self.angle) * self.amplitude + return x, y diff --git a/qt_ui/patterns/threephase/deep_throb.py b/qt_ui/patterns/threephase/deep_throb.py new file mode 100644 index 0000000..8321aac --- /dev/null +++ b/qt_ui/patterns/threephase/deep_throb.py @@ -0,0 +1,62 @@ +""" +Deep Throb Pattern - Slow, deep pulsing with powerful rhythm +""" +import numpy as np +from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern + + +@register_pattern(category="experimental") +class DeepThrobPattern(ThreephasePattern): + display_name = "Deep Throb" + description = "Slow, deep pulsing with powerful rhythm" + + def __init__(self, amplitude=1.0, velocity=1.0): + super().__init__(amplitude, velocity) + self.time = 0 + + def update(self, dt: float): + self.time += dt * self.velocity * 0.4 # Slow, powerful rhythm + + # Primary throbbing motion - slow, deep, penetrating + throb_cycle = (self.time * 0.8) % (2 * np.pi) + + # Create a powerful, smooth throbbing motion + if throb_cycle < np.pi: + # Rising phase - building intensity + throb_progress = throb_cycle / np.pi + throb_intensity = np.sin(throb_progress * np.pi) ** 2 # Smooth curve + else: + # Falling phase - gradual release + throb_progress = (throb_cycle - np.pi) / np.pi + throb_intensity = (1.0 - throb_progress) ** 2 # Smooth decay + + # Apply throbbing to Alpha (primary penetration axis) + # Extended range to go negative on downward motion + base_alpha = -0.2 + 1.3 * throb_intensity # -0.2 to 1.1 range (extends to negative) + + # Add depth variation - occasionally goes deeper + depth_cycle = (self.time * 0.2) % (2 * np.pi) + if depth_cycle < np.pi * 0.3: # 30% of cycle + depth_modifier = 1.0 + 0.4 * np.sin(depth_cycle / (np.pi * 0.3) * np.pi) + else: + depth_modifier = 1.0 + + alpha = base_alpha * depth_modifier + + # Minimal Beta movement - focused penetration + beta = 0.1 * np.sin(self.time * 0.6) + 0.05 * np.sin(self.time * 1.1) + + # Add powerful pulses during peak intensity + if throb_intensity > 0.8: + pulse_strength = (throb_intensity - 0.8) / 0.2 # 0 to 1 + pulse_alpha = 0.2 * pulse_strength * np.sin(self.time * 8.0) + pulse_beta = 0.1 * pulse_strength * np.cos(self.time * 6.5) + alpha += pulse_alpha + beta += pulse_beta + + # Ensure powerful but controlled movement - extended negative range + alpha = np.clip(alpha, -0.5, 1.3) # Now extends to -0.5 as requested + beta = np.clip(beta, -0.3, 0.3) + + return alpha * self.amplitude, beta * self.amplitude + diff --git a/qt_ui/patterns/threephase/figure_eight.py b/qt_ui/patterns/threephase/figure_eight.py new file mode 100644 index 0000000..1de803a --- /dev/null +++ b/qt_ui/patterns/threephase/figure_eight.py @@ -0,0 +1,23 @@ +""" +Figure Eight Pattern - Lemniscate motion +""" +import numpy as np +from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern + + +@register_pattern(category="basic") +class FigureEightPattern(ThreephasePattern): + display_name = "Figure 8" + description = "Lemniscate (figure-8) pattern creating flowing, dynamic motion. Medium speed with crossing loops. Provides varied stimulation with changing directions and intensities." + + def __init__(self, amplitude=1.0, velocity=1.0): + super().__init__(amplitude, velocity) + self.angle = 0 + + def update(self, dt: float): + """Update figure-8 position using parametric lemniscate equations""" + self.angle = self.angle + dt * self.velocity + # Parametric equations for figure-8 (lemniscate) + alpha = np.sin(self.angle) # Y component: -1 to 1 + beta = 0.5 * np.sin(2 * self.angle) # X component: -0.5 to 0.5 + return alpha * self.amplitude, beta * self.amplitude diff --git a/qt_ui/patterns/threephase/jerky_stroke.py b/qt_ui/patterns/threephase/jerky_stroke.py new file mode 100644 index 0000000..4ea312a --- /dev/null +++ b/qt_ui/patterns/threephase/jerky_stroke.py @@ -0,0 +1,47 @@ +""" +Jerky Stroke Pattern - Vertical stroking with sudden jerky interruptions +""" +import numpy as np +from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern + + +@register_pattern(category="complex") +class JerkyStrokePattern(ThreephasePattern): + display_name = "Jerky Stroke" + description = "Vertical stroking with sudden jerky interruptions. Combines slow buildup with quick return phases and micro-jerk overlays. Irregular, surprising motion." + + def __init__(self, amplitude=1.0, velocity=1.0): + super().__init__(amplitude, velocity) + self.time = 0 + self.jerk_timer = 0 + + def update(self, dt: float): + self.time = self.time + dt * self.velocity + self.jerk_timer += dt * self.velocity * 5 + + # Main vertical stroking motion + stroke_cycle = 3.0 + stroke_progress = (self.time % stroke_cycle) / stroke_cycle + + # Create jerky, non-linear stroke progression + if stroke_progress < 0.6: + smooth_progress = (stroke_progress / 0.6) ** 0.3 + alpha = smooth_progress * 2 - 1 + else: + return_progress = 1 - (1 - (stroke_progress - 0.6) / 0.4) ** 2 + alpha = 1 - return_progress * 2 + + # Add jerky micro-movements + jerk_intensity = 0.08 + if (self.jerk_timer % 1.0) < 0.15: + jerk_modifier = np.sin(self.jerk_timer * 30) * jerk_intensity + alpha += jerk_modifier + + # Horizontal positioning with variation + beta = 0.3 * np.sin(self.time * 1.3) + 0.1 * np.sin(self.time * 7.1) + + alpha = np.clip(alpha, -1, 1) + beta = np.clip(beta, -1, 1) + + return alpha * self.amplitude, beta * self.amplitude + diff --git a/qt_ui/patterns/threephase/lightning_strike.py b/qt_ui/patterns/threephase/lightning_strike.py new file mode 100644 index 0000000..33b7e8e --- /dev/null +++ b/qt_ui/patterns/threephase/lightning_strike.py @@ -0,0 +1,57 @@ +""" +Lightning Strike Pattern - Sudden intense strikes with quiet periods +""" +import numpy as np +from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern + + +@register_pattern(category="experimental") +class LightningStrikePattern(ThreephasePattern): + display_name = "Lightning Strike" + description = "Sudden intense strikes with quiet periods" + + def __init__(self, amplitude=1.0, velocity=1.0): + super().__init__(amplitude, velocity) + self.time = 0 + self.last_strike_time = 0 + self.strike_duration = 0.15 # Quick strikes + self.strike_interval = 2.5 # Random interval between strikes + self.in_strike = False + + def update(self, dt: float): + self.time += dt * self.velocity + + # Determine if we should start a new strike + if not self.in_strike and (self.time - self.last_strike_time) > self.strike_interval: + self.in_strike = True + self.last_strike_time = self.time + # Randomize next interval using pseudo-random based on time + pseudo_rand = abs(np.sin(self.time * 17.543)) * abs(np.cos(self.time * 23.891)) + self.strike_interval = 1.0 + pseudo_rand * 3.0 + + # End strike after duration + if self.in_strike and (self.time - self.last_strike_time) > self.strike_duration: + self.in_strike = False + + if self.in_strike: + # Sharp, fast, erratic movements during strike + strike_progress = (self.time - self.last_strike_time) / self.strike_duration + intensity = np.sin(strike_progress * np.pi) * 2.0 # Peak in middle + + # Jagged, unpredictable motion + alpha = intensity * (0.5 + 0.8 * np.sin(self.time * 45)) + beta = intensity * 0.4 * np.cos(self.time * 38 + np.pi/3) + + # Add sharp directional changes + if int(self.time * 50) % 3 == 0: # Change direction rapidly + alpha *= -0.7 + if int(self.time * 43) % 4 == 0: + beta *= -0.9 + + else: + # Calm between strikes - minimal movement + alpha = 0.1 * np.sin(self.time * 0.8) + beta = 0.05 * np.cos(self.time * 0.6) + + return alpha * self.amplitude, beta * self.amplitude + diff --git a/qt_ui/patterns/threephase/micro_circles.py b/qt_ui/patterns/threephase/micro_circles.py new file mode 100644 index 0000000..dc77ceb --- /dev/null +++ b/qt_ui/patterns/threephase/micro_circles.py @@ -0,0 +1,46 @@ +""" +Micro Circles Pattern - Small circular motions with drifting origins +""" +import numpy as np +from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern + + +@register_pattern(category="basic") +class MicroCirclesPattern(ThreephasePattern): + display_name = "Micro Circles" + description = "Small circular motions with drifting origins" + + def __init__(self, amplitude=1.0, velocity=1.0): + super().__init__(amplitude, velocity) + self.time = 0 + self.origin_alpha = 0 + self.origin_beta = 0 + self.circle_radius = 0.15 # Small circles + + def update(self, dt: float): + self.time += dt * self.velocity + + # Slowly drift the origin of the micro circles + self.origin_alpha = 0.4 * np.sin(self.time * 0.1) + 0.3 * np.sin(self.time * 0.07) + self.origin_beta = 0.3 * np.cos(self.time * 0.08) + 0.2 * np.cos(self.time * 0.12) + + # Create small circular motion around the drifting origin + circle_alpha = self.circle_radius * np.cos(self.time * 4.0) + circle_beta = self.circle_radius * np.sin(self.time * 4.0) + + # Add subtle variations in circle size + size_mod = 0.8 + 0.4 * np.sin(self.time * 0.3) + circle_alpha *= size_mod + circle_beta *= size_mod + + # Occasionally add micro-tremors + if int(self.time * 2) % 7 == 0: + tremor_alpha = 0.02 * np.sin(self.time * 25) + tremor_beta = 0.02 * np.cos(self.time * 27) + else: + tremor_alpha = tremor_beta = 0 + + alpha = self.origin_alpha + circle_alpha + tremor_alpha + beta = self.origin_beta + circle_beta + tremor_beta + + return alpha * self.amplitude, beta * self.amplitude diff --git a/qt_ui/patterns/threephase/mouse.py b/qt_ui/patterns/threephase/mouse.py new file mode 100644 index 0000000..5dc6180 --- /dev/null +++ b/qt_ui/patterns/threephase/mouse.py @@ -0,0 +1,30 @@ +""" +Mouse Pattern - direct mouse control +""" +from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern +from stim_math.axis import AbstractAxis + +@register_pattern(category="manual") +class MousePattern(ThreephasePattern): + display_name = "Mouse" + description = "Direct mouse control for manual positioning" + category = "manual" + def __init__(self, alpha: AbstractAxis = None, beta: AbstractAxis = None, amplitude: float = 1.0, velocity: float = 1.0): + super().__init__(amplitude=amplitude, velocity=velocity) + self.alpha = alpha + self.beta = beta + self.x = 0.00001 + self.y = 0 + def mouse_event(self, x, y): + if self.alpha is not None: + self.alpha.add(x) + if self.beta is not None: + self.beta.add(y) + self.x = x + self.y = y + def update(self, dt: float): + return self.x * self.amplitude, self.y * self.amplitude + def last_position_is_mouse_position(self): + if self.alpha is not None and self.beta is not None: + return (self.x, self.y) == (self.alpha.last_value(), self.beta.last_value()) + return False diff --git a/qt_ui/patterns/threephase/orbiting_circles.py b/qt_ui/patterns/threephase/orbiting_circles.py new file mode 100644 index 0000000..2040c41 --- /dev/null +++ b/qt_ui/patterns/threephase/orbiting_circles.py @@ -0,0 +1,53 @@ +""" +Orbiting Circles Pattern - Varying diameter circles with moving origins +""" +import numpy as np +from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern + + +@register_pattern(category="complex") +class OrbitingCirclesPattern(ThreephasePattern): + display_name = "Orbiting Circles" + description = "Varying diameter circles with origins that move in interesting patterns around the Alpha (Y) axis, lingering at the most sensitive spot (Alpha=1, Beta=0). Combines orbital motion with size variation." + + def __init__(self, amplitude=1.0, velocity=1.0): + super().__init__(amplitude, velocity) + self.time = 0 + + def update(self, dt: float): + self.time = self.time + dt * self.velocity + + # Moving origin along Alpha (Y) axis with lingering at sensitive spot (Alpha=1, Beta=0) + origin_base_freq = 0.4 + origin_alpha = 0.7 * np.sin(self.time * origin_base_freq) + + # Add "lingering" at the sensitive spot (Alpha=1, Beta=0) + linger_freq = 0.15 + linger_pull = 0.4 * np.exp(-((origin_alpha - 1.0)**2) * 5) * (1 + np.sin(self.time * linger_freq * 10)) + origin_alpha += linger_pull + + # Slight horizontal drift for the origin + origin_beta = 0.15 * np.sin(self.time * 0.7) + 0.1 * np.cos(self.time * 1.3) + + # Varying diameter circles around the moving origin + circle_freq = 3.0 + base_radius = 0.3 + + # Radius varies with multiple harmonics for organic feel + radius_variation = 0.6 * (1 + 0.5 * np.sin(self.time * 0.8) + 0.3 * np.sin(self.time * 2.1)) + current_radius = base_radius * radius_variation + + # Circle motion + circle_alpha = current_radius * np.cos(self.time * circle_freq) + circle_beta = current_radius * np.sin(self.time * circle_freq) + + # Combine origin movement with circle + final_alpha = origin_alpha + circle_alpha + final_beta = origin_beta + circle_beta + + # Favor positive Alpha (sensitive area) + final_alpha = np.clip(final_alpha, -1.2, 1.5) + final_beta = np.clip(final_beta, -1.0, 1.0) + + return final_alpha * self.amplitude, final_beta * self.amplitude + diff --git a/qt_ui/patterns/threephase/random_walk.py b/qt_ui/patterns/threephase/random_walk.py new file mode 100644 index 0000000..5974b65 --- /dev/null +++ b/qt_ui/patterns/threephase/random_walk.py @@ -0,0 +1,46 @@ +""" +Random Walk Pattern - Unpredictable wandering motion with center-pull +""" +import numpy as np +from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern + + +@register_pattern(category="experimental") +class RandomWalkPattern(ThreephasePattern): + display_name = "Random Walk" + description = "Unpredictable wandering motion with pseudo-random direction changes. Includes center-pull to prevent drift. Creates natural, organic, unpredictable movement patterns." + + def __init__(self, amplitude=1.0, velocity=1.0): + super().__init__(amplitude, velocity) + self.time = 0 + self.alpha_pos = 0.0 + self.beta_pos = 0.0 + self.center_pull_strength = 0.003 + self.noise_strength = 0.8 # Reduced from 3.5 to prevent excessive buildup + + def _pseudo_random(self, t): + """Simple pseudo-random function based on time""" + x = np.sin(t * 37.12345) * np.cos(t * 23.67891) + y = np.sin(t * 41.98765) * np.cos(t * 19.43210) + return x, y + + def update(self, dt: float): + self.time = self.time + dt * self.velocity + + # Get pseudo-random direction + rand_x, rand_y = self._pseudo_random(self.time) + + # Random walk with gentle drift + self.alpha_pos += rand_x * self.noise_strength * dt + self.beta_pos += rand_y * self.noise_strength * dt + + # Pull towards center to prevent drift away + self.alpha_pos -= self.alpha_pos * self.center_pull_strength + self.beta_pos -= self.beta_pos * self.center_pull_strength + + # Clamp to bounds that work well with amplitude scaling + self.alpha_pos = np.clip(self.alpha_pos, -1.0, 1.0) + self.beta_pos = np.clip(self.beta_pos, -1.0, 1.0) + + return self.alpha_pos * self.amplitude, self.beta_pos * self.amplitude + diff --git a/qt_ui/patterns/threephase/rose_curve.py b/qt_ui/patterns/threephase/rose_curve.py new file mode 100644 index 0000000..1649b55 --- /dev/null +++ b/qt_ui/patterns/threephase/rose_curve.py @@ -0,0 +1,30 @@ +""" +Rose Curve Pattern - Mathematical rose with beautiful petals +""" +import numpy as np +from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern + + +@register_pattern(category="mathematical") +class RoseCurvePattern(ThreephasePattern): + display_name = "Rose Curve" + description = "Mathematical rose pattern with 5 beautiful petals. Smooth, organic flower-like motion with symmetrical design. Gentle yet varied stimulation following natural curves." + + def __init__(self, amplitude=1.0, velocity=1.0): + super().__init__(amplitude, velocity) + self.time = 0 + self.n = 5 # Number of petals + + def update(self, dt: float): + """Update rose curve position using polar rose equations""" + self.time = self.time + dt * self.velocity + + # Rose curve equation: r = cos(n*θ) + theta = self.time + r = abs(np.cos(self.n * theta)) # Take absolute value to keep all petals + + alpha = r * np.cos(theta) + beta = r * np.sin(theta) + + return alpha * self.amplitude, beta * self.amplitude + diff --git a/qt_ui/patterns/threephase/spirograph.py b/qt_ui/patterns/threephase/spirograph.py new file mode 100644 index 0000000..246726d --- /dev/null +++ b/qt_ui/patterns/threephase/spirograph.py @@ -0,0 +1,34 @@ +""" +Spirograph Pattern - Classic cycloid mathematical pattern +""" +import numpy as np +from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern + + +@register_pattern(category="mathematical") +class SpirographPattern(ThreephasePattern): + display_name = "Spirograph" + description = "Classic cycloid pattern created by a circle rolling inside another circle. Creates intricate geometric loops and spirals. Mathematical precision with elegant, flowing complexity." + + def __init__(self, amplitude=1.0, velocity=1.0): + super().__init__(amplitude, velocity) + self.time = 0 + self.R = 3.0 # Radius of fixed circle + self.r = 1.0 # Radius of rolling circle + self.d = 0.7 # Distance from center of rolling circle to drawing point + + def update(self, dt: float): + """Update spirograph position using classic cycloid equations""" + self.time = self.time + dt * self.velocity + + # Classic spirograph/cycloid equations + t = self.time + alpha = (self.R + self.r) * np.cos(t) - self.d * np.cos((self.R + self.r) / self.r * t) + beta = (self.R + self.r) * np.sin(t) - self.d * np.sin((self.R + self.r) / self.r * t) + + # Normalize to -1 to 1 range + max_val = (self.R + self.r + self.d) + alpha = alpha / max_val * 0.8 + beta = beta / max_val * 0.8 + + return alpha * self.amplitude, beta * self.amplitude diff --git a/qt_ui/patterns/threephase/tremor_circle.py b/qt_ui/patterns/threephase/tremor_circle.py new file mode 100644 index 0000000..0d6667a --- /dev/null +++ b/qt_ui/patterns/threephase/tremor_circle.py @@ -0,0 +1,52 @@ +""" +Tremor Circle Pattern - Circular motion with tremor-like micro-shakes +""" +import numpy as np +from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern + + +@register_pattern(category="complex") +class TremorCirclePattern(ThreephasePattern): + display_name = "Tremor Circle" + description = "Circular motion overlaid with fine tremor-like micro-shakes. Base circular movement modulated by high-frequency oscillations. Natural, subtle vibratory motion." + + def __init__(self, amplitude=1.0, velocity=1.0): + super().__init__(amplitude, velocity) + self.time = 0 + + def update(self, dt: float): + self.time = self.time + dt * self.velocity + + # Base circular motion + circle_period = 6.0 + circle_angle = (self.time / circle_period) * 2 * np.pi + + base_alpha = 0.6 * np.cos(circle_angle) + base_beta = 0.6 * np.sin(circle_angle) + + # Tremor modulation - high frequency, low amplitude + tremor_freq1 = 25.0 + tremor_freq2 = 31.5 + tremor_intensity = 0.08 + + tremor_alpha = tremor_intensity * ( + np.sin(self.time * tremor_freq1) * 0.6 + + np.sin(self.time * tremor_freq2 * 1.3) * 0.4 + ) + tremor_beta = tremor_intensity * ( + np.cos(self.time * tremor_freq1 * 1.1) * 0.6 + + np.cos(self.time * tremor_freq2 * 0.9) * 0.4 + ) + + # Amplitude modulation for tremor realism + tremor_envelope = 0.8 + 0.2 * np.sin(self.time * 0.7) + tremor_alpha *= tremor_envelope + tremor_beta *= tremor_envelope + + alpha = base_alpha + tremor_alpha + beta = base_beta + tremor_beta + + alpha = np.clip(alpha, -1, 1) + beta = np.clip(beta, -1, 1) + + return alpha * self.amplitude, beta * self.amplitude diff --git a/qt_ui/patterns/threephase/vertical_oscillation.py b/qt_ui/patterns/threephase/vertical_oscillation.py new file mode 100644 index 0000000..cde9203 --- /dev/null +++ b/qt_ui/patterns/threephase/vertical_oscillation.py @@ -0,0 +1,24 @@ +""" +Vertical Oscillation Pattern - Layered movement with slow vertical and rapid oscillations +""" +import numpy as np +from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern + + +@register_pattern(category="basic") +class VerticalOscillationPattern(ThreephasePattern): + display_name = "Vertical Oscillation" + description = "Combines slow vertical movement (2-second cycles) with rapid oscillations (5Hz). Creates complex layered stimulation with both rhythmic and vibrational components." + + def __init__(self, amplitude=1.0, velocity=1.0): + super().__init__(amplitude, velocity) + self.time = 0 + + def update(self, dt: float): + """Update position with layered vertical movement and oscillations""" + self.time = self.time + dt * self.velocity + # Alpha: slow vertical movement, 2 second cycles (0.5 Hz) + alpha = np.sin(2 * np.pi * 0.5 * self.time) # 0.5 Hz = 2 second cycle + # Beta: rapid oscillations, 5 oscillations per second (5 Hz) + beta = 0.2 * np.sin(2 * np.pi * 5 * self.time) # 5 Hz oscillations + return alpha * self.amplitude, beta * self.amplitude diff --git a/qt_ui/patterns/threephase/w_shape.py b/qt_ui/patterns/threephase/w_shape.py new file mode 100644 index 0000000..b3d52df --- /dev/null +++ b/qt_ui/patterns/threephase/w_shape.py @@ -0,0 +1,68 @@ +""" +W Shape Pattern - Complex W-shaped movement with multiple peaks and valleys +""" +import numpy as np +from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern + + +@register_pattern(category="complex") +class WShapePattern(ThreephasePattern): + display_name = "W Shape" + description = "Complex W-shaped pattern with multiple peaks and valleys. Higher base speed with intricate path changes. Provides varied, unpredictable stimulation with multiple transition points." + + def __init__(self, amplitude=1.0, velocity=1.0): + super().__init__(amplitude, velocity) + self.time = 0 + + def update(self, dt: float): + self.time = self.time + dt * 2 * self.velocity # Base speed doubled + + # Create a full W cycle (4π for complete W with two V shapes) + cycle_time = self.time % (4 * np.pi) + phase = int(cycle_time / np.pi) # 0, 1, 2, 3 + phase_progress = (cycle_time % np.pi) / np.pi # 0 to 1 within each phase + + if phase == 0: + # First V forward: center (0,1) → right valley (0.5,0) → right peak (1,1) + if phase_progress <= 0.5: + t = phase_progress * 2 + beta = t * 0.5 + alpha = 1 - t + else: + t = (phase_progress - 0.5) * 2 + beta = 0.5 + t * 0.5 + alpha = t + elif phase == 1: + # First V backward: right peak (1,1) → right valley (0.5,0) → center (0,1) + if phase_progress <= 0.5: + t = phase_progress * 2 + beta = 1 - t * 0.5 + alpha = 1 - t + else: + t = (phase_progress - 0.5) * 2 + beta = 0.5 - t * 0.5 + alpha = t + elif phase == 2: + # Second V forward: center (0,1) → left valley (-0.5,0) → left peak (-1,1) + if phase_progress <= 0.5: + t = phase_progress * 2 + beta = -t * 0.5 + alpha = 1 - t + else: + t = (phase_progress - 0.5) * 2 + beta = -0.5 - t * 0.5 + alpha = t + else: # phase == 3 + # Second V backward: left peak (-1,1) → left valley (-0.5,0) → center (0,1) + if phase_progress <= 0.5: + t = phase_progress * 2 + beta = -1 + t * 0.5 + alpha = 1 - t + else: + t = (phase_progress - 0.5) * 2 + beta = -0.5 + t * 0.5 + alpha = t + + alpha = np.clip(alpha, 0, 1) + beta = np.clip(beta, -1, 1) + return alpha * self.amplitude, beta * self.amplitude diff --git a/qt_ui/patterns/threephase_patterns.py b/qt_ui/patterns/threephase_patterns.py index a1327e9..a677f7e 100644 --- a/qt_ui/patterns/threephase_patterns.py +++ b/qt_ui/patterns/threephase_patterns.py @@ -1,65 +1,17 @@ -from abc import ABC, abstractmethod -import numpy as np import time import logging - +import numpy as np from PySide6 import QtCore - import qt_ui.settings from stim_math.axis import AbstractAxis, WriteProtectedAxis +from qt_ui.patterns.threephase.base import get_registered_patterns +from qt_ui.patterns.threephase.mouse import MousePattern +from qt_ui.services.pattern_service import PatternControlService +# Import other patterns to trigger registration +import qt_ui.patterns.threephase # This will import and register all patterns logger = logging.getLogger('restim.motion_generation') - -class ThreephasePattern(ABC): - def __init__(self): - ... - - def name(self): - ... - - @abstractmethod - def update(self, dt: float): - ... - - -class MousePattern(ThreephasePattern): - def __init__(self, alpha: AbstractAxis, beta: AbstractAxis): - super().__init__() - self.alpha = alpha - self.beta = beta - self.x = 0.00001 # hack to force display update on load - self.y = 0 - - def name(self): - return "mouse" - - def mouse_event(self, x, y): - self.alpha.add(x) - self.beta.add(y) - self.x = x - self.y = y - - def update(self, dt: float): - return self.x, self.y - - def last_position_is_mouse_position(self): - return (self.x, self.y) == (self.alpha.last_value(), self.beta.last_value()) - - -class CirclePattern(ThreephasePattern): - def __init__(self): - super().__init__() - self.angle = 0 - - def name(self): - return "Circle" - - def update(self, dt: float): - self.angle = self.angle + dt * 1 - return np.cos(self.angle), np.sin(self.angle) - - class ThreephaseMotionGenerator(QtCore.QObject): def __init__(self, parent, alpha: AbstractAxis, beta: AbstractAxis): super().__init__(parent) @@ -69,11 +21,16 @@ def __init__(self, parent, alpha: AbstractAxis, beta: AbstractAxis): self.script_alpha = None self.script_beta = None + # Initialize pattern service for preferences + self.pattern_service = PatternControlService() + + # Instantiate MousePattern with axes self.mouse_pattern = MousePattern(alpha, beta) - self.patterns = [ - self.mouse_pattern, - CirclePattern(), - ] + + # Load patterns respecting user preferences + self.refresh_patterns() + + # Default to mouse pattern self.pattern = self.mouse_pattern self.velocity = 1 @@ -92,13 +49,48 @@ def set_enable(self, enable): else: self.timer.stop() + def refresh_patterns(self): + """Refresh the patterns list based on current user preferences""" + # Get available patterns from the service, respecting user preferences + available_patterns = self.pattern_service.get_available_patterns(respect_user_preferences=True) + + # Get registry for pattern classes + registry = get_registered_patterns() + + # Rebuild patterns list + self.patterns = [] + + for pattern_info in available_patterns: + pattern_name = pattern_info['name'] + class_name = pattern_info['class_name'] + + if class_name == 'MousePattern': + # Always include mouse pattern (special case with axes) + self.patterns.append(self.mouse_pattern) + elif pattern_name in registry: + # Instantiate other patterns + pattern_cls = registry[pattern_name] + self.patterns.append(pattern_cls()) + else: + # Fallback - find by class name + for reg_name, pattern_cls in registry.items(): + if pattern_cls.__name__ == class_name: + self.patterns.append(pattern_cls()) + break + + logger.info(f"Refreshed patterns: {len(self.patterns)} patterns loaded") + for pattern in self.patterns: + logger.debug(f" - {pattern.name()}") + def set_pattern(self, pattern): - if issubclass(pattern.__class__, ThreephasePattern): + if isinstance(pattern, MousePattern): + self.pattern = self.mouse_pattern + elif pattern in self.patterns: self.pattern = pattern def set_scripts(self, alpha, beta): - self.script_alpha = alpha if issubclass(alpha.__class__, WriteProtectedAxis) else None - self.script_beta = beta if issubclass(beta.__class__, WriteProtectedAxis) else None + self.script_alpha = alpha if isinstance(alpha, WriteProtectedAxis) else None + self.script_beta = beta if isinstance(beta, WriteProtectedAxis) else None def any_scripts_loaded(self): return (self.script_alpha, self.script_beta) != (None, None) @@ -111,17 +103,14 @@ def timeout(self): self.last_update_time = time.time() if not self.any_scripts_loaded(): - if issubclass(self.pattern.__class__, MousePattern): + if isinstance(self.pattern, MousePattern): if self.pattern.last_position_is_mouse_position(): - # mouse position, display update already handled by control. pass else: - # tcode position, send lagged position a = self.alpha.interpolate(time.time() - self.latency) b = self.beta.interpolate(time.time() - self.latency) self.position_updated.emit(a, b) else: - # update pattern, display lagged position a, b = self.pattern.update(dt * self.velocity) self.alpha.add(a) self.beta.add(b) @@ -129,7 +118,6 @@ def timeout(self): b = self.beta.interpolate(time.time() - self.latency) self.position_updated.emit(a, b) else: - # update display with data from funscript if self.script_alpha: a = self.script_alpha.interpolate(time.time() - self.latency) else: @@ -151,22 +139,4 @@ def refreshSettings(self): self.timer.setInterval(int(1000 // np.clip(qt_ui.settings.display_fps.get(), 1.0, 500.0))) self.latency = qt_ui.settings.display_latency.get() / 1000.0 - position_updated = QtCore.Signal(float, float) # a, b - -# TODO: re-instate old patterns and add more -""" - if self.pattern == Pattern.A: - self.theta += elapsed * self.velocity - xy = (np.cos(self.theta), 0) - elif self.pattern == Pattern.B: - self.theta += elapsed * self.velocity - xy = (np.cos(self.theta) * 0.5, np.cos(self.theta) * 3**.5/2) - elif self.pattern == Pattern.C: - self.theta += elapsed * self.velocity - xy = (np.cos(self.theta) * 0.5, -np.cos(self.theta) * 3**.5/2) - elif self.pattern == Pattern.LARGE_CIRCLE: - self.theta += elapsed * self.velocity - xy = (np.cos(self.theta), np.sin(self.theta)) - elif self.pattern == Pattern.MOUSE: - return -""" \ No newline at end of file + position_updated = QtCore.Signal(float, float) # a, b \ No newline at end of file diff --git a/qt_ui/preferences_dialog.py b/qt_ui/preferences_dialog.py index cecd18c..29246dd 100644 --- a/qt_ui/preferences_dialog.py +++ b/qt_ui/preferences_dialog.py @@ -1,22 +1,32 @@ import functools from PySide6.QtSerialPort import QSerialPortInfo -from PySide6.QtWidgets import QDialog, QAbstractButton, QDialogButtonBox, QAbstractItemView, QHeaderView, QComboBox +from PySide6.QtWidgets import QDialog, QAbstractButton, QDialogButtonBox, QAbstractItemView, QHeaderView, QComboBox, QTableWidgetItem, QCheckBox, QApplication +from PySide6.QtCore import Qt, Signal, QTimer from qt_ui.preferences_dialog_ui import Ui_PreferencesDialog from qt_ui.models.funscript_kit import FunscriptKitModel +from qt_ui.services.pattern_service import PatternControlService import qt_ui.settings import sounddevice as sd class PreferencesDialog(QDialog, Ui_PreferencesDialog): + # Signal emitted when pattern preferences change + patterns_changed = Signal() + def __init__(self, parent=None): super().__init__(parent) self.setupUi(self) self.tabWidget.setCurrentIndex(0) + # Initialize pattern service and cache pattern data immediately + self.pattern_service = PatternControlService() + self._cached_patterns = None + self._cache_patterns_data() + self.loadSettings() self.audio_api.currentIndexChanged.connect(self.repopulate_audio_devices) @@ -39,6 +49,9 @@ def __init__(self, parent=None): QAbstractItemView.AnyKeyPressed ) + # patterns setup - do this immediately during initialization + self.setup_patterns_tab() + # media sync reset buttons self.mpc_reload.clicked.connect( functools.partial(self.mpc_address.setText, qt_ui.settings.media_sync_mpc_address.default_value) @@ -56,6 +69,14 @@ def __init__(self, parent=None): # focstim/neostim reload serial devices self.refresh_serial_devices.clicked.connect(self.repopulate_serial_devices) self.neostim_refresh_serial_devices.clicked.connect(self.repopulate_serial_devices) + + def _cache_patterns_data(self): + """Cache pattern data at startup to avoid late discovery issues""" + try: + # Pre-load all pattern data immediately + self._cached_patterns = self.pattern_service.get_available_patterns(respect_user_preferences=False) + except Exception as e: + self._cached_patterns = [] def exec(self): self.loadSettings() @@ -132,6 +153,9 @@ def loadSettings(self): # funscript mapping self.tableView.setModel(FunscriptKitModel.load_from_settings()) + + # refresh pattern preferences (just reload checkboxes from settings) + self.refresh_pattern_preferences() def repopulate_audio_devices(self): self.audio_output_device.clear() @@ -239,3 +263,91 @@ def saveSettings(self): def funscript_reset_defaults(self): self.tableView.model().reset_to_defaults() + + def setup_patterns_tab(self): + """Setup the patterns tab with cached pattern data""" + # Use cached patterns data to avoid late discovery + patterns = self._cached_patterns or [] + + if not patterns: + return + + # Set up table with known size + self.patterns_table.setRowCount(len(patterns)) + + # Populate table rows + for row, pattern in enumerate(patterns): + # Column 0: Pattern name + name_item = QTableWidgetItem(pattern['name']) + name_item.setFlags(Qt.ItemIsEnabled | Qt.ItemIsSelectable) + self.patterns_table.setItem(row, 0, name_item) + + # Column 1: Enabled checkbox (only for non-priority patterns) + if pattern['class_name'] in ['MousePattern', 'CirclePattern']: + # For Mouse and Circle patterns, show blank cell + enabled_item = QTableWidgetItem("") + enabled_item.setFlags(Qt.ItemIsEnabled) + self.patterns_table.setItem(row, 1, enabled_item) + else: + # For other patterns, create checkbox + checkbox = QCheckBox() + pattern_enabled = self.pattern_service.is_pattern_enabled(pattern['name']) + checkbox.setChecked(pattern_enabled) + checkbox.setProperty("pattern_name", pattern['name']) + checkbox.setProperty("class_name", pattern['class_name']) + checkbox.toggled.connect(self.on_pattern_checkbox_changed) + + # Add checkbox to table + checkbox_item = QTableWidgetItem() + checkbox_item.setFlags(Qt.ItemIsEnabled) + self.patterns_table.setItem(row, 1, checkbox_item) + self.patterns_table.setCellWidget(row, 1, checkbox) + + # Simple, single layout update + self.patterns_table.resizeRowsToContents() + + # Connect buttons + self.button_patterns_enable_all.clicked.connect(self.enable_all_patterns) + self.button_patterns_disable_all.clicked.connect(self.disable_all_patterns) + + def refresh_pattern_preferences(self): + """Refresh checkbox states from current settings without rebuilding the UI""" + if not hasattr(self, 'patterns_table'): + return + + for row in range(self.patterns_table.rowCount()): + checkbox = self.patterns_table.cellWidget(row, 1) + if isinstance(checkbox, QCheckBox): + pattern_name = checkbox.property("pattern_name") + if pattern_name: + # Update checkbox state from settings + enabled = self.pattern_service.is_pattern_enabled(pattern_name) + checkbox.setChecked(enabled) + + def enable_all_patterns(self): + """Enable all patterns with checkboxes""" + for row in range(self.patterns_table.rowCount()): + widget = self.patterns_table.cellWidget(row, 1) + if isinstance(widget, QCheckBox): + widget.setChecked(True) + # Emit signal to notify that patterns have changed + self.patterns_changed.emit() + + def disable_all_patterns(self): + """Disable all patterns with checkboxes""" + for row in range(self.patterns_table.rowCount()): + widget = self.patterns_table.cellWidget(row, 1) + if isinstance(widget, QCheckBox): + widget.setChecked(False) + # Emit signal to notify that patterns have changed + self.patterns_changed.emit() + + def on_pattern_checkbox_changed(self, checked: bool): + """Handle when a pattern checkbox is toggled""" + sender = self.sender() + if isinstance(sender, QCheckBox): + pattern_name = sender.property("pattern_name") + if pattern_name: + self.pattern_service.set_pattern_enabled(pattern_name, checked) + # Emit signal to notify that patterns have changed + self.patterns_changed.emit() diff --git a/qt_ui/preferences_dialog_ui.py b/qt_ui/preferences_dialog_ui.py index 28fcfef..8b63676 100644 --- a/qt_ui/preferences_dialog_ui.py +++ b/qt_ui/preferences_dialog_ui.py @@ -19,8 +19,8 @@ QDialogButtonBox, QDoubleSpinBox, QFormLayout, QFrame, QGridLayout, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPushButton, QSizePolicy, - QSpacerItem, QSpinBox, QTabWidget, QToolButton, - QVBoxLayout, QWidget) + QSpacerItem, QSpinBox, QTabWidget, QTableWidget, QToolButton, + QVBoxLayout, QWidget, QAbstractItemView) from qt_ui.widgets.table_view_with_combobox import TableViewWithComboBox import restim_rc @@ -554,6 +554,58 @@ def setupUi(self, PreferencesDialog): self.verticalLayout_7.addWidget(self.frame) self.tabWidget.addTab(self.tab_funscript, "") + self.tab_patterns = QWidget() + self.tab_patterns.setObjectName(u"tab_patterns") + self.verticalLayout_8 = QVBoxLayout(self.tab_patterns) + self.verticalLayout_8.setObjectName(u"verticalLayout_8") + + # Threephase Patterns title + self.patterns_title = QLabel(self.tab_patterns) + self.patterns_title.setObjectName(u"patterns_title") + self.patterns_title.setText("Threephase Patterns") + font = QFont() + font.setPointSize(12) + font.setBold(True) + self.patterns_title.setFont(font) + self.patterns_title.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.patterns_title.setStyleSheet("QLabel { margin: 10px 0px; }") + self.verticalLayout_8.addWidget(self.patterns_title) + + # Enable/Disable All buttons + self.patterns_button_frame = QFrame(self.tab_patterns) + self.patterns_button_frame.setObjectName(u"patterns_button_frame") + self.patterns_button_frame.setFrameShape(QFrame.Shape.StyledPanel) + self.patterns_button_frame.setFrameShadow(QFrame.Shadow.Raised) + self.patterns_button_layout = QHBoxLayout(self.patterns_button_frame) + self.patterns_button_layout.setObjectName(u"patterns_button_layout") + + self.button_patterns_enable_all = QPushButton(self.patterns_button_frame) + self.button_patterns_enable_all.setObjectName(u"button_patterns_enable_all") + self.patterns_button_layout.addWidget(self.button_patterns_enable_all) + + self.button_patterns_disable_all = QPushButton(self.patterns_button_frame) + self.button_patterns_disable_all.setObjectName(u"button_patterns_disable_all") + self.patterns_button_layout.addWidget(self.button_patterns_disable_all) + + self.patterns_button_spacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + self.patterns_button_layout.addItem(self.patterns_button_spacer) + + self.verticalLayout_8.addWidget(self.patterns_button_frame) + + # Patterns table - will be populated programmatically + self.patterns_table = QTableWidget(self.tab_patterns) + self.patterns_table.setObjectName(u"patterns_table") + self.patterns_table.setColumnCount(2) + self.patterns_table.setHorizontalHeaderLabels([u"Pattern", u"Enabled"]) + self.patterns_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + self.patterns_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) + self.patterns_table.setAlternatingRowColors(True) + self.patterns_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self.patterns_table.verticalHeader().setVisible(False) + + self.verticalLayout_8.addWidget(self.patterns_table) + + self.tabWidget.addTab(self.tab_patterns, "") self.verticalLayout.addWidget(self.tabWidget) @@ -662,6 +714,9 @@ def retranslateUi(self, PreferencesDialog): self.display_latency_ms.setSuffix("") self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_display), QCoreApplication.translate("PreferencesDialog", u"Display", None)) self.button_funscript_reset_defaults.setText(QCoreApplication.translate("PreferencesDialog", u"Reset all to defaults", None)) + self.button_patterns_enable_all.setText(QCoreApplication.translate("PreferencesDialog", u"Enable All", None)) + self.button_patterns_disable_all.setText(QCoreApplication.translate("PreferencesDialog", u"Disable All", None)) + self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_patterns), QCoreApplication.translate("PreferencesDialog", u"Patterns", None)) self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_funscript), QCoreApplication.translate("PreferencesDialog", u"Funscript / T-Code", None)) # retranslateUi diff --git a/qt_ui/services/__init__.py b/qt_ui/services/__init__.py new file mode 100644 index 0000000..a70b302 --- /dev/null +++ b/qt_ui/services/__init__.py @@ -0,0 +1 @@ +# Services package diff --git a/qt_ui/services/fourphase_pattern_service.py b/qt_ui/services/fourphase_pattern_service.py new file mode 100644 index 0000000..45ed2b0 --- /dev/null +++ b/qt_ui/services/fourphase_pattern_service.py @@ -0,0 +1,157 @@ +""" +Pattern Control Service for Fourphase +Handles pattern application and validation logic for fourphase patterns. +""" +import logging +from typing import Dict, Any, Optional, List +from dataclasses import dataclass +from datetime import datetime + +from qt_ui.patterns.fourphase.base import get_registered_patterns + +logger = logging.getLogger('restim.pattern_service.fourphase') + +@dataclass +class PatternParams: + pattern_name: str + amplitude: float = 10.0 + velocity: float = 1.0 + power: float = 100.0 + active: bool = True + + def validate(self) -> List[str]: + errors = [] + if not self.pattern_name: + errors.append("Pattern name is required") + if not (0 <= self.amplitude <= 10): + errors.append("Amplitude must be between 0 and 10") + if not (0.1 <= self.velocity <= 10): + errors.append("Velocity must be between 0.1 and 10") + if not (0 <= self.power <= 100): + errors.append("Power must be between 0 and 100") + return errors + +@dataclass +class ActivityRecord: + pattern_name: str + amplitude: float + velocity: float + power: float + active: bool + timestamp: datetime + + @classmethod + def from_params(cls, params: PatternParams) -> 'ActivityRecord': + return cls( + pattern_name=params.pattern_name, + amplitude=params.amplitude, + velocity=params.velocity, + power=params.power, + active=params.active, + timestamp=datetime.now() + ) + +class FourphasePatternControlService: + def __init__(self, max_history_size: int = 100): + self.activity_history: List[ActivityRecord] = [] + self.max_history_size = max_history_size + self._pattern_registry = get_registered_patterns() + + def get_available_patterns(self) -> List[Dict[str, Any]]: + patterns = [] + for pattern_name, pattern_class in self._pattern_registry.items(): + if hasattr(pattern_class, 'display_name'): + patterns.append({ + "name": pattern_class.display_name, + "description": getattr(pattern_class, 'description', pattern_name), + "category": getattr(pattern_class, 'category', 'basic'), + "class_name": pattern_class.__name__ + }) + else: + patterns.append({ + "name": pattern_name, + "description": f"Pattern: {pattern_name}", + "category": "basic", + "class_name": pattern_class.__name__ + }) + return patterns + + def validate_pattern(self, pattern_name: str) -> bool: + return pattern_name in self._pattern_registry + + def apply_pattern(self, params: PatternParams) -> Dict[str, Any]: + errors = params.validate() + if errors: + logger.warning(f"Pattern validation failed: {errors}") + return { + "success": False, + "errors": errors, + "message": "Parameter validation failed" + } + if not self.validate_pattern(params.pattern_name): + error_msg = f"Pattern '{params.pattern_name}' not found" + logger.warning(error_msg) + return { + "success": False, + "errors": [error_msg], + "message": "Pattern not found" + } + activity = ActivityRecord.from_params(params) + self.add_activity_record(activity) + logger.info(f"Applied pattern: {params.pattern_name} (amp={params.amplitude}, vel={params.velocity}, power={params.power}, active={params.active})") + return { + "success": True, + "errors": [], + "message": f"Applied pattern '{params.pattern_name}' successfully", + "params": params + } + + def stop_stimulation(self) -> Dict[str, Any]: + stop_params = PatternParams( + pattern_name="Stop", + amplitude=0, + velocity=1.0, + power=0, + active=False + ) + activity = ActivityRecord.from_params(stop_params) + self.add_activity_record(activity) + logger.info("Stimulation stopped") + return { + "success": True, + "errors": [], + "message": "Stimulation stopped successfully", + "params": stop_params + } + + def add_activity_record(self, record: ActivityRecord): + self.activity_history.append(record) + if len(self.activity_history) > self.max_history_size: + self.activity_history = self.activity_history[-self.max_history_size:] + + def get_recent_activity(self, count: int = 10) -> List[ActivityRecord]: + return self.activity_history[-count:] + + def get_last_pattern_params(self) -> Optional[PatternParams]: + if not self.activity_history: + return None + last_record = self.activity_history[-1] + return PatternParams( + pattern_name=last_record.pattern_name, + amplitude=last_record.amplitude, + velocity=last_record.velocity, + power=last_record.power, + active=last_record.active + ) + + def build_activity_summary(self, recent_count: int = 5) -> str: + if not self.activity_history: + return "No recent activity." + recent = self.get_recent_activity(recent_count) + summary_lines = [] + for record in recent: + status = "Active" if record.active else "Inactive" + summary_lines.append( + f"- {record.pattern_name}: {status}, Power={record.power}%, Amp={record.amplitude}, Vel={record.velocity} ({record.timestamp.strftime('%H:%M:%S')})" + ) + return "Recent Activity:\n" + "\n".join(summary_lines) diff --git a/qt_ui/services/pattern_service.py b/qt_ui/services/pattern_service.py new file mode 100644 index 0000000..a74b5f0 --- /dev/null +++ b/qt_ui/services/pattern_service.py @@ -0,0 +1,219 @@ +""" +Pattern Control Service +Handles pattern application and validation logic. +Separated from UI to enable reuse and testing. +""" +import logging +from typing import Dict, Any, Optional, List +from dataclasses import dataclass +from datetime import datetime + +from qt_ui.patterns.threephase.base import get_registered_patterns, get_patterns_by_category, get_all_categories +import qt_ui.settings + +logger = logging.getLogger('restim.pattern_service') + +@dataclass +class PatternParams: + pattern_name: str + amplitude: float = 10.0 + velocity: float = 1.0 + power: float = 100.0 + active: bool = True + + def validate(self) -> List[str]: + errors = [] + if not self.pattern_name: + errors.append("Pattern name is required") + if not (0 <= self.amplitude <= 10): + errors.append("Amplitude must be between 0 and 10") + if not (0.1 <= self.velocity <= 10): + errors.append("Velocity must be between 0.1 and 10") + if not (0 <= self.power <= 100): + errors.append("Power must be between 0 and 100") + return errors + +@dataclass +class ActivityRecord: + pattern_name: str + amplitude: float + velocity: float + power: float + active: bool + timestamp: datetime + + @classmethod + def from_params(cls, params: PatternParams) -> 'ActivityRecord': + return cls( + pattern_name=params.pattern_name, + amplitude=params.amplitude, + velocity=params.velocity, + power=params.power, + active=params.active, + timestamp=datetime.now() + ) + +class PatternControlService: + def __init__(self, max_history_size: int = 100): + self.activity_history: List[ActivityRecord] = [] + self.max_history_size = max_history_size + self._pattern_registry = get_registered_patterns() + + def get_available_patterns(self, respect_user_preferences: bool = False) -> List[Dict[str, Any]]: + patterns = [] + priority_patterns = [] + other_patterns = [] + + # Get enabled patterns from settings + enabled_patterns = qt_ui.settings.pattern_enabled.get() + + for pattern_name, pattern_class in self._pattern_registry.items(): + pattern_info = { + "name": pattern_class.display_name if hasattr(pattern_class, 'display_name') else pattern_name, + "description": getattr(pattern_class, 'description', pattern_name), + "category": getattr(pattern_class, 'category', 'basic'), + "class_name": pattern_class.__name__ + } + + # Check if pattern should be filtered out based on user preferences + if respect_user_preferences: + # Mouse and Circle patterns are always enabled + if pattern_class.__name__ not in ['MousePattern', 'CirclePattern']: + # For other patterns, check if they're disabled in settings + # Default to enabled if not in settings + pattern_enabled = enabled_patterns.get(pattern_info["name"], True) + if not pattern_enabled: + continue # Skip disabled patterns + + # Put Mouse and Circle first, then sort the rest alphabetically + if pattern_class.__name__ in ['MousePattern', 'CirclePattern']: + priority_patterns.append(pattern_info) + else: + other_patterns.append(pattern_info) + + # Sort priority patterns (Mouse first, then Circle) + priority_patterns.sort(key=lambda x: (x['class_name'] != 'MousePattern', x['name'])) + + # Sort other patterns alphabetically by display name + other_patterns.sort(key=lambda x: x['name']) + + # Combine: priority patterns first, then others + patterns = priority_patterns + other_patterns + + return patterns + + def get_patterns_by_category(self, category: str) -> List[Dict[str, Any]]: + """Get all patterns in a specific category""" + category_patterns = get_patterns_by_category(category) + patterns = [] + for pattern_name, pattern_class in category_patterns.items(): + patterns.append({ + "name": pattern_class.display_name if hasattr(pattern_class, 'display_name') else pattern_name, + "description": getattr(pattern_class, 'description', pattern_name), + "category": category, + "class_name": pattern_class.__name__ + }) + return patterns + + def get_available_categories(self) -> List[str]: + """Get list of all available categories""" + return sorted(list(get_all_categories())) + + def validate_pattern(self, pattern_name: str) -> bool: + return pattern_name in self._pattern_registry + + def apply_pattern(self, params: PatternParams) -> Dict[str, Any]: + errors = params.validate() + if errors: + logger.warning(f"Pattern validation failed: {errors}") + return { + "success": False, + "errors": errors, + "message": "Parameter validation failed" + } + if not self.validate_pattern(params.pattern_name): + error_msg = f"Pattern '{params.pattern_name}' not found" + logger.warning(error_msg) + return { + "success": False, + "errors": [error_msg], + "message": "Pattern not found" + } + activity = ActivityRecord.from_params(params) + self.add_activity_record(activity) + logger.info(f"Applied pattern: {params.pattern_name} (amp={params.amplitude}, vel={params.velocity}, power={params.power}, active={params.active})") + return { + "success": True, + "errors": [], + "message": f"Applied pattern '{params.pattern_name}' successfully", + "params": params + } + + def stop_stimulation(self) -> Dict[str, Any]: + stop_params = PatternParams( + pattern_name="Stop", + amplitude=0, + velocity=1.0, + power=0, + active=False + ) + activity = ActivityRecord.from_params(stop_params) + self.add_activity_record(activity) + logger.info("Stimulation stopped") + return { + "success": True, + "errors": [], + "message": "Stimulation stopped successfully", + "params": stop_params + } + + def add_activity_record(self, record: ActivityRecord): + self.activity_history.append(record) + if len(self.activity_history) > self.max_history_size: + self.activity_history = self.activity_history[-self.max_history_size:] + + def get_recent_activity(self, count: int = 10) -> List[ActivityRecord]: + return self.activity_history[-count:] + + def get_last_pattern_params(self) -> Optional[PatternParams]: + if not self.activity_history: + return None + last_record = self.activity_history[-1] + return PatternParams( + pattern_name=last_record.pattern_name, + amplitude=last_record.amplitude, + velocity=last_record.velocity, + power=last_record.power, + active=last_record.active + ) + + def build_activity_summary(self, recent_count: int = 5) -> str: + if not self.activity_history: + return "No recent activity." + recent = self.get_recent_activity(recent_count) + summary_lines = [] + for record in recent: + status = "Active" if record.active else "Inactive" + summary_lines.append( + f"- {record.pattern_name}: {status}, Power={record.power}%, Amp={record.amplitude}, Vel={record.velocity} ({record.timestamp.strftime('%H:%M:%S')})" + ) + return "Recent Activity:\n" + "\n".join(summary_lines) + + def is_pattern_enabled(self, pattern_name: str) -> bool: + """Check if a pattern is enabled in user preferences""" + enabled_patterns = qt_ui.settings.pattern_enabled.get() + return enabled_patterns.get(pattern_name, True) # Default to enabled + + def set_pattern_enabled(self, pattern_name: str, enabled: bool): + """Set pattern enabled state in user preferences""" + enabled_patterns = qt_ui.settings.pattern_enabled.get() + # Create a new dictionary to avoid reference issues + new_patterns = enabled_patterns.copy() + new_patterns[pattern_name] = enabled + qt_ui.settings.pattern_enabled.set(new_patterns) + logger.info(f"Pattern '{pattern_name}' {'enabled' if enabled else 'disabled'}") + + def reset_pattern_preferences(self): + """Reset all patterns to enabled state""" + qt_ui.settings.pattern_enabled.set({}) + logger.info("Pattern preferences reset to defaults (all enabled)") diff --git a/qt_ui/settings.py b/qt_ui/settings.py index 137f997..95aa696 100644 --- a/qt_ui/settings.py +++ b/qt_ui/settings.py @@ -160,3 +160,31 @@ def set(self, value): focstim_dump_notifications_to_file = Setting("focstim/dump_notifications_to_file", False, bool) neostim_serial_port = Setting("neostim/serial_port", '', str) + +# Pattern preferences - we'll store this as a JSON string and convert to dict +import json + +class DictSetting(Setting): + """Special setting for dictionary data stored as JSON string""" + def __init__(self, key, default_value): + super().__init__(key, default_value, str) + + def get(self): + if self.cache is None: + json_str = get_settings_instance().value(self.key, json.dumps(self.default_value), str) + try: + self.cache = json.loads(json_str) if json_str else self.default_value + except (json.JSONDecodeError, TypeError): + self.cache = self.default_value + return self.cache + + def set(self, value): + json_str = json.dumps(value) + current_cache = self.cache if self.cache is not None else self.default_value + if json_str != json.dumps(current_cache): + get_settings_instance().setValue(self.key, json_str) + self.cache = value + # Force immediate sync to ensure persistence + get_settings_instance().sync() + +pattern_enabled = DictSetting("patterns/enabled", {}) diff --git a/qt_ui/volume_control_widget.py b/qt_ui/volume_control_widget.py index 0223137..1eb225e 100644 --- a/qt_ui/volume_control_widget.py +++ b/qt_ui/volume_control_widget.py @@ -95,13 +95,21 @@ def timeout(self): else: api_volume = self.axis_api_volume.interpolate(time.time() - self.latency) external_volume = self.axis_external_volume.interpolate(time.time() - self.latency) + + # Progress bar shows the effective applied volume (as it always did) + effective_volume = int(master_volume * api_volume * inactivity_volume * external_volume * 100) self.volume_widget.set_value_and_tooltip( - int(master_volume * api_volume * inactivity_volume * external_volume * 100), + effective_volume, f"master volume: {master_volume * 100:.0f}%\n" + f"tcode/funscript volume: {api_volume * 100:.0f}%\n" + f"inactivity volume: {inactivity_volume * 100:.0f}%\n" + f"external volume: {external_volume * 100:.0f}%" ) + + # Red line shows the master volume setting (from spinbox) + if self.doubleSpinBox_volume is not None: + master_volume_setting = int(self.doubleSpinBox_volume.value()) + self.volume_widget.set_master_volume_indicator(master_volume_setting) def timeout_ramp(self, dt: float): if not self.checkBox_ramp_enabled.isChecked(): diff --git a/qt_ui/widgets/volume_widget.py b/qt_ui/widgets/volume_widget.py index ab0d1be..b7e468e 100644 --- a/qt_ui/widgets/volume_widget.py +++ b/qt_ui/widgets/volume_widget.py @@ -1,5 +1,7 @@ from PySide6 import QtWidgets from PySide6.QtWidgets import QStyleFactory +from PySide6.QtCore import Qt, QPoint +from PySide6.QtGui import QPainter, QColor, QPolygon class VolumeWidget(QtWidgets.QProgressBar): @@ -10,7 +12,48 @@ def __init__(self, parent): # default progress bar styling is awful. if self.style().name() == 'windows11': self.setStyle(QStyleFactory.create("Fusion")) + + self.master_volume = 0 # Red line for master volume setting def set_value_and_tooltip(self, value: int, tooltip: str): self.setValue(value) self.setToolTip(tooltip) + + def set_master_volume_indicator(self, master_volume: int): + """Set the red line position for master volume setting""" + self.master_volume = master_volume + self.update() # Trigger repaint + + def paintEvent(self, event): + # First paint the normal progress bar + super().paintEvent(event) + + # Then paint the red line with notch for master volume + if self.master_volume > 0: + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # Calculate position for the red line + width = self.width() + height = self.height() + master_pos = int((self.master_volume / 100.0) * width) + + # Draw red vertical line + painter.setPen(QColor(255, 0, 0, 200)) # Red line with transparency + painter.drawLine(master_pos, 0, master_pos, height) + + # Draw notch at the top + notch_size = 6 + painter.setBrush(QColor(255, 0, 0, 220)) + painter.setPen(QColor(180, 0, 0, 255)) # Darker red outline + + # Create triangular notch pointing down + notch_points = [ + QPoint(master_pos - notch_size, 0), + QPoint(master_pos + notch_size, 0), + QPoint(master_pos, notch_size) + ] + notch = QPolygon(notch_points) + painter.drawPolygon(notch) + + painter.end() diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..ef2f946 --- /dev/null +++ b/run.bat @@ -0,0 +1,17 @@ +@echo off +setlocal +set VENV_PATH=venv + +if not exist "%VENV_PATH%" ( + echo setting up venv + python -m venv "%VENV_PATH%" +) + +echo activating venv +call "%VENV_PATH%\Scripts\activate.bat" + +echo checking requirements +python -m pip install -r requirements.txt + +echo starting restim +python restim.py From 74b1cb54da0e0bbbf871169e3a582ada2ca5569a Mon Sep 17 00:00:00 2001 From: abacaba-100 Date: Mon, 1 Sep 2025 21:15:01 -0600 Subject: [PATCH 07/47] descriptions to blank strings, not needed on this fork --- qt_ui/patterns/threephase/butterfly.py | 12 +++---- qt_ui/patterns/threephase/circle.py | 4 +-- qt_ui/patterns/threephase/deep_throb.py | 36 +++++++++---------- qt_ui/patterns/threephase/figure_eight.py | 12 +++---- qt_ui/patterns/threephase/jerky_stroke.py | 10 +++--- qt_ui/patterns/threephase/lightning_strike.py | 4 +-- qt_ui/patterns/threephase/micro_circles.py | 4 +-- qt_ui/patterns/threephase/mouse.py | 4 +-- qt_ui/patterns/threephase/orbiting_circles.py | 10 +++--- qt_ui/patterns/threephase/random_walk.py | 4 +-- qt_ui/patterns/threephase/rose_curve.py | 4 +-- qt_ui/patterns/threephase/spirograph.py | 4 +-- qt_ui/patterns/threephase/tremor_circle.py | 8 ++--- .../threephase/vertical_oscillation.py | 14 ++++---- qt_ui/patterns/threephase/w_shape.py | 16 ++++----- 15 files changed, 73 insertions(+), 73 deletions(-) diff --git a/qt_ui/patterns/threephase/butterfly.py b/qt_ui/patterns/threephase/butterfly.py index 205883a..6fc56e3 100644 --- a/qt_ui/patterns/threephase/butterfly.py +++ b/qt_ui/patterns/threephase/butterfly.py @@ -1,5 +1,5 @@ """ -Butterfly Curve Pattern - Artistic butterfly wing mathematical pattern +Butterfly Curve Pattern """ import numpy as np from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern @@ -8,23 +8,23 @@ @register_pattern(category="mathematical") class ButterflyPattern(ThreephasePattern): display_name = "Butterfly Curve" - description = "Artistic butterfly wing pattern based on mathematical curves. Complex, elegant motion with wing-like symmetry. Creates sophisticated, artistic stimulation paths." + description = "" def __init__(self, amplitude=1.0, velocity=1.0): super().__init__(amplitude, velocity) self.time = 0 def update(self, dt: float): - """Update butterfly pattern using classic butterfly curve equations""" - self.time = self.time + dt * self.velocity * 0.5 # Slower for detail + """Update butterfly pattern""" + self.time = self.time + dt * self.velocity * 0.5 - # Classic butterfly curve parametric equations + # Butterfly curve parametric equations t = self.time exp_cos = np.exp(np.cos(t)) alpha = np.sin(t) * (exp_cos - 2 * np.cos(4 * t) - np.sin(t/12)**5) beta = np.cos(t) * (exp_cos - 2 * np.cos(4 * t) - np.sin(t/12)**5) - # Normalize to fit in -1 to 1 range + # Normalize to range scale = 0.15 alpha = alpha * scale beta = beta * scale diff --git a/qt_ui/patterns/threephase/circle.py b/qt_ui/patterns/threephase/circle.py index 7c3f49a..66725b0 100644 --- a/qt_ui/patterns/threephase/circle.py +++ b/qt_ui/patterns/threephase/circle.py @@ -1,5 +1,5 @@ """ -Circle Pattern - simple circular motion +Circle Pattern """ import numpy as np from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern @@ -7,7 +7,7 @@ @register_pattern(category="mathematical") class CirclePattern(ThreephasePattern): display_name = "Circle" - description = "Simple circular motion pattern" + description = "" category = "mathematical" def __init__(self, amplitude: float = 1.0, velocity: float = 1.0): super().__init__(amplitude=amplitude, velocity=velocity) diff --git a/qt_ui/patterns/threephase/deep_throb.py b/qt_ui/patterns/threephase/deep_throb.py index 8321aac..913a840 100644 --- a/qt_ui/patterns/threephase/deep_throb.py +++ b/qt_ui/patterns/threephase/deep_throb.py @@ -1,5 +1,5 @@ """ -Deep Throb Pattern - Slow, deep pulsing with powerful rhythm +Deep Throb Pattern """ import numpy as np from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern @@ -8,33 +8,33 @@ @register_pattern(category="experimental") class DeepThrobPattern(ThreephasePattern): display_name = "Deep Throb" - description = "Slow, deep pulsing with powerful rhythm" + description = "" def __init__(self, amplitude=1.0, velocity=1.0): super().__init__(amplitude, velocity) self.time = 0 def update(self, dt: float): - self.time += dt * self.velocity * 0.4 # Slow, powerful rhythm + self.time += dt * self.velocity * 0.4 # Slow rhythm - # Primary throbbing motion - slow, deep, penetrating + # Primary throbbing motion throb_cycle = (self.time * 0.8) % (2 * np.pi) - # Create a powerful, smooth throbbing motion + # Smooth throbbing motion if throb_cycle < np.pi: - # Rising phase - building intensity + # Rising phase throb_progress = throb_cycle / np.pi - throb_intensity = np.sin(throb_progress * np.pi) ** 2 # Smooth curve + throb_intensity = np.sin(throb_progress * np.pi) ** 2 else: - # Falling phase - gradual release + # Falling phase throb_progress = (throb_cycle - np.pi) / np.pi - throb_intensity = (1.0 - throb_progress) ** 2 # Smooth decay + throb_intensity = (1.0 - throb_progress) ** 2 - # Apply throbbing to Alpha (primary penetration axis) - # Extended range to go negative on downward motion - base_alpha = -0.2 + 1.3 * throb_intensity # -0.2 to 1.1 range (extends to negative) + # Apply throbbing to Alpha + # Extended range + base_alpha = -0.2 + 1.3 * throb_intensity - # Add depth variation - occasionally goes deeper + # Add depth variation depth_cycle = (self.time * 0.2) % (2 * np.pi) if depth_cycle < np.pi * 0.3: # 30% of cycle depth_modifier = 1.0 + 0.4 * np.sin(depth_cycle / (np.pi * 0.3) * np.pi) @@ -43,19 +43,19 @@ def update(self, dt: float): alpha = base_alpha * depth_modifier - # Minimal Beta movement - focused penetration + # Minimal Beta movement beta = 0.1 * np.sin(self.time * 0.6) + 0.05 * np.sin(self.time * 1.1) - # Add powerful pulses during peak intensity + # Add pulses during peak intensity if throb_intensity > 0.8: - pulse_strength = (throb_intensity - 0.8) / 0.2 # 0 to 1 + pulse_strength = (throb_intensity - 0.8) / 0.2 pulse_alpha = 0.2 * pulse_strength * np.sin(self.time * 8.0) pulse_beta = 0.1 * pulse_strength * np.cos(self.time * 6.5) alpha += pulse_alpha beta += pulse_beta - # Ensure powerful but controlled movement - extended negative range - alpha = np.clip(alpha, -0.5, 1.3) # Now extends to -0.5 as requested + # Ensure controlled movement + alpha = np.clip(alpha, -0.5, 1.3) # Extended negative range beta = np.clip(beta, -0.3, 0.3) return alpha * self.amplitude, beta * self.amplitude diff --git a/qt_ui/patterns/threephase/figure_eight.py b/qt_ui/patterns/threephase/figure_eight.py index 1de803a..19e2bfc 100644 --- a/qt_ui/patterns/threephase/figure_eight.py +++ b/qt_ui/patterns/threephase/figure_eight.py @@ -1,5 +1,5 @@ """ -Figure Eight Pattern - Lemniscate motion +Figure Eight Pattern """ import numpy as np from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern @@ -8,16 +8,16 @@ @register_pattern(category="basic") class FigureEightPattern(ThreephasePattern): display_name = "Figure 8" - description = "Lemniscate (figure-8) pattern creating flowing, dynamic motion. Medium speed with crossing loops. Provides varied stimulation with changing directions and intensities." + description = "" def __init__(self, amplitude=1.0, velocity=1.0): super().__init__(amplitude, velocity) self.angle = 0 def update(self, dt: float): - """Update figure-8 position using parametric lemniscate equations""" + """Update figure-8 position""" self.angle = self.angle + dt * self.velocity - # Parametric equations for figure-8 (lemniscate) - alpha = np.sin(self.angle) # Y component: -1 to 1 - beta = 0.5 * np.sin(2 * self.angle) # X component: -0.5 to 0.5 + # Parametric equations for figure-8 + alpha = np.sin(self.angle) # Y component + beta = 0.5 * np.sin(2 * self.angle) # X component return alpha * self.amplitude, beta * self.amplitude diff --git a/qt_ui/patterns/threephase/jerky_stroke.py b/qt_ui/patterns/threephase/jerky_stroke.py index 4ea312a..c1c0bd9 100644 --- a/qt_ui/patterns/threephase/jerky_stroke.py +++ b/qt_ui/patterns/threephase/jerky_stroke.py @@ -1,5 +1,5 @@ """ -Jerky Stroke Pattern - Vertical stroking with sudden jerky interruptions +Jerky Stroke Pattern """ import numpy as np from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern @@ -8,7 +8,7 @@ @register_pattern(category="complex") class JerkyStrokePattern(ThreephasePattern): display_name = "Jerky Stroke" - description = "Vertical stroking with sudden jerky interruptions. Combines slow buildup with quick return phases and micro-jerk overlays. Irregular, surprising motion." + description = "" def __init__(self, amplitude=1.0, velocity=1.0): super().__init__(amplitude, velocity) @@ -23,7 +23,7 @@ def update(self, dt: float): stroke_cycle = 3.0 stroke_progress = (self.time % stroke_cycle) / stroke_cycle - # Create jerky, non-linear stroke progression + # Create jerky stroke progression if stroke_progress < 0.6: smooth_progress = (stroke_progress / 0.6) ** 0.3 alpha = smooth_progress * 2 - 1 @@ -31,13 +31,13 @@ def update(self, dt: float): return_progress = 1 - (1 - (stroke_progress - 0.6) / 0.4) ** 2 alpha = 1 - return_progress * 2 - # Add jerky micro-movements + # Add jerky movements jerk_intensity = 0.08 if (self.jerk_timer % 1.0) < 0.15: jerk_modifier = np.sin(self.jerk_timer * 30) * jerk_intensity alpha += jerk_modifier - # Horizontal positioning with variation + # Horizontal positioning beta = 0.3 * np.sin(self.time * 1.3) + 0.1 * np.sin(self.time * 7.1) alpha = np.clip(alpha, -1, 1) diff --git a/qt_ui/patterns/threephase/lightning_strike.py b/qt_ui/patterns/threephase/lightning_strike.py index 33b7e8e..55ed8c9 100644 --- a/qt_ui/patterns/threephase/lightning_strike.py +++ b/qt_ui/patterns/threephase/lightning_strike.py @@ -1,5 +1,5 @@ """ -Lightning Strike Pattern - Sudden intense strikes with quiet periods +Lightning Strike Pattern """ import numpy as np from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern @@ -8,7 +8,7 @@ @register_pattern(category="experimental") class LightningStrikePattern(ThreephasePattern): display_name = "Lightning Strike" - description = "Sudden intense strikes with quiet periods" + description = "" def __init__(self, amplitude=1.0, velocity=1.0): super().__init__(amplitude, velocity) diff --git a/qt_ui/patterns/threephase/micro_circles.py b/qt_ui/patterns/threephase/micro_circles.py index dc77ceb..a1fb490 100644 --- a/qt_ui/patterns/threephase/micro_circles.py +++ b/qt_ui/patterns/threephase/micro_circles.py @@ -1,5 +1,5 @@ """ -Micro Circles Pattern - Small circular motions with drifting origins +Micro Circles Pattern """ import numpy as np from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern @@ -8,7 +8,7 @@ @register_pattern(category="basic") class MicroCirclesPattern(ThreephasePattern): display_name = "Micro Circles" - description = "Small circular motions with drifting origins" + description = "" def __init__(self, amplitude=1.0, velocity=1.0): super().__init__(amplitude, velocity) diff --git a/qt_ui/patterns/threephase/mouse.py b/qt_ui/patterns/threephase/mouse.py index 5dc6180..66af7c5 100644 --- a/qt_ui/patterns/threephase/mouse.py +++ b/qt_ui/patterns/threephase/mouse.py @@ -1,5 +1,5 @@ """ -Mouse Pattern - direct mouse control +Mouse Pattern """ from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern from stim_math.axis import AbstractAxis @@ -7,7 +7,7 @@ @register_pattern(category="manual") class MousePattern(ThreephasePattern): display_name = "Mouse" - description = "Direct mouse control for manual positioning" + description = "" category = "manual" def __init__(self, alpha: AbstractAxis = None, beta: AbstractAxis = None, amplitude: float = 1.0, velocity: float = 1.0): super().__init__(amplitude=amplitude, velocity=velocity) diff --git a/qt_ui/patterns/threephase/orbiting_circles.py b/qt_ui/patterns/threephase/orbiting_circles.py index 2040c41..e30030d 100644 --- a/qt_ui/patterns/threephase/orbiting_circles.py +++ b/qt_ui/patterns/threephase/orbiting_circles.py @@ -1,5 +1,5 @@ """ -Orbiting Circles Pattern - Varying diameter circles with moving origins +Orbiting Circles Pattern """ import numpy as np from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern @@ -8,7 +8,7 @@ @register_pattern(category="complex") class OrbitingCirclesPattern(ThreephasePattern): display_name = "Orbiting Circles" - description = "Varying diameter circles with origins that move in interesting patterns around the Alpha (Y) axis, lingering at the most sensitive spot (Alpha=1, Beta=0). Combines orbital motion with size variation." + description = "" def __init__(self, amplitude=1.0, velocity=1.0): super().__init__(amplitude, velocity) @@ -17,7 +17,7 @@ def __init__(self, amplitude=1.0, velocity=1.0): def update(self, dt: float): self.time = self.time + dt * self.velocity - # Moving origin along Alpha (Y) axis with lingering at sensitive spot (Alpha=1, Beta=0) + # Moving origin along Alpha axis origin_base_freq = 0.4 origin_alpha = 0.7 * np.sin(self.time * origin_base_freq) @@ -26,14 +26,14 @@ def update(self, dt: float): linger_pull = 0.4 * np.exp(-((origin_alpha - 1.0)**2) * 5) * (1 + np.sin(self.time * linger_freq * 10)) origin_alpha += linger_pull - # Slight horizontal drift for the origin + # Slight horizontal drift origin_beta = 0.15 * np.sin(self.time * 0.7) + 0.1 * np.cos(self.time * 1.3) # Varying diameter circles around the moving origin circle_freq = 3.0 base_radius = 0.3 - # Radius varies with multiple harmonics for organic feel + # Radius varies with multiple harmonics radius_variation = 0.6 * (1 + 0.5 * np.sin(self.time * 0.8) + 0.3 * np.sin(self.time * 2.1)) current_radius = base_radius * radius_variation diff --git a/qt_ui/patterns/threephase/random_walk.py b/qt_ui/patterns/threephase/random_walk.py index 5974b65..fbe9800 100644 --- a/qt_ui/patterns/threephase/random_walk.py +++ b/qt_ui/patterns/threephase/random_walk.py @@ -1,5 +1,5 @@ """ -Random Walk Pattern - Unpredictable wandering motion with center-pull +Random Walk Pattern """ import numpy as np from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern @@ -8,7 +8,7 @@ @register_pattern(category="experimental") class RandomWalkPattern(ThreephasePattern): display_name = "Random Walk" - description = "Unpredictable wandering motion with pseudo-random direction changes. Includes center-pull to prevent drift. Creates natural, organic, unpredictable movement patterns." + description = "" def __init__(self, amplitude=1.0, velocity=1.0): super().__init__(amplitude, velocity) diff --git a/qt_ui/patterns/threephase/rose_curve.py b/qt_ui/patterns/threephase/rose_curve.py index 1649b55..bfac700 100644 --- a/qt_ui/patterns/threephase/rose_curve.py +++ b/qt_ui/patterns/threephase/rose_curve.py @@ -1,5 +1,5 @@ """ -Rose Curve Pattern - Mathematical rose with beautiful petals +Rose Curve Pattern """ import numpy as np from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern @@ -8,7 +8,7 @@ @register_pattern(category="mathematical") class RoseCurvePattern(ThreephasePattern): display_name = "Rose Curve" - description = "Mathematical rose pattern with 5 beautiful petals. Smooth, organic flower-like motion with symmetrical design. Gentle yet varied stimulation following natural curves." + description = "" def __init__(self, amplitude=1.0, velocity=1.0): super().__init__(amplitude, velocity) diff --git a/qt_ui/patterns/threephase/spirograph.py b/qt_ui/patterns/threephase/spirograph.py index 246726d..5520f0d 100644 --- a/qt_ui/patterns/threephase/spirograph.py +++ b/qt_ui/patterns/threephase/spirograph.py @@ -1,5 +1,5 @@ """ -Spirograph Pattern - Classic cycloid mathematical pattern +Spirograph Pattern """ import numpy as np from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern @@ -8,7 +8,7 @@ @register_pattern(category="mathematical") class SpirographPattern(ThreephasePattern): display_name = "Spirograph" - description = "Classic cycloid pattern created by a circle rolling inside another circle. Creates intricate geometric loops and spirals. Mathematical precision with elegant, flowing complexity." + description = "" def __init__(self, amplitude=1.0, velocity=1.0): super().__init__(amplitude, velocity) diff --git a/qt_ui/patterns/threephase/tremor_circle.py b/qt_ui/patterns/threephase/tremor_circle.py index 0d6667a..8d5db15 100644 --- a/qt_ui/patterns/threephase/tremor_circle.py +++ b/qt_ui/patterns/threephase/tremor_circle.py @@ -1,5 +1,5 @@ """ -Tremor Circle Pattern - Circular motion with tremor-like micro-shakes +Tremor Circle Pattern """ import numpy as np from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern @@ -8,7 +8,7 @@ @register_pattern(category="complex") class TremorCirclePattern(ThreephasePattern): display_name = "Tremor Circle" - description = "Circular motion overlaid with fine tremor-like micro-shakes. Base circular movement modulated by high-frequency oscillations. Natural, subtle vibratory motion." + description = "" def __init__(self, amplitude=1.0, velocity=1.0): super().__init__(amplitude, velocity) @@ -24,7 +24,7 @@ def update(self, dt: float): base_alpha = 0.6 * np.cos(circle_angle) base_beta = 0.6 * np.sin(circle_angle) - # Tremor modulation - high frequency, low amplitude + # Tremor modulation tremor_freq1 = 25.0 tremor_freq2 = 31.5 tremor_intensity = 0.08 @@ -38,7 +38,7 @@ def update(self, dt: float): np.cos(self.time * tremor_freq2 * 0.9) * 0.4 ) - # Amplitude modulation for tremor realism + # Amplitude modulation tremor_envelope = 0.8 + 0.2 * np.sin(self.time * 0.7) tremor_alpha *= tremor_envelope tremor_beta *= tremor_envelope diff --git a/qt_ui/patterns/threephase/vertical_oscillation.py b/qt_ui/patterns/threephase/vertical_oscillation.py index cde9203..a1e4994 100644 --- a/qt_ui/patterns/threephase/vertical_oscillation.py +++ b/qt_ui/patterns/threephase/vertical_oscillation.py @@ -1,5 +1,5 @@ """ -Vertical Oscillation Pattern - Layered movement with slow vertical and rapid oscillations +Vertical Oscillation Pattern """ import numpy as np from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern @@ -8,17 +8,17 @@ @register_pattern(category="basic") class VerticalOscillationPattern(ThreephasePattern): display_name = "Vertical Oscillation" - description = "Combines slow vertical movement (2-second cycles) with rapid oscillations (5Hz). Creates complex layered stimulation with both rhythmic and vibrational components." + description = "" def __init__(self, amplitude=1.0, velocity=1.0): super().__init__(amplitude, velocity) self.time = 0 def update(self, dt: float): - """Update position with layered vertical movement and oscillations""" + """Update position with layered movement""" self.time = self.time + dt * self.velocity - # Alpha: slow vertical movement, 2 second cycles (0.5 Hz) - alpha = np.sin(2 * np.pi * 0.5 * self.time) # 0.5 Hz = 2 second cycle - # Beta: rapid oscillations, 5 oscillations per second (5 Hz) - beta = 0.2 * np.sin(2 * np.pi * 5 * self.time) # 5 Hz oscillations + # Alpha: slow vertical movement, 2 second cycles + alpha = np.sin(2 * np.pi * 0.5 * self.time) # 0.5 Hz + # Beta: rapid oscillations, 5 oscillations per second + beta = 0.2 * np.sin(2 * np.pi * 5 * self.time) # 5 Hz return alpha * self.amplitude, beta * self.amplitude diff --git a/qt_ui/patterns/threephase/w_shape.py b/qt_ui/patterns/threephase/w_shape.py index b3d52df..16238e3 100644 --- a/qt_ui/patterns/threephase/w_shape.py +++ b/qt_ui/patterns/threephase/w_shape.py @@ -1,5 +1,5 @@ """ -W Shape Pattern - Complex W-shaped movement with multiple peaks and valleys +W Shape Pattern """ import numpy as np from qt_ui.patterns.threephase.base import ThreephasePattern, register_pattern @@ -8,7 +8,7 @@ @register_pattern(category="complex") class WShapePattern(ThreephasePattern): display_name = "W Shape" - description = "Complex W-shaped pattern with multiple peaks and valleys. Higher base speed with intricate path changes. Provides varied, unpredictable stimulation with multiple transition points." + description = "" def __init__(self, amplitude=1.0, velocity=1.0): super().__init__(amplitude, velocity) @@ -17,13 +17,13 @@ def __init__(self, amplitude=1.0, velocity=1.0): def update(self, dt: float): self.time = self.time + dt * 2 * self.velocity # Base speed doubled - # Create a full W cycle (4π for complete W with two V shapes) + # Create a full W cycle cycle_time = self.time % (4 * np.pi) phase = int(cycle_time / np.pi) # 0, 1, 2, 3 - phase_progress = (cycle_time % np.pi) / np.pi # 0 to 1 within each phase + phase_progress = (cycle_time % np.pi) / np.pi # 0 to 1 if phase == 0: - # First V forward: center (0,1) → right valley (0.5,0) → right peak (1,1) + # First V forward if phase_progress <= 0.5: t = phase_progress * 2 beta = t * 0.5 @@ -33,7 +33,7 @@ def update(self, dt: float): beta = 0.5 + t * 0.5 alpha = t elif phase == 1: - # First V backward: right peak (1,1) → right valley (0.5,0) → center (0,1) + # First V backward if phase_progress <= 0.5: t = phase_progress * 2 beta = 1 - t * 0.5 @@ -43,7 +43,7 @@ def update(self, dt: float): beta = 0.5 - t * 0.5 alpha = t elif phase == 2: - # Second V forward: center (0,1) → left valley (-0.5,0) → left peak (-1,1) + # Second V forward if phase_progress <= 0.5: t = phase_progress * 2 beta = -t * 0.5 @@ -53,7 +53,7 @@ def update(self, dt: float): beta = -0.5 - t * 0.5 alpha = t else: # phase == 3 - # Second V backward: left peak (-1,1) → left valley (-0.5,0) → center (0,1) + # Second V backward if phase_progress <= 0.5: t = phase_progress * 2 beta = -1 + t * 0.5 From 0b18f91969af52fbb4da088bff0968552b908e8f Mon Sep 17 00:00:00 2001 From: abacaba-100 Date: Mon, 1 Sep 2025 21:16:07 -0600 Subject: [PATCH 08/47] more string and keyword removal, non LLM fork --- qt_ui/patterns/threephase/random_walk.py | 4 ++-- qt_ui/patterns/threephase/rose_curve.py | 2 +- qt_ui/patterns/threephase/spirograph.py | 4 ++-- qt_ui/patterns/threephase/w_shape.py | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/qt_ui/patterns/threephase/random_walk.py b/qt_ui/patterns/threephase/random_walk.py index fbe9800..80991e4 100644 --- a/qt_ui/patterns/threephase/random_walk.py +++ b/qt_ui/patterns/threephase/random_walk.py @@ -16,7 +16,7 @@ def __init__(self, amplitude=1.0, velocity=1.0): self.alpha_pos = 0.0 self.beta_pos = 0.0 self.center_pull_strength = 0.003 - self.noise_strength = 0.8 # Reduced from 3.5 to prevent excessive buildup + self.noise_strength = 0.8 def _pseudo_random(self, t): """Simple pseudo-random function based on time""" @@ -30,7 +30,7 @@ def update(self, dt: float): # Get pseudo-random direction rand_x, rand_y = self._pseudo_random(self.time) - # Random walk with gentle drift + # Random walk self.alpha_pos += rand_x * self.noise_strength * dt self.beta_pos += rand_y * self.noise_strength * dt diff --git a/qt_ui/patterns/threephase/rose_curve.py b/qt_ui/patterns/threephase/rose_curve.py index bfac700..06f4766 100644 --- a/qt_ui/patterns/threephase/rose_curve.py +++ b/qt_ui/patterns/threephase/rose_curve.py @@ -21,7 +21,7 @@ def update(self, dt: float): # Rose curve equation: r = cos(n*θ) theta = self.time - r = abs(np.cos(self.n * theta)) # Take absolute value to keep all petals + r = abs(np.cos(self.n * theta)) alpha = r * np.cos(theta) beta = r * np.sin(theta) diff --git a/qt_ui/patterns/threephase/spirograph.py b/qt_ui/patterns/threephase/spirograph.py index 5520f0d..cb1d937 100644 --- a/qt_ui/patterns/threephase/spirograph.py +++ b/qt_ui/patterns/threephase/spirograph.py @@ -15,7 +15,7 @@ def __init__(self, amplitude=1.0, velocity=1.0): self.time = 0 self.R = 3.0 # Radius of fixed circle self.r = 1.0 # Radius of rolling circle - self.d = 0.7 # Distance from center of rolling circle to drawing point + self.d = 0.7 # Distance from center def update(self, dt: float): """Update spirograph position using classic cycloid equations""" @@ -26,7 +26,7 @@ def update(self, dt: float): alpha = (self.R + self.r) * np.cos(t) - self.d * np.cos((self.R + self.r) / self.r * t) beta = (self.R + self.r) * np.sin(t) - self.d * np.sin((self.R + self.r) / self.r * t) - # Normalize to -1 to 1 range + # Normalize to range max_val = (self.R + self.r + self.d) alpha = alpha / max_val * 0.8 beta = beta / max_val * 0.8 diff --git a/qt_ui/patterns/threephase/w_shape.py b/qt_ui/patterns/threephase/w_shape.py index 16238e3..3949b0b 100644 --- a/qt_ui/patterns/threephase/w_shape.py +++ b/qt_ui/patterns/threephase/w_shape.py @@ -20,7 +20,7 @@ def update(self, dt: float): # Create a full W cycle cycle_time = self.time % (4 * np.pi) phase = int(cycle_time / np.pi) # 0, 1, 2, 3 - phase_progress = (cycle_time % np.pi) / np.pi # 0 to 1 + phase_progress = (cycle_time % np.pi) / np.pi if phase == 0: # First V forward From db2f19caf515d74b5ddbd058805398dccff5788a Mon Sep 17 00:00:00 2001 From: diglet48 Date: Tue, 2 Sep 2025 15:18:27 +0200 Subject: [PATCH 09/47] Process pull request #24 review issues * remove unused signal * refresh patterns in combobox after closing preferences dialog * use OK/Apply/Cancel pattern in preferences dialog instead of immediately saving changes * use *.ui files and code generator for preferences dialog --- designer/preferencesdialog.ui | 82 ++++++++++++++++++++++ qt_ui/mainwindow.py | 29 ++++++-- qt_ui/patterns/threephase_patterns.py | 4 +- qt_ui/preferences_dialog.py | 37 +++++----- qt_ui/preferences_dialog_ui.py | 99 ++++++++++++++------------- 5 files changed, 174 insertions(+), 77 deletions(-) diff --git a/designer/preferencesdialog.ui b/designer/preferencesdialog.ui index 5a1f476..4b74103 100644 --- a/designer/preferencesdialog.ui +++ b/designer/preferencesdialog.ui @@ -797,6 +797,88 @@ + + + Patterns + + + + + + <html><head/><body><p><span style=" font-size:12pt; font-weight:700;">Threephase patterns</span></p></body></html> + + + Qt::AlignmentFlag::AlignCenter + + + 10 + + + + + + + QFrame::Shape::StyledPanel + + + QFrame::Shadow::Raised + + + + + + Enable All + + + + + + + Disable All + + + + + + + Qt::Orientation::Horizontal + + + + 40 + 20 + + + + + + + + + + + true + + + QAbstractItemView::SelectionBehavior::SelectRows + + + false + + + + Pattern + + + + + Enabled + + + + + + diff --git a/qt_ui/mainwindow.py b/qt_ui/mainwindow.py index a42d93c..9588db5 100644 --- a/qt_ui/mainwindow.py +++ b/qt_ui/mainwindow.py @@ -387,9 +387,6 @@ def set_visible(widget, state): if config.device_type in (DeviceType.AUDIO_THREE_PHASE, DeviceType.NEOSTIM_THREE_PHASE, DeviceType.FOCSTIM_THREE_PHASE): self.motion_3.set_enable(True) self.motion_4.set_enable(False) - self.comboBox_patternSelect.clear() - for pattern in self.motion_3.patterns: - self.comboBox_patternSelect.addItem(pattern.name(), pattern) self.stackedWidget_visual.setCurrentIndex( self.stackedWidget_visual.indexOf(self.page_threephase) ) @@ -397,13 +394,12 @@ def set_visible(widget, state): if config.device_type == DeviceType.FOCSTIM_FOUR_PHASE: self.motion_3.set_enable(False) self.motion_4.set_enable(True) - self.comboBox_patternSelect.clear() - for pattern in self.motion_4.patterns: - self.comboBox_patternSelect.addItem(pattern.name(), pattern) self.stackedWidget_visual.setCurrentIndex( self.stackedWidget_visual.indexOf(self.page_fourphase) ) + self.refresh_pattern_combobox() + def pattern_selection_changed(self, index): pattern = self.comboBox_patternSelect.currentData() self.motion_3.set_pattern(pattern) @@ -537,6 +533,27 @@ def reload_settings(self): self.tab_a_b_testing.refreshSettings() self.motion_3.refreshSettings() self.motion_4.refreshSettings() + self.refresh_pattern_combobox() + + 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): + self.comboBox_patternSelect.clear() + for pattern in self.motion_3.patterns: + self.comboBox_patternSelect.addItem(pattern.name(), pattern) + else: + self.comboBox_patternSelect.clear() + for pattern in self.motion_4.patterns: + self.comboBox_patternSelect.addItem(pattern.name(), pattern) + + # try to select pattern with similar name as was previously selected + index = self.comboBox_patternSelect.findText(currently_selected_text) + if index == -1: + index = 0 + self.comboBox_patternSelect.setCurrentIndex(index) + def save_settings(self): """ diff --git a/qt_ui/patterns/threephase_patterns.py b/qt_ui/patterns/threephase_patterns.py index a677f7e..9f5854d 100644 --- a/qt_ui/patterns/threephase_patterns.py +++ b/qt_ui/patterns/threephase_patterns.py @@ -27,9 +27,6 @@ def __init__(self, parent, alpha: AbstractAxis, beta: AbstractAxis): # Instantiate MousePattern with axes self.mouse_pattern = MousePattern(alpha, beta) - # Load patterns respecting user preferences - self.refresh_patterns() - # Default to mouse pattern self.pattern = self.mouse_pattern @@ -138,5 +135,6 @@ def mouse_event(self, a, b): def refreshSettings(self): self.timer.setInterval(int(1000 // np.clip(qt_ui.settings.display_fps.get(), 1.0, 500.0))) self.latency = qt_ui.settings.display_latency.get() / 1000.0 + self.refresh_patterns() position_updated = QtCore.Signal(float, float) # a, b \ No newline at end of file diff --git a/qt_ui/preferences_dialog.py b/qt_ui/preferences_dialog.py index 29246dd..f49e9b8 100644 --- a/qt_ui/preferences_dialog.py +++ b/qt_ui/preferences_dialog.py @@ -13,9 +13,6 @@ class PreferencesDialog(QDialog, Ui_PreferencesDialog): - # Signal emitted when pattern preferences change - patterns_changed = Signal() - def __init__(self, parent=None): super().__init__(parent) self.setupUi(self) @@ -261,6 +258,18 @@ def saveSettings(self): # funscript mapping self.tableView.model().save_to_settings() + # patterns + for row in range(self.patterns_table.rowCount()): + checkbox = self.patterns_table.cellWidget(row, 1) + if isinstance(checkbox, QCheckBox): + pattern_name = checkbox.property("pattern_name") + if pattern_name: + # Update checkbox state from settings + was_enabled = self.pattern_service.is_pattern_enabled(pattern_name) + is_enabled = checkbox.isChecked() + if was_enabled != is_enabled: + self.pattern_service.set_pattern_enabled(pattern_name, is_enabled) + def funscript_reset_defaults(self): self.tableView.model().reset_to_defaults() @@ -295,8 +304,7 @@ def setup_patterns_tab(self): checkbox.setChecked(pattern_enabled) checkbox.setProperty("pattern_name", pattern['name']) checkbox.setProperty("class_name", pattern['class_name']) - checkbox.toggled.connect(self.on_pattern_checkbox_changed) - + # Add checkbox to table checkbox_item = QTableWidgetItem() checkbox_item.setFlags(Qt.ItemIsEnabled) @@ -304,6 +312,8 @@ def setup_patterns_tab(self): self.patterns_table.setCellWidget(row, 1, checkbox) # Simple, single layout update + self.patterns_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) + self.patterns_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) self.patterns_table.resizeRowsToContents() # Connect buttons @@ -330,24 +340,11 @@ def enable_all_patterns(self): widget = self.patterns_table.cellWidget(row, 1) if isinstance(widget, QCheckBox): widget.setChecked(True) - # Emit signal to notify that patterns have changed - self.patterns_changed.emit() - + def disable_all_patterns(self): """Disable all patterns with checkboxes""" for row in range(self.patterns_table.rowCount()): widget = self.patterns_table.cellWidget(row, 1) if isinstance(widget, QCheckBox): widget.setChecked(False) - # Emit signal to notify that patterns have changed - self.patterns_changed.emit() - - def on_pattern_checkbox_changed(self, checked: bool): - """Handle when a pattern checkbox is toggled""" - sender = self.sender() - if isinstance(sender, QCheckBox): - pattern_name = sender.property("pattern_name") - if pattern_name: - self.pattern_service.set_pattern_enabled(pattern_name, checked) - # Emit signal to notify that patterns have changed - self.patterns_changed.emit() + diff --git a/qt_ui/preferences_dialog_ui.py b/qt_ui/preferences_dialog_ui.py index 8b63676..b40e329 100644 --- a/qt_ui/preferences_dialog_ui.py +++ b/qt_ui/preferences_dialog_ui.py @@ -15,12 +15,13 @@ QFont, QFontDatabase, QGradient, QIcon, QImage, QKeySequence, QLinearGradient, QPainter, QPalette, QPixmap, QRadialGradient, QTransform) -from PySide6.QtWidgets import (QAbstractButton, QApplication, QCheckBox, QComboBox, - QDialogButtonBox, QDoubleSpinBox, QFormLayout, QFrame, - QGridLayout, QGroupBox, QHBoxLayout, QHeaderView, - QLabel, QLineEdit, QPushButton, QSizePolicy, - QSpacerItem, QSpinBox, QTabWidget, QTableWidget, QToolButton, - QVBoxLayout, QWidget, QAbstractItemView) +from PySide6.QtWidgets import (QAbstractButton, QAbstractItemView, QApplication, QCheckBox, + QComboBox, QDialogButtonBox, QDoubleSpinBox, QFormLayout, + QFrame, QGridLayout, QGroupBox, QHBoxLayout, + QHeaderView, QLabel, QLineEdit, QPushButton, + QSizePolicy, QSpacerItem, QSpinBox, QTabWidget, + QTableWidget, QTableWidgetItem, QToolButton, QVBoxLayout, + QWidget) from qt_ui.widgets.table_view_with_combobox import TableViewWithComboBox import restim_rc @@ -556,54 +557,51 @@ def setupUi(self, PreferencesDialog): self.tabWidget.addTab(self.tab_funscript, "") self.tab_patterns = QWidget() self.tab_patterns.setObjectName(u"tab_patterns") - self.verticalLayout_8 = QVBoxLayout(self.tab_patterns) - self.verticalLayout_8.setObjectName(u"verticalLayout_8") - - # Threephase Patterns title - self.patterns_title = QLabel(self.tab_patterns) - self.patterns_title.setObjectName(u"patterns_title") - self.patterns_title.setText("Threephase Patterns") - font = QFont() - font.setPointSize(12) - font.setBold(True) - self.patterns_title.setFont(font) - self.patterns_title.setAlignment(Qt.AlignmentFlag.AlignCenter) - self.patterns_title.setStyleSheet("QLabel { margin: 10px 0px; }") - self.verticalLayout_8.addWidget(self.patterns_title) - - # Enable/Disable All buttons - self.patterns_button_frame = QFrame(self.tab_patterns) - self.patterns_button_frame.setObjectName(u"patterns_button_frame") - self.patterns_button_frame.setFrameShape(QFrame.Shape.StyledPanel) - self.patterns_button_frame.setFrameShadow(QFrame.Shadow.Raised) - self.patterns_button_layout = QHBoxLayout(self.patterns_button_frame) - self.patterns_button_layout.setObjectName(u"patterns_button_layout") - - self.button_patterns_enable_all = QPushButton(self.patterns_button_frame) + self.verticalLayout_9 = QVBoxLayout(self.tab_patterns) + self.verticalLayout_9.setObjectName(u"verticalLayout_9") + self.label_19 = QLabel(self.tab_patterns) + self.label_19.setObjectName(u"label_19") + self.label_19.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.label_19.setMargin(10) + + self.verticalLayout_9.addWidget(self.label_19) + + self.frame_9 = QFrame(self.tab_patterns) + self.frame_9.setObjectName(u"frame_9") + self.frame_9.setFrameShape(QFrame.Shape.StyledPanel) + self.frame_9.setFrameShadow(QFrame.Shadow.Raised) + self.horizontalLayout_3 = QHBoxLayout(self.frame_9) + self.horizontalLayout_3.setObjectName(u"horizontalLayout_3") + self.button_patterns_enable_all = QPushButton(self.frame_9) self.button_patterns_enable_all.setObjectName(u"button_patterns_enable_all") - self.patterns_button_layout.addWidget(self.button_patterns_enable_all) - - self.button_patterns_disable_all = QPushButton(self.patterns_button_frame) + + self.horizontalLayout_3.addWidget(self.button_patterns_enable_all) + + self.button_patterns_disable_all = QPushButton(self.frame_9) self.button_patterns_disable_all.setObjectName(u"button_patterns_disable_all") - self.patterns_button_layout.addWidget(self.button_patterns_disable_all) - - self.patterns_button_spacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) - self.patterns_button_layout.addItem(self.patterns_button_spacer) - - self.verticalLayout_8.addWidget(self.patterns_button_frame) - - # Patterns table - will be populated programmatically + + self.horizontalLayout_3.addWidget(self.button_patterns_disable_all) + + self.horizontalSpacer = QSpacerItem(40, 20, QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + + self.horizontalLayout_3.addItem(self.horizontalSpacer) + + + self.verticalLayout_9.addWidget(self.frame_9) + self.patterns_table = QTableWidget(self.tab_patterns) + if (self.patterns_table.columnCount() < 2): + self.patterns_table.setColumnCount(2) + __qtablewidgetitem = QTableWidgetItem() + self.patterns_table.setHorizontalHeaderItem(0, __qtablewidgetitem) + __qtablewidgetitem1 = QTableWidgetItem() + self.patterns_table.setHorizontalHeaderItem(1, __qtablewidgetitem1) self.patterns_table.setObjectName(u"patterns_table") - self.patterns_table.setColumnCount(2) - self.patterns_table.setHorizontalHeaderLabels([u"Pattern", u"Enabled"]) - self.patterns_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch) - self.patterns_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.ResizeToContents) self.patterns_table.setAlternatingRowColors(True) self.patterns_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.patterns_table.verticalHeader().setVisible(False) - - self.verticalLayout_8.addWidget(self.patterns_table) + + self.verticalLayout_9.addWidget(self.patterns_table) self.tabWidget.addTab(self.tab_patterns, "") @@ -714,9 +712,14 @@ def retranslateUi(self, PreferencesDialog): self.display_latency_ms.setSuffix("") self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_display), QCoreApplication.translate("PreferencesDialog", u"Display", None)) self.button_funscript_reset_defaults.setText(QCoreApplication.translate("PreferencesDialog", u"Reset all to defaults", None)) + self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_funscript), QCoreApplication.translate("PreferencesDialog", u"Funscript / T-Code", None)) + self.label_19.setText(QCoreApplication.translate("PreferencesDialog", u"

Threephase patterns

", None)) self.button_patterns_enable_all.setText(QCoreApplication.translate("PreferencesDialog", u"Enable All", None)) self.button_patterns_disable_all.setText(QCoreApplication.translate("PreferencesDialog", u"Disable All", None)) + ___qtablewidgetitem = self.patterns_table.horizontalHeaderItem(0) + ___qtablewidgetitem.setText(QCoreApplication.translate("PreferencesDialog", u"Pattern", None)); + ___qtablewidgetitem1 = self.patterns_table.horizontalHeaderItem(1) + ___qtablewidgetitem1.setText(QCoreApplication.translate("PreferencesDialog", u"Enabled", None)); self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_patterns), QCoreApplication.translate("PreferencesDialog", u"Patterns", None)) - self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_funscript), QCoreApplication.translate("PreferencesDialog", u"Funscript / T-Code", None)) # retranslateUi From f216fcc6bed2d0bd2b543a37d10a2f2283b0ca6c Mon Sep 17 00:00:00 2001 From: diglet48 Date: Wed, 17 Sep 2025 11:59:08 +0200 Subject: [PATCH 10/47] Set volume to 0 on FOC, NeoDK when media is not playing This already was the case for audio-based stim. --- device/focstim/fourphase_algorithm.py | 3 +++ device/focstim/threephase_algorithm.py | 3 +++ device/neostim/algorithm.py | 3 +++ 3 files changed, 9 insertions(+) diff --git a/device/focstim/fourphase_algorithm.py b/device/focstim/fourphase_algorithm.py index c2c9c2a..9a23e4a 100644 --- a/device/focstim/fourphase_algorithm.py +++ b/device/focstim/fourphase_algorithm.py @@ -57,6 +57,9 @@ def remap(value, min_value, max_value): alpha, beta, gamma = self.position_params.get_position(t) + if not self.media.is_playing(): + volume *= 0 + return { AxisType.AXIS_POSITION_ALPHA: alpha, AxisType.AXIS_POSITION_BETA: beta, diff --git a/device/focstim/threephase_algorithm.py b/device/focstim/threephase_algorithm.py index 58ffdd9..383f97e 100644 --- a/device/focstim/threephase_algorithm.py +++ b/device/focstim/threephase_algorithm.py @@ -56,6 +56,9 @@ def remap(value, min_value, max_value): alpha, beta = self.position_params.get_position(t) + if not self.media.is_playing(): + volume *= 0 + return { AxisType.AXIS_POSITION_ALPHA: alpha, AxisType.AXIS_POSITION_BETA: beta, diff --git a/device/neostim/algorithm.py b/device/neostim/algorithm.py index a3dc0d0..ee7d062 100644 --- a/device/neostim/algorithm.py +++ b/device/neostim/algorithm.py @@ -70,6 +70,9 @@ def update_params(self): np.clip(self.params.volume.inactivity.last_value(), 0, 1) * \ np.clip(self.params.volume.external.last_value(), 0, 1) + if not self.media.is_playing(): + volume *= 0 + alpha, beta = self.position_params.get_position(t) calibration_neutral = self.params.calibrate.neutral.last_value() calibration_right = self.params.calibrate.right.last_value() From 895400d66ae0be8ecf363d3abbbcf993d4867e88 Mon Sep 17 00:00:00 2001 From: diglet48 Date: Sun, 28 Sep 2025 20:02:31 +0200 Subject: [PATCH 11/47] Implement wifi configuration for V4 --- designer/preferencesdialog.ui | 191 +++++++++++++++++++----- device/focstim/focstim_rpc_pb2.py | 20 +-- device/focstim/focstim_rpc_pb2.pyi | 22 ++- device/focstim/helpers.py | 82 ++++++++++ device/focstim/messages_pb2.py | 22 ++- device/focstim/messages_pb2.pyi | 30 ++++ device/focstim/notifications_pb2.py | 6 +- device/focstim/notifications_pb2.pyi | 12 ++ device/focstim/proto_api.py | 17 ++- qt_ui/mainwindow.py | 10 +- qt_ui/preferences_dialog.py | 76 +++++++++- qt_ui/preferences_dialog_ui.py | 215 +++++++++++++++++++++------ qt_ui/settings.py | 5 + 13 files changed, 595 insertions(+), 113 deletions(-) create mode 100644 device/focstim/helpers.py diff --git a/designer/preferencesdialog.ui b/designer/preferencesdialog.ui index 4b74103..64b6d88 100644 --- a/designer/preferencesdialog.ui +++ b/designer/preferencesdialog.ui @@ -386,42 +386,38 @@ - + - FOC-Stim + Communication - - - - - - - - - - - - - - - Useful if you have multiple FOC-Stim boxes - + + + - teleplot prefix (?) + Serial - - + + - Refresh + Wifi - - + + + + + + + Serial + + + + - Dump notifications to file + Serial port @@ -435,21 +431,110 @@ + + + + Refresh + + + + + + + + + + Network + + + + + + + + + Upload ssid/password + + + + + + Password + + + + + + + + + + SSID + + + + + + + + + + IP + + + + + + + Read from device + + + + + + + + + + Advanced + + + Use teleplot - - + + - Serial port + - + + + + Useful if you have multiple FOC-Stim boxes + + + teleplot prefix (?) + + + + + + + + + + Dump notifications to file + + + + @@ -898,16 +983,54 @@ - udp_localhost_only - display_fps - display_latency_ms + focstim_radio_serial + focstim_radio_wifi + focstim_port + focstim_refresh_serial_devices + focstim_ssid + focstim_password + focstim_sync + focstim_use_teleplot + focstim_teleplot_prefix + focstim_dump_notifications + tcp_port + udp_port + gb_serial + serial_auto_expand + serial_port + gb_buttplug_wsdm + buttplug_wsdm_address + buttplug_wsdm_auto_expand + audio_latency + tabWidget + gb_udp_server audio_api + display_latency_ms + websocket_port + neostim_refresh_serial_devices + neostim_port + mpc_address + mpc_reload + heresphere_address + heresphere_reload + vlc_reload + vlc_address + vlc_username + vlc_password + kodi_address + kodi_reload + tableView + button_funscript_reset_defaults + button_patterns_enable_all + button_patterns_disable_all + patterns_table audio_output_device gb_websocket_server - websocket_localhost_only gb_tcp_server + udp_localhost_only + display_fps tcp_localhost_only - gb_udp_server + websocket_localhost_only diff --git a/device/focstim/focstim_rpc_pb2.py b/device/focstim/focstim_rpc_pb2.py index 57fbadd..7c075f0 100644 --- a/device/focstim/focstim_rpc_pb2.py +++ b/device/focstim/focstim_rpc_pb2.py @@ -9,19 +9,19 @@ from . import notifications_pb2 as notifications__pb2 from . import messages_pb2 as messages__pb2 from . import constants_pb2 as constants__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x11focstim_rpc.proto\x12\x0bfocstim_rpc\x1a\x13notifications.proto\x1a\x0emessages.proto\x1a\x0fconstants.proto"\xc3\x05\n\x0cNotification\x12:\n\x11notification_boot\x18\x01 \x01(\x0b2\x1d.focstim_rpc.NotificationBootH\x00\x12L\n\x1anotification_potentiometer\x18\x02 \x01(\x0b2&.focstim_rpc.NotificationPotentiometerH\x00\x12B\n\x15notification_currents\x18\x03 \x01(\x0b2!.focstim_rpc.NotificationCurrentsH\x00\x12Q\n\x1dnotification_model_estimation\x18\x04 \x01(\x0b2(.focstim_rpc.NotificationModelEstimationH\x00\x12I\n\x19notification_system_stats\x18\x05 \x01(\x0b2$.focstim_rpc.NotificationSystemStatsH\x00\x12I\n\x19notification_signal_stats\x18\x06 \x01(\x0b2$.focstim_rpc.NotificationSignalStatsH\x00\x12@\n\x14notification_battery\x18\x07 \x01(\x0b2 .focstim_rpc.NotificationBatteryH\x00\x12J\n\x19notification_debug_string\x18\xe8\x07 \x01(\x0b2$.focstim_rpc.NotificationDebugStringH\x00\x12J\n\x19notification_debug_as5311\x18\xe9\x07 \x01(\x0b2$.focstim_rpc.NotificationDebugAS5311H\x00\x12\x12\n\ttimestamp\x18\xe7\x07 \x01(\x04B\x0e\n\x0cnotification"\xa6\x05\n\x07Request\x12\n\n\x02id\x18\x01 \x01(\r\x12H\n\x18request_firmware_version\x18\xf4\x03 \x01(\x0b2#.focstim_rpc.RequestFirmwareVersionH\x00\x12H\n\x18request_capabilities_get\x18\xf5\x03 \x01(\x0b2#.focstim_rpc.RequestCapabilitiesGetH\x00\x12@\n\x14request_signal_start\x18\xf6\x03 \x01(\x0b2\x1f.focstim_rpc.RequestSignalStartH\x00\x12>\n\x13request_signal_stop\x18\xf7\x03 \x01(\x0b2\x1e.focstim_rpc.RequestSignalStopH\x00\x12>\n\x14request_axis_move_to\x18\x05 \x01(\x0b2\x1e.focstim_rpc.RequestAxisMoveToH\x00\x12B\n\x15request_timestamp_set\x18\xf8\x03 \x01(\x0b2 .focstim_rpc.RequestTimestampSetH\x00\x12B\n\x15request_timestamp_get\x18\xf9\x03 \x01(\x0b2 .focstim_rpc.RequestTimestampGetH\x00\x12R\n\x1erequest_debug_stm32_deep_sleep\x18\xe8\x07 \x01(\x0b2\'.focstim_rpc.RequestDebugStm32DeepSleepH\x00\x12S\n\x1erequest_debug_enter_bootloader\x18\xe9\x07 \x01(\x0b2(.focstim_rpc.RequestDebugEnterBootloaderH\x00B\x08\n\x06params"\x85\x05\n\x08Response\x12\n\n\x02id\x18\x01 \x01(\r\x12J\n\x19response_firmware_version\x18\xf4\x03 \x01(\x0b2$.focstim_rpc.ResponseFirmwareVersionH\x00\x12J\n\x19response_capabilities_get\x18\xf5\x03 \x01(\x0b2$.focstim_rpc.ResponseCapabilitiesGetH\x00\x12B\n\x15response_signal_start\x18\xf6\x03 \x01(\x0b2 .focstim_rpc.ResponseSignalStartH\x00\x12@\n\x14response_signal_stop\x18\xf7\x03 \x01(\x0b2\x1f.focstim_rpc.ResponseSignalStopH\x00\x12@\n\x15response_axis_move_to\x18\x05 \x01(\x0b2\x1f.focstim_rpc.ResponseAxisMoveToH\x00\x12D\n\x16response_timestamp_set\x18\xf8\x03 \x01(\x0b2!.focstim_rpc.ResponseTimestampSetH\x00\x12D\n\x16response_timestamp_get\x18\xf9\x03 \x01(\x0b2!.focstim_rpc.ResponseTimestampGetH\x00\x12T\n\x1fresponse_debug_stm32_deep_sleep\x18\xe8\x07 \x01(\x0b2(.focstim_rpc.ResponseDebugStm32DeepSleepH\x00\x12!\n\x05error\x18\x03 \x01(\x0b2\x12.focstim_rpc.ErrorB\x08\n\x06result"*\n\x05Error\x12!\n\x04code\x18\x01 \x01(\x0e2\x13.focstim_rpc.Errors"\x9e\x01\n\nRpcMessage\x12\'\n\x07request\x18\x02 \x01(\x0b2\x14.focstim_rpc.RequestH\x00\x12)\n\x08response\x18\x04 \x01(\x0b2\x15.focstim_rpc.ResponseH\x00\x121\n\x0cnotification\x18\x05 \x01(\x0b2\x19.focstim_rpc.NotificationH\x00B\t\n\x07messageb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x11focstim_rpc.proto\x12\x0bfocstim_rpc\x1a\x13notifications.proto\x1a\x0emessages.proto\x1a\x0fconstants.proto"\x8f\x06\n\x0cNotification\x12:\n\x11notification_boot\x18\x01 \x01(\x0b2\x1d.focstim_rpc.NotificationBootH\x00\x12L\n\x1anotification_potentiometer\x18\x02 \x01(\x0b2&.focstim_rpc.NotificationPotentiometerH\x00\x12B\n\x15notification_currents\x18\x03 \x01(\x0b2!.focstim_rpc.NotificationCurrentsH\x00\x12Q\n\x1dnotification_model_estimation\x18\x04 \x01(\x0b2(.focstim_rpc.NotificationModelEstimationH\x00\x12I\n\x19notification_system_stats\x18\x05 \x01(\x0b2$.focstim_rpc.NotificationSystemStatsH\x00\x12I\n\x19notification_signal_stats\x18\x06 \x01(\x0b2$.focstim_rpc.NotificationSignalStatsH\x00\x12@\n\x14notification_battery\x18\x07 \x01(\x0b2 .focstim_rpc.NotificationBatteryH\x00\x12J\n\x19notification_debug_string\x18\xe8\x07 \x01(\x0b2$.focstim_rpc.NotificationDebugStringH\x00\x12J\n\x19notification_debug_as5311\x18\xe9\x07 \x01(\x0b2$.focstim_rpc.NotificationDebugAS5311H\x00\x12J\n\x19notification_debug_edging\x18\xea\x07 \x01(\x0b2$.focstim_rpc.NotificationDebugEdgingH\x00\x12\x12\n\ttimestamp\x18\xe7\x07 \x01(\x04B\x0e\n\x0cnotification"\xb4\x06\n\x07Request\x12\n\n\x02id\x18\x01 \x01(\r\x12H\n\x18request_firmware_version\x18\xf4\x03 \x01(\x0b2#.focstim_rpc.RequestFirmwareVersionH\x00\x12H\n\x18request_capabilities_get\x18\xf5\x03 \x01(\x0b2#.focstim_rpc.RequestCapabilitiesGetH\x00\x12@\n\x14request_signal_start\x18\xf6\x03 \x01(\x0b2\x1f.focstim_rpc.RequestSignalStartH\x00\x12>\n\x13request_signal_stop\x18\xf7\x03 \x01(\x0b2\x1e.focstim_rpc.RequestSignalStopH\x00\x12>\n\x14request_axis_move_to\x18\x05 \x01(\x0b2\x1e.focstim_rpc.RequestAxisMoveToH\x00\x12B\n\x15request_timestamp_set\x18\xf8\x03 \x01(\x0b2 .focstim_rpc.RequestTimestampSetH\x00\x12B\n\x15request_timestamp_get\x18\xf9\x03 \x01(\x0b2 .focstim_rpc.RequestTimestampGetH\x00\x12M\n\x1brequest_wifi_parameters_set\x18\xfb\x03 \x01(\x0b2%.focstim_rpc.RequestWifiParametersSetH\x00\x12=\n\x13request_wifi_ip_get\x18\xfc\x03 \x01(\x0b2\x1d.focstim_rpc.RequestWifiIPGetH\x00\x12R\n\x1erequest_debug_stm32_deep_sleep\x18\xe8\x07 \x01(\x0b2\'.focstim_rpc.RequestDebugStm32DeepSleepH\x00\x12S\n\x1erequest_debug_enter_bootloader\x18\xe9\x07 \x01(\x0b2(.focstim_rpc.RequestDebugEnterBootloaderH\x00B\x08\n\x06params"\x97\x06\n\x08Response\x12\n\n\x02id\x18\x01 \x01(\r\x12J\n\x19response_firmware_version\x18\xf4\x03 \x01(\x0b2$.focstim_rpc.ResponseFirmwareVersionH\x00\x12J\n\x19response_capabilities_get\x18\xf5\x03 \x01(\x0b2$.focstim_rpc.ResponseCapabilitiesGetH\x00\x12B\n\x15response_signal_start\x18\xf6\x03 \x01(\x0b2 .focstim_rpc.ResponseSignalStartH\x00\x12@\n\x14response_signal_stop\x18\xf7\x03 \x01(\x0b2\x1f.focstim_rpc.ResponseSignalStopH\x00\x12@\n\x15response_axis_move_to\x18\x05 \x01(\x0b2\x1f.focstim_rpc.ResponseAxisMoveToH\x00\x12D\n\x16response_timestamp_set\x18\xf8\x03 \x01(\x0b2!.focstim_rpc.ResponseTimestampSetH\x00\x12D\n\x16response_timestamp_get\x18\xf9\x03 \x01(\x0b2!.focstim_rpc.ResponseTimestampGetH\x00\x12O\n\x1cresponse_wifi_parameters_set\x18\xfb\x03 \x01(\x0b2&.focstim_rpc.ResponseWifiParametersSetH\x00\x12?\n\x14response_wifi_ip_get\x18\xfc\x03 \x01(\x0b2\x1e.focstim_rpc.ResponseWifiIPGetH\x00\x12T\n\x1fresponse_debug_stm32_deep_sleep\x18\xe8\x07 \x01(\x0b2(.focstim_rpc.ResponseDebugStm32DeepSleepH\x00\x12!\n\x05error\x18\x03 \x01(\x0b2\x12.focstim_rpc.ErrorB\x08\n\x06result"*\n\x05Error\x12!\n\x04code\x18\x01 \x01(\x0e2\x13.focstim_rpc.Errors"\x9e\x01\n\nRpcMessage\x12\'\n\x07request\x18\x02 \x01(\x0b2\x14.focstim_rpc.RequestH\x00\x12)\n\x08response\x18\x04 \x01(\x0b2\x15.focstim_rpc.ResponseH\x00\x121\n\x0cnotification\x18\x05 \x01(\x0b2\x19.focstim_rpc.NotificationH\x00B\t\n\x07messageb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'focstim_rpc_pb2', _globals) if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None _globals['_NOTIFICATION']._serialized_start = 89 - _globals['_NOTIFICATION']._serialized_end = 796 - _globals['_REQUEST']._serialized_start = 799 - _globals['_REQUEST']._serialized_end = 1477 - _globals['_RESPONSE']._serialized_start = 1480 - _globals['_RESPONSE']._serialized_end = 2125 - _globals['_ERROR']._serialized_start = 2127 - _globals['_ERROR']._serialized_end = 2169 - _globals['_RPCMESSAGE']._serialized_start = 2172 - _globals['_RPCMESSAGE']._serialized_end = 2330 \ No newline at end of file + _globals['_NOTIFICATION']._serialized_end = 872 + _globals['_REQUEST']._serialized_start = 875 + _globals['_REQUEST']._serialized_end = 1695 + _globals['_RESPONSE']._serialized_start = 1698 + _globals['_RESPONSE']._serialized_end = 2489 + _globals['_ERROR']._serialized_start = 2491 + _globals['_ERROR']._serialized_end = 2533 + _globals['_RPCMESSAGE']._serialized_start = 2536 + _globals['_RPCMESSAGE']._serialized_end = 2694 \ No newline at end of file diff --git a/device/focstim/focstim_rpc_pb2.pyi b/device/focstim/focstim_rpc_pb2.pyi index 87154f8..5b35d1d 100644 --- a/device/focstim/focstim_rpc_pb2.pyi +++ b/device/focstim/focstim_rpc_pb2.pyi @@ -8,7 +8,7 @@ from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union DESCRIPTOR: _descriptor.FileDescriptor class Notification(_message.Message): - __slots__ = ('notification_boot', 'notification_potentiometer', 'notification_currents', 'notification_model_estimation', 'notification_system_stats', 'notification_signal_stats', 'notification_battery', 'notification_debug_string', 'notification_debug_as5311', 'timestamp') + __slots__ = ('notification_boot', 'notification_potentiometer', 'notification_currents', 'notification_model_estimation', 'notification_system_stats', 'notification_signal_stats', 'notification_battery', 'notification_debug_string', 'notification_debug_as5311', 'notification_debug_edging', 'timestamp') NOTIFICATION_BOOT_FIELD_NUMBER: _ClassVar[int] NOTIFICATION_POTENTIOMETER_FIELD_NUMBER: _ClassVar[int] NOTIFICATION_CURRENTS_FIELD_NUMBER: _ClassVar[int] @@ -18,6 +18,7 @@ class Notification(_message.Message): NOTIFICATION_BATTERY_FIELD_NUMBER: _ClassVar[int] NOTIFICATION_DEBUG_STRING_FIELD_NUMBER: _ClassVar[int] NOTIFICATION_DEBUG_AS5311_FIELD_NUMBER: _ClassVar[int] + NOTIFICATION_DEBUG_EDGING_FIELD_NUMBER: _ClassVar[int] TIMESTAMP_FIELD_NUMBER: _ClassVar[int] notification_boot: _notifications_pb2.NotificationBoot notification_potentiometer: _notifications_pb2.NotificationPotentiometer @@ -28,13 +29,14 @@ class Notification(_message.Message): notification_battery: _notifications_pb2.NotificationBattery notification_debug_string: _notifications_pb2.NotificationDebugString notification_debug_as5311: _notifications_pb2.NotificationDebugAS5311 + notification_debug_edging: _notifications_pb2.NotificationDebugEdging timestamp: int - def __init__(self, notification_boot: _Optional[_Union[_notifications_pb2.NotificationBoot, _Mapping]]=..., notification_potentiometer: _Optional[_Union[_notifications_pb2.NotificationPotentiometer, _Mapping]]=..., notification_currents: _Optional[_Union[_notifications_pb2.NotificationCurrents, _Mapping]]=..., notification_model_estimation: _Optional[_Union[_notifications_pb2.NotificationModelEstimation, _Mapping]]=..., notification_system_stats: _Optional[_Union[_notifications_pb2.NotificationSystemStats, _Mapping]]=..., notification_signal_stats: _Optional[_Union[_notifications_pb2.NotificationSignalStats, _Mapping]]=..., notification_battery: _Optional[_Union[_notifications_pb2.NotificationBattery, _Mapping]]=..., notification_debug_string: _Optional[_Union[_notifications_pb2.NotificationDebugString, _Mapping]]=..., notification_debug_as5311: _Optional[_Union[_notifications_pb2.NotificationDebugAS5311, _Mapping]]=..., timestamp: _Optional[int]=...) -> None: + def __init__(self, notification_boot: _Optional[_Union[_notifications_pb2.NotificationBoot, _Mapping]]=..., notification_potentiometer: _Optional[_Union[_notifications_pb2.NotificationPotentiometer, _Mapping]]=..., notification_currents: _Optional[_Union[_notifications_pb2.NotificationCurrents, _Mapping]]=..., notification_model_estimation: _Optional[_Union[_notifications_pb2.NotificationModelEstimation, _Mapping]]=..., notification_system_stats: _Optional[_Union[_notifications_pb2.NotificationSystemStats, _Mapping]]=..., notification_signal_stats: _Optional[_Union[_notifications_pb2.NotificationSignalStats, _Mapping]]=..., notification_battery: _Optional[_Union[_notifications_pb2.NotificationBattery, _Mapping]]=..., notification_debug_string: _Optional[_Union[_notifications_pb2.NotificationDebugString, _Mapping]]=..., notification_debug_as5311: _Optional[_Union[_notifications_pb2.NotificationDebugAS5311, _Mapping]]=..., notification_debug_edging: _Optional[_Union[_notifications_pb2.NotificationDebugEdging, _Mapping]]=..., timestamp: _Optional[int]=...) -> None: ... class Request(_message.Message): - __slots__ = ('id', 'request_firmware_version', 'request_capabilities_get', 'request_signal_start', 'request_signal_stop', 'request_axis_move_to', 'request_timestamp_set', 'request_timestamp_get', 'request_debug_stm32_deep_sleep', 'request_debug_enter_bootloader') + __slots__ = ('id', 'request_firmware_version', 'request_capabilities_get', 'request_signal_start', 'request_signal_stop', 'request_axis_move_to', 'request_timestamp_set', 'request_timestamp_get', 'request_wifi_parameters_set', 'request_wifi_ip_get', 'request_debug_stm32_deep_sleep', 'request_debug_enter_bootloader') ID_FIELD_NUMBER: _ClassVar[int] REQUEST_FIRMWARE_VERSION_FIELD_NUMBER: _ClassVar[int] REQUEST_CAPABILITIES_GET_FIELD_NUMBER: _ClassVar[int] @@ -43,6 +45,8 @@ class Request(_message.Message): REQUEST_AXIS_MOVE_TO_FIELD_NUMBER: _ClassVar[int] REQUEST_TIMESTAMP_SET_FIELD_NUMBER: _ClassVar[int] REQUEST_TIMESTAMP_GET_FIELD_NUMBER: _ClassVar[int] + REQUEST_WIFI_PARAMETERS_SET_FIELD_NUMBER: _ClassVar[int] + REQUEST_WIFI_IP_GET_FIELD_NUMBER: _ClassVar[int] REQUEST_DEBUG_STM32_DEEP_SLEEP_FIELD_NUMBER: _ClassVar[int] REQUEST_DEBUG_ENTER_BOOTLOADER_FIELD_NUMBER: _ClassVar[int] id: int @@ -53,14 +57,16 @@ class Request(_message.Message): request_axis_move_to: _messages_pb2.RequestAxisMoveTo request_timestamp_set: _messages_pb2.RequestTimestampSet request_timestamp_get: _messages_pb2.RequestTimestampGet + request_wifi_parameters_set: _messages_pb2.RequestWifiParametersSet + request_wifi_ip_get: _messages_pb2.RequestWifiIPGet request_debug_stm32_deep_sleep: _messages_pb2.RequestDebugStm32DeepSleep request_debug_enter_bootloader: _messages_pb2.RequestDebugEnterBootloader - def __init__(self, id: _Optional[int]=..., request_firmware_version: _Optional[_Union[_messages_pb2.RequestFirmwareVersion, _Mapping]]=..., request_capabilities_get: _Optional[_Union[_messages_pb2.RequestCapabilitiesGet, _Mapping]]=..., request_signal_start: _Optional[_Union[_messages_pb2.RequestSignalStart, _Mapping]]=..., request_signal_stop: _Optional[_Union[_messages_pb2.RequestSignalStop, _Mapping]]=..., request_axis_move_to: _Optional[_Union[_messages_pb2.RequestAxisMoveTo, _Mapping]]=..., request_timestamp_set: _Optional[_Union[_messages_pb2.RequestTimestampSet, _Mapping]]=..., request_timestamp_get: _Optional[_Union[_messages_pb2.RequestTimestampGet, _Mapping]]=..., request_debug_stm32_deep_sleep: _Optional[_Union[_messages_pb2.RequestDebugStm32DeepSleep, _Mapping]]=..., request_debug_enter_bootloader: _Optional[_Union[_messages_pb2.RequestDebugEnterBootloader, _Mapping]]=...) -> None: + def __init__(self, id: _Optional[int]=..., request_firmware_version: _Optional[_Union[_messages_pb2.RequestFirmwareVersion, _Mapping]]=..., request_capabilities_get: _Optional[_Union[_messages_pb2.RequestCapabilitiesGet, _Mapping]]=..., request_signal_start: _Optional[_Union[_messages_pb2.RequestSignalStart, _Mapping]]=..., request_signal_stop: _Optional[_Union[_messages_pb2.RequestSignalStop, _Mapping]]=..., request_axis_move_to: _Optional[_Union[_messages_pb2.RequestAxisMoveTo, _Mapping]]=..., request_timestamp_set: _Optional[_Union[_messages_pb2.RequestTimestampSet, _Mapping]]=..., request_timestamp_get: _Optional[_Union[_messages_pb2.RequestTimestampGet, _Mapping]]=..., request_wifi_parameters_set: _Optional[_Union[_messages_pb2.RequestWifiParametersSet, _Mapping]]=..., request_wifi_ip_get: _Optional[_Union[_messages_pb2.RequestWifiIPGet, _Mapping]]=..., request_debug_stm32_deep_sleep: _Optional[_Union[_messages_pb2.RequestDebugStm32DeepSleep, _Mapping]]=..., request_debug_enter_bootloader: _Optional[_Union[_messages_pb2.RequestDebugEnterBootloader, _Mapping]]=...) -> None: ... class Response(_message.Message): - __slots__ = ('id', 'response_firmware_version', 'response_capabilities_get', 'response_signal_start', 'response_signal_stop', 'response_axis_move_to', 'response_timestamp_set', 'response_timestamp_get', 'response_debug_stm32_deep_sleep', 'error') + __slots__ = ('id', 'response_firmware_version', 'response_capabilities_get', 'response_signal_start', 'response_signal_stop', 'response_axis_move_to', 'response_timestamp_set', 'response_timestamp_get', 'response_wifi_parameters_set', 'response_wifi_ip_get', 'response_debug_stm32_deep_sleep', 'error') ID_FIELD_NUMBER: _ClassVar[int] RESPONSE_FIRMWARE_VERSION_FIELD_NUMBER: _ClassVar[int] RESPONSE_CAPABILITIES_GET_FIELD_NUMBER: _ClassVar[int] @@ -69,6 +75,8 @@ class Response(_message.Message): RESPONSE_AXIS_MOVE_TO_FIELD_NUMBER: _ClassVar[int] RESPONSE_TIMESTAMP_SET_FIELD_NUMBER: _ClassVar[int] RESPONSE_TIMESTAMP_GET_FIELD_NUMBER: _ClassVar[int] + RESPONSE_WIFI_PARAMETERS_SET_FIELD_NUMBER: _ClassVar[int] + RESPONSE_WIFI_IP_GET_FIELD_NUMBER: _ClassVar[int] RESPONSE_DEBUG_STM32_DEEP_SLEEP_FIELD_NUMBER: _ClassVar[int] ERROR_FIELD_NUMBER: _ClassVar[int] id: int @@ -79,10 +87,12 @@ class Response(_message.Message): response_axis_move_to: _messages_pb2.ResponseAxisMoveTo response_timestamp_set: _messages_pb2.ResponseTimestampSet response_timestamp_get: _messages_pb2.ResponseTimestampGet + response_wifi_parameters_set: _messages_pb2.ResponseWifiParametersSet + response_wifi_ip_get: _messages_pb2.ResponseWifiIPGet response_debug_stm32_deep_sleep: _messages_pb2.ResponseDebugStm32DeepSleep error: Error - def __init__(self, id: _Optional[int]=..., response_firmware_version: _Optional[_Union[_messages_pb2.ResponseFirmwareVersion, _Mapping]]=..., response_capabilities_get: _Optional[_Union[_messages_pb2.ResponseCapabilitiesGet, _Mapping]]=..., response_signal_start: _Optional[_Union[_messages_pb2.ResponseSignalStart, _Mapping]]=..., response_signal_stop: _Optional[_Union[_messages_pb2.ResponseSignalStop, _Mapping]]=..., response_axis_move_to: _Optional[_Union[_messages_pb2.ResponseAxisMoveTo, _Mapping]]=..., response_timestamp_set: _Optional[_Union[_messages_pb2.ResponseTimestampSet, _Mapping]]=..., response_timestamp_get: _Optional[_Union[_messages_pb2.ResponseTimestampGet, _Mapping]]=..., response_debug_stm32_deep_sleep: _Optional[_Union[_messages_pb2.ResponseDebugStm32DeepSleep, _Mapping]]=..., error: _Optional[_Union[Error, _Mapping]]=...) -> None: + def __init__(self, id: _Optional[int]=..., response_firmware_version: _Optional[_Union[_messages_pb2.ResponseFirmwareVersion, _Mapping]]=..., response_capabilities_get: _Optional[_Union[_messages_pb2.ResponseCapabilitiesGet, _Mapping]]=..., response_signal_start: _Optional[_Union[_messages_pb2.ResponseSignalStart, _Mapping]]=..., response_signal_stop: _Optional[_Union[_messages_pb2.ResponseSignalStop, _Mapping]]=..., response_axis_move_to: _Optional[_Union[_messages_pb2.ResponseAxisMoveTo, _Mapping]]=..., response_timestamp_set: _Optional[_Union[_messages_pb2.ResponseTimestampSet, _Mapping]]=..., response_timestamp_get: _Optional[_Union[_messages_pb2.ResponseTimestampGet, _Mapping]]=..., response_wifi_parameters_set: _Optional[_Union[_messages_pb2.ResponseWifiParametersSet, _Mapping]]=..., response_wifi_ip_get: _Optional[_Union[_messages_pb2.ResponseWifiIPGet, _Mapping]]=..., response_debug_stm32_deep_sleep: _Optional[_Union[_messages_pb2.ResponseDebugStm32DeepSleep, _Mapping]]=..., error: _Optional[_Union[Error, _Mapping]]=...) -> None: ... class Error(_message.Message): diff --git a/device/focstim/helpers.py b/device/focstim/helpers.py new file mode 100644 index 0000000..4f4ad91 --- /dev/null +++ b/device/focstim/helpers.py @@ -0,0 +1,82 @@ +import logging +import time +import datetime +import os + +import google.protobuf.text_format +from PySide6.QtSerialPort import QSerialPort +from PySide6.QtCore import QIODevice, QTimer, QObject +from PySide6.QtNetwork import QAbstractSocket +from PySide6.QtNetwork import QTcpSocket + + +from device.focstim.proto_api import FOCStimProtoAPI +from device.focstim.notifications_pb2 import NotificationBoot, NotificationPotentiometer, NotificationCurrents, \ + NotificationModelEstimation, NotificationSystemStats, NotificationSignalStats, NotificationDebugString, \ + NotificationBattery, NotificationDebugAS5311 +from device.focstim.teleplot import Teleplot +from device.output_device import OutputDevice +from stim_math.audio_gen.base_classes import RemoteGenerationAlgorithm + + +logger = logging.getLogger('restim.focstim') + + +class WifiUploadHelper: + def __init__(self): + pass + + def upload(self, com_port, ssid, password): + logger.info(f"Connecting to FOC-Stim at {com_port}") + self.transport = QSerialPort() + self.transport.setPortName(com_port) + self.transport.setBaudRate(115200) + success = self.transport.open(QIODevice.OpenModeFlag.ReadWrite) + self.transport.setSettingsRestoredOnClose(False) + + if not success: + logger.error(f"connection error: {self.transport.errorString()}") + return None + + self.api = FOCStimProtoAPI(None, self.transport, None) + self.fut = self.api.request_wifi_parameters_set(ssid, password) + self.fut.set_timeout(1000) + + # self.fut.on_timeout.connect(self.timeout) + # self.fut.on_result.connect(self.result) + return self.fut + + def close(self): + self.transport.close() + self.api = None + + +class GrabIpHelper: + def __init__(self): + pass + + def get_ip(self, com_port): + logger.info(f"Connecting to FOC-Stim at {com_port}") + self.transport = QSerialPort() + self.transport.setPortName(com_port) + self.transport.setBaudRate(115200) + success = self.transport.open(QIODevice.OpenModeFlag.ReadWrite) + self.transport.setSettingsRestoredOnClose(False) + + if not success: + logger.error(f"connection error: {self.transport.errorString()}") + return None + + self.api = FOCStimProtoAPI(None, self.transport, None) + self.fut = self.api.request_wifi_ip_get() + self.fut.set_timeout(1000) + + # self.fut.on_timeout.connect(self.timeout) + # self.fut.on_result.connect(self.result) + return self.fut + + def close(self): + self.transport.close() + self.api = None + + diff --git a/device/focstim/messages_pb2.py b/device/focstim/messages_pb2.py index cf82ca7..d7a2c4e 100644 --- a/device/focstim/messages_pb2.py +++ b/device/focstim/messages_pb2.py @@ -7,7 +7,7 @@ _runtime_version.ValidateProtobufRuntimeVersion(_runtime_version.Domain.PUBLIC, 6, 31, 0, '', 'messages.proto') _sym_db = _symbol_database.Default() from . import constants_pb2 as constants__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0emessages.proto\x12\x0bfocstim_rpc\x1a\x0fconstants.proto"\x18\n\x16RequestFirmwareVersion"f\n\x17ResponseFirmwareVersion\x12+\n\x05board\x18\x01 \x01(\x0e2\x1c.focstim_rpc.BoardIdentifier\x12\x1e\n\x16stm32_firmware_version\x18\x02 \x01(\t"\x18\n\x16RequestCapabilitiesGet"\x91\x01\n\x17ResponseCapabilitiesGet\x12\x12\n\nthreephase\x18\x01 \x01(\x08\x12\x11\n\tfourphase\x18\x02 \x01(\x08\x12\x0f\n\x07battery\x18\x03 \x01(\x08\x12\x15\n\rpotentiometer\x18\x04 \x01(\x08\x12\'\n\x1fmaximum_waveform_amplitude_amps\x18\x05 \x01(\x02";\n\x12RequestSignalStart\x12%\n\x04mode\x18\x01 \x01(\x0e2\x17.focstim_rpc.OutputMode"\x15\n\x13ResponseSignalStart"\x13\n\x11RequestSignalStop"\x14\n\x12ResponseSignalStop"\x10\n\x0eRequestModeSet"\x11\n\x0fResponseModeSet"Y\n\x11RequestAxisMoveTo\x12#\n\x04axis\x18\x01 \x01(\x0e2\x15.focstim_rpc.AxisType\x12\r\n\x05value\x18\x03 \x01(\x02\x12\x10\n\x08interval\x18\x04 \x01(\r"\x14\n\x12ResponseAxisMoveTo"i\n\x0eRequestAxisSet\x12#\n\x04axis\x18\x01 \x01(\x0e2\x15.focstim_rpc.AxisType\x12\x14\n\x0ctimestamp_ms\x18\x02 \x01(\x07\x12\r\n\x05value\x18\x03 \x01(\x02\x12\r\n\x05clear\x18\x04 \x01(\x08"\x11\n\x0fResponseAxisSet"+\n\x13RequestTimestampSet\x12\x14\n\x0ctimestamp_ms\x18\x01 \x01(\x04"N\n\x14ResponseTimestampSet\x12\x11\n\toffset_ms\x18\x01 \x01(\x03\x12\x11\n\tchange_ms\x18\x02 \x01(\x12\x12\x10\n\x08error_ms\x18\x03 \x01(\x12"\x15\n\x13RequestTimestampGet"G\n\x14ResponseTimestampGet\x12\x14\n\x0ctimestamp_ms\x18\x01 \x01(\x07\x12\x19\n\x11unix_timestamp_ms\x18\x02 \x01(\x04"\x1c\n\x1aRequestDebugStm32DeepSleep"\x1d\n\x1bResponseDebugStm32DeepSleep"\x1d\n\x1bRequestDebugEnterBootloaderb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0emessages.proto\x12\x0bfocstim_rpc\x1a\x0fconstants.proto"\x18\n\x16RequestFirmwareVersion"f\n\x17ResponseFirmwareVersion\x12+\n\x05board\x18\x01 \x01(\x0e2\x1c.focstim_rpc.BoardIdentifier\x12\x1e\n\x16stm32_firmware_version\x18\x02 \x01(\t"\x18\n\x16RequestCapabilitiesGet"\x91\x01\n\x17ResponseCapabilitiesGet\x12\x12\n\nthreephase\x18\x01 \x01(\x08\x12\x11\n\tfourphase\x18\x02 \x01(\x08\x12\x0f\n\x07battery\x18\x03 \x01(\x08\x12\x15\n\rpotentiometer\x18\x04 \x01(\x08\x12\'\n\x1fmaximum_waveform_amplitude_amps\x18\x05 \x01(\x02";\n\x12RequestSignalStart\x12%\n\x04mode\x18\x01 \x01(\x0e2\x17.focstim_rpc.OutputMode"\x15\n\x13ResponseSignalStart"\x13\n\x11RequestSignalStop"\x14\n\x12ResponseSignalStop"\x10\n\x0eRequestModeSet"\x11\n\x0fResponseModeSet"Y\n\x11RequestAxisMoveTo\x12#\n\x04axis\x18\x01 \x01(\x0e2\x15.focstim_rpc.AxisType\x12\r\n\x05value\x18\x03 \x01(\x02\x12\x10\n\x08interval\x18\x04 \x01(\r"\x14\n\x12ResponseAxisMoveTo"i\n\x0eRequestAxisSet\x12#\n\x04axis\x18\x01 \x01(\x0e2\x15.focstim_rpc.AxisType\x12\x14\n\x0ctimestamp_ms\x18\x02 \x01(\x07\x12\r\n\x05value\x18\x03 \x01(\x02\x12\r\n\x05clear\x18\x04 \x01(\x08"\x11\n\x0fResponseAxisSet"+\n\x13RequestTimestampSet\x12\x14\n\x0ctimestamp_ms\x18\x01 \x01(\x04"N\n\x14ResponseTimestampSet\x12\x11\n\toffset_ms\x18\x01 \x01(\x03\x12\x11\n\tchange_ms\x18\x02 \x01(\x12\x12\x10\n\x08error_ms\x18\x03 \x01(\x12"\x15\n\x13RequestTimestampGet"G\n\x14ResponseTimestampGet\x12\x14\n\x0ctimestamp_ms\x18\x01 \x01(\x07\x12\x19\n\x11unix_timestamp_ms\x18\x02 \x01(\x04":\n\x18RequestWifiParametersSet\x12\x0c\n\x04ssid\x18\x01 \x01(\x0c\x12\x10\n\x08password\x18\x02 \x01(\x0c"\x1b\n\x19ResponseWifiParametersSet"\x12\n\x10RequestWifiIPGet"\x1f\n\x11ResponseWifiIPGet\x12\n\n\x02ip\x18\x01 \x01(\r"\x1c\n\x1aRequestDebugStm32DeepSleep"\x1d\n\x1bResponseDebugStm32DeepSleep"\x1d\n\x1bRequestDebugEnterBootloaderb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'messages_pb2', _globals) @@ -49,9 +49,17 @@ _globals['_REQUESTTIMESTAMPGET']._serialized_end = 901 _globals['_RESPONSETIMESTAMPGET']._serialized_start = 903 _globals['_RESPONSETIMESTAMPGET']._serialized_end = 974 - _globals['_REQUESTDEBUGSTM32DEEPSLEEP']._serialized_start = 976 - _globals['_REQUESTDEBUGSTM32DEEPSLEEP']._serialized_end = 1004 - _globals['_RESPONSEDEBUGSTM32DEEPSLEEP']._serialized_start = 1006 - _globals['_RESPONSEDEBUGSTM32DEEPSLEEP']._serialized_end = 1035 - _globals['_REQUESTDEBUGENTERBOOTLOADER']._serialized_start = 1037 - _globals['_REQUESTDEBUGENTERBOOTLOADER']._serialized_end = 1066 \ No newline at end of file + _globals['_REQUESTWIFIPARAMETERSSET']._serialized_start = 976 + _globals['_REQUESTWIFIPARAMETERSSET']._serialized_end = 1034 + _globals['_RESPONSEWIFIPARAMETERSSET']._serialized_start = 1036 + _globals['_RESPONSEWIFIPARAMETERSSET']._serialized_end = 1063 + _globals['_REQUESTWIFIIPGET']._serialized_start = 1065 + _globals['_REQUESTWIFIIPGET']._serialized_end = 1083 + _globals['_RESPONSEWIFIIPGET']._serialized_start = 1085 + _globals['_RESPONSEWIFIIPGET']._serialized_end = 1116 + _globals['_REQUESTDEBUGSTM32DEEPSLEEP']._serialized_start = 1118 + _globals['_REQUESTDEBUGSTM32DEEPSLEEP']._serialized_end = 1146 + _globals['_RESPONSEDEBUGSTM32DEEPSLEEP']._serialized_start = 1148 + _globals['_RESPONSEDEBUGSTM32DEEPSLEEP']._serialized_end = 1177 + _globals['_REQUESTDEBUGENTERBOOTLOADER']._serialized_start = 1179 + _globals['_REQUESTDEBUGENTERBOOTLOADER']._serialized_end = 1208 \ No newline at end of file diff --git a/device/focstim/messages_pb2.pyi b/device/focstim/messages_pb2.pyi index c7b1cd4..306432c 100644 --- a/device/focstim/messages_pb2.pyi +++ b/device/focstim/messages_pb2.pyi @@ -154,6 +154,36 @@ class ResponseTimestampGet(_message.Message): def __init__(self, timestamp_ms: _Optional[int]=..., unix_timestamp_ms: _Optional[int]=...) -> None: ... +class RequestWifiParametersSet(_message.Message): + __slots__ = ('ssid', 'password') + SSID_FIELD_NUMBER: _ClassVar[int] + PASSWORD_FIELD_NUMBER: _ClassVar[int] + ssid: bytes + password: bytes + + def __init__(self, ssid: _Optional[bytes]=..., password: _Optional[bytes]=...) -> None: + ... + +class ResponseWifiParametersSet(_message.Message): + __slots__ = () + + def __init__(self) -> None: + ... + +class RequestWifiIPGet(_message.Message): + __slots__ = () + + def __init__(self) -> None: + ... + +class ResponseWifiIPGet(_message.Message): + __slots__ = ('ip',) + IP_FIELD_NUMBER: _ClassVar[int] + ip: int + + def __init__(self, ip: _Optional[int]=...) -> None: + ... + class RequestDebugStm32DeepSleep(_message.Message): __slots__ = () diff --git a/device/focstim/notifications_pb2.py b/device/focstim/notifications_pb2.py index 4c0243b..d049395 100644 --- a/device/focstim/notifications_pb2.py +++ b/device/focstim/notifications_pb2.py @@ -6,7 +6,7 @@ from google.protobuf.internal import builder as _builder _runtime_version.ValidateProtobufRuntimeVersion(_runtime_version.Domain.PUBLIC, 6, 31, 0, '', 'notifications.proto') _sym_db = _symbol_database.Default() -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13notifications.proto\x12\x0bfocstim_rpc"\x12\n\x10NotificationBoot"*\n\x19NotificationPotentiometer\x12\r\n\x05value\x18\x01 \x01(\x02"\xd5\x01\n\x14NotificationCurrents\x12\r\n\x05rms_a\x18\x01 \x01(\x02\x12\r\n\x05rms_b\x18\x02 \x01(\x02\x12\r\n\x05rms_c\x18\x03 \x01(\x02\x12\r\n\x05rms_d\x18\x04 \x01(\x02\x12\x0e\n\x06peak_a\x18\x05 \x01(\x02\x12\x0e\n\x06peak_b\x18\x06 \x01(\x02\x12\x0e\n\x06peak_c\x18\x07 \x01(\x02\x12\x0e\n\x06peak_d\x18\x08 \x01(\x02\x12\x14\n\x0coutput_power\x18\t \x01(\x02\x12\x19\n\x11output_power_skin\x18\n \x01(\x02\x12\x10\n\x08peak_cmd\x18\x0b \x01(\x02"\xdf\x01\n\x1bNotificationModelEstimation\x12\x14\n\x0cresistance_a\x18\x01 \x01(\x02\x12\x14\n\x0creluctance_a\x18\x02 \x01(\x02\x12\x14\n\x0cresistance_b\x18\x03 \x01(\x02\x12\x14\n\x0creluctance_b\x18\x04 \x01(\x02\x12\x14\n\x0cresistance_c\x18\x05 \x01(\x02\x12\x14\n\x0creluctance_c\x18\x06 \x01(\x02\x12\x14\n\x0cresistance_d\x18\x07 \x01(\x02\x12\x14\n\x0creluctance_d\x18\x08 \x01(\x02\x12\x10\n\x08constant\x18\x14 \x01(\x02"W\n\x0fSystemStatsESC1\x12\x12\n\ntemp_stm32\x18\x01 \x01(\x02\x12\x12\n\ntemp_board\x18\x02 \x01(\x02\x12\r\n\x05v_bus\x18\x03 \x01(\x02\x12\r\n\x05v_ref\x18\x04 \x01(\x02"s\n\x14SystemStatsFocstimV3\x12\x12\n\ntemp_stm32\x18\x01 \x01(\x02\x12\r\n\x05v_sys\x18\x02 \x01(\x02\x12\r\n\x05v_ref\x18\x03 \x01(\x02\x12\x0f\n\x07v_boost\x18\x04 \x01(\x02\x12\x18\n\x10boost_duty_cycle\x18\x05 \x01(\x02"\x89\x01\n\x17NotificationSystemStats\x12,\n\x04esc1\x18\x01 \x01(\x0b2\x1c.focstim_rpc.SystemStatsESC1H\x00\x126\n\tfocstimv3\x18\x02 \x01(\x0b2!.focstim_rpc.SystemStatsFocstimV3H\x00B\x08\n\x06system"J\n\x17NotificationSignalStats\x12\x1e\n\x16actual_pulse_frequency\x18\x01 \x01(\x02\x12\x0f\n\x07v_drive\x18\x02 \x01(\x02"\x9b\x01\n\x13NotificationBattery\x12\x17\n\x0fbattery_voltage\x18\x01 \x01(\x02\x12 \n\x18battery_charge_rate_watt\x18\x02 \x01(\x02\x12\x13\n\x0bbattery_soc\x18\x03 \x01(\x02\x12\x1a\n\x12wall_power_present\x18\x04 \x01(\x08\x12\x18\n\x10chip_temperature\x18\x05 \x01(\x02"*\n\x17NotificationDebugString\x12\x0f\n\x07message\x18\x01 \x01(\t"F\n\x17NotificationDebugAS5311\x12\x0b\n\x03raw\x18\x01 \x01(\x05\x12\x0f\n\x07tracked\x18\x02 \x01(\x11\x12\r\n\x05flags\x18\x03 \x01(\x05b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13notifications.proto\x12\x0bfocstim_rpc"\x12\n\x10NotificationBoot"*\n\x19NotificationPotentiometer\x12\r\n\x05value\x18\x01 \x01(\x02"\xd5\x01\n\x14NotificationCurrents\x12\r\n\x05rms_a\x18\x01 \x01(\x02\x12\r\n\x05rms_b\x18\x02 \x01(\x02\x12\r\n\x05rms_c\x18\x03 \x01(\x02\x12\r\n\x05rms_d\x18\x04 \x01(\x02\x12\x0e\n\x06peak_a\x18\x05 \x01(\x02\x12\x0e\n\x06peak_b\x18\x06 \x01(\x02\x12\x0e\n\x06peak_c\x18\x07 \x01(\x02\x12\x0e\n\x06peak_d\x18\x08 \x01(\x02\x12\x14\n\x0coutput_power\x18\t \x01(\x02\x12\x19\n\x11output_power_skin\x18\n \x01(\x02\x12\x10\n\x08peak_cmd\x18\x0b \x01(\x02"\xdf\x01\n\x1bNotificationModelEstimation\x12\x14\n\x0cresistance_a\x18\x01 \x01(\x02\x12\x14\n\x0creluctance_a\x18\x02 \x01(\x02\x12\x14\n\x0cresistance_b\x18\x03 \x01(\x02\x12\x14\n\x0creluctance_b\x18\x04 \x01(\x02\x12\x14\n\x0cresistance_c\x18\x05 \x01(\x02\x12\x14\n\x0creluctance_c\x18\x06 \x01(\x02\x12\x14\n\x0cresistance_d\x18\x07 \x01(\x02\x12\x14\n\x0creluctance_d\x18\x08 \x01(\x02\x12\x10\n\x08constant\x18\x14 \x01(\x02"W\n\x0fSystemStatsESC1\x12\x12\n\ntemp_stm32\x18\x01 \x01(\x02\x12\x12\n\ntemp_board\x18\x02 \x01(\x02\x12\r\n\x05v_bus\x18\x03 \x01(\x02\x12\r\n\x05v_ref\x18\x04 \x01(\x02"s\n\x14SystemStatsFocstimV3\x12\x12\n\ntemp_stm32\x18\x01 \x01(\x02\x12\r\n\x05v_sys\x18\x02 \x01(\x02\x12\r\n\x05v_ref\x18\x03 \x01(\x02\x12\x0f\n\x07v_boost\x18\x04 \x01(\x02\x12\x18\n\x10boost_duty_cycle\x18\x05 \x01(\x02"\x89\x01\n\x17NotificationSystemStats\x12,\n\x04esc1\x18\x01 \x01(\x0b2\x1c.focstim_rpc.SystemStatsESC1H\x00\x126\n\tfocstimv3\x18\x02 \x01(\x0b2!.focstim_rpc.SystemStatsFocstimV3H\x00B\x08\n\x06system"J\n\x17NotificationSignalStats\x12\x1e\n\x16actual_pulse_frequency\x18\x01 \x01(\x02\x12\x0f\n\x07v_drive\x18\x02 \x01(\x02"\x9b\x01\n\x13NotificationBattery\x12\x17\n\x0fbattery_voltage\x18\x01 \x01(\x02\x12 \n\x18battery_charge_rate_watt\x18\x02 \x01(\x02\x12\x13\n\x0bbattery_soc\x18\x03 \x01(\x02\x12\x1a\n\x12wall_power_present\x18\x04 \x01(\x08\x12\x18\n\x10chip_temperature\x18\x05 \x01(\x02"*\n\x17NotificationDebugString\x12\x0f\n\x07message\x18\x01 \x01(\t"F\n\x17NotificationDebugAS5311\x12\x0b\n\x03raw\x18\x01 \x01(\x05\x12\x0f\n\x07tracked\x18\x02 \x01(\x11\x12\r\n\x05flags\x18\x03 \x01(\x05"k\n\x17NotificationDebugEdging\x12\x1c\n\x14full_power_threshold\x18\x01 \x01(\x02\x12\x1f\n\x17reduced_power_threshold\x18\x02 \x01(\x02\x12\x11\n\treduction\x18\x03 \x01(\x02b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'notifications_pb2', _globals) @@ -33,4 +33,6 @@ _globals['_NOTIFICATIONDEBUGSTRING']._serialized_start = 1122 _globals['_NOTIFICATIONDEBUGSTRING']._serialized_end = 1164 _globals['_NOTIFICATIONDEBUGAS5311']._serialized_start = 1166 - _globals['_NOTIFICATIONDEBUGAS5311']._serialized_end = 1236 \ No newline at end of file + _globals['_NOTIFICATIONDEBUGAS5311']._serialized_end = 1236 + _globals['_NOTIFICATIONDEBUGEDGING']._serialized_start = 1238 + _globals['_NOTIFICATIONDEBUGEDGING']._serialized_end = 1345 \ No newline at end of file diff --git a/device/focstim/notifications_pb2.pyi b/device/focstim/notifications_pb2.pyi index 6854c55..ac1f074 100644 --- a/device/focstim/notifications_pb2.pyi +++ b/device/focstim/notifications_pb2.pyi @@ -154,4 +154,16 @@ class NotificationDebugAS5311(_message.Message): flags: int def __init__(self, raw: _Optional[int]=..., tracked: _Optional[int]=..., flags: _Optional[int]=...) -> None: + ... + +class NotificationDebugEdging(_message.Message): + __slots__ = ('full_power_threshold', 'reduced_power_threshold', 'reduction') + FULL_POWER_THRESHOLD_FIELD_NUMBER: _ClassVar[int] + REDUCED_POWER_THRESHOLD_FIELD_NUMBER: _ClassVar[int] + REDUCTION_FIELD_NUMBER: _ClassVar[int] + full_power_threshold: float + reduced_power_threshold: float + reduction: float + + def __init__(self, full_power_threshold: _Optional[float]=..., reduced_power_threshold: _Optional[float]=..., reduction: _Optional[float]=...) -> None: ... \ No newline at end of file diff --git a/device/focstim/proto_api.py b/device/focstim/proto_api.py index 62e5b4f..7e12fb0 100644 --- a/device/focstim/proto_api.py +++ b/device/focstim/proto_api.py @@ -10,7 +10,7 @@ from device.focstim.hdlc import HDLC from device.focstim.messages_pb2 import RequestFirmwareVersion, RequestAxisSet, RequestAxisMoveTo, RequestTimestampSet, \ RequestSignalStart, RequestSignalStop, RequestCapabilitiesGet, RequestDebugStm32DeepSleep, \ - RequestDebugEnterBootloader + RequestDebugEnterBootloader, RequestWifiParametersSet, RequestWifiIPGet from device.focstim.notifications_pb2 import NotificationBoot, NotificationPotentiometer, NotificationCurrents, \ NotificationModelEstimation, NotificationSystemStats, NotificationSignalStats, NotificationBattery, \ NotificationDebugString, NotificationDebugAS5311 @@ -209,6 +209,21 @@ def request_capabilities_get(self) -> Future: request_capabilities_get=RequestCapabilitiesGet() )) + def request_wifi_parameters_set(self, ssid: bytes, password: bytes) -> Future: + return self.send_request(Request( + id=self.next_request_id(), + request_wifi_parameters_set=RequestWifiParametersSet( + ssid=ssid, + password=password + ) + )) + + def request_wifi_ip_get(self) -> Future: + return self.send_request(Request( + id=self.next_request_id(), + request_wifi_ip_get=RequestWifiIPGet() + )) + def request_debug_stm32_deep_sleep(self) -> Future: return self.send_request(Request( id=self.next_request_id(), diff --git a/qt_ui/mainwindow.py b/qt_ui/mainwindow.py index 9588db5..8746ae8 100644 --- a/qt_ui/mainwindow.py +++ b/qt_ui/mainwindow.py @@ -447,11 +447,15 @@ def signal_start(self): self.refresh_play_button_icon() elif device.device_type in (DeviceType.FOCSTIM_THREE_PHASE, DeviceType.FOCSTIM_FOUR_PHASE): output_device = FOCStimProtoDevice() - serial_port_name = qt_ui.settings.focstim_serial_port.get() use_teleplot = qt_ui.settings.focstim_use_teleplot.get() dump_notifications = qt_ui.settings.focstim_dump_notifications_to_file.get() - output_device.start_serial(serial_port_name, use_teleplot, dump_notifications, algorithm) - # output_device.start_tcp('192.168.2.17', 55533, use_teleplot, dump_notifications, algorithm) + comms_wifi = qt_ui.settings.focstim_communication_wifi.get() + if not comms_wifi: + serial_port_name = qt_ui.settings.focstim_serial_port.get() + output_device.start_serial(serial_port_name, use_teleplot, dump_notifications, algorithm) + else: + ip = qt_ui.settings.focstim_ip.get() + output_device.start_tcp(ip, 55533, use_teleplot, dump_notifications, algorithm) if output_device.is_connected_and_running(): self.output_device = output_device self.playstate = PlayState.PLAYING diff --git a/qt_ui/preferences_dialog.py b/qt_ui/preferences_dialog.py index f49e9b8..2a4edab 100644 --- a/qt_ui/preferences_dialog.py +++ b/qt_ui/preferences_dialog.py @@ -1,5 +1,7 @@ import functools +import logging +import google.protobuf.text_format from PySide6.QtSerialPort import QSerialPortInfo from PySide6.QtWidgets import QDialog, QAbstractButton, QDialogButtonBox, QAbstractItemView, QHeaderView, QComboBox, QTableWidgetItem, QCheckBox, QApplication from PySide6.QtCore import Qt, Signal, QTimer @@ -7,10 +9,13 @@ from qt_ui.preferences_dialog_ui import Ui_PreferencesDialog from qt_ui.models.funscript_kit import FunscriptKitModel from qt_ui.services.pattern_service import PatternControlService +from device.focstim.helpers import WifiUploadHelper, GrabIpHelper import qt_ui.settings import sounddevice as sd +logger = logging.getLogger('restim.preferences') + class PreferencesDialog(QDialog, Ui_PreferencesDialog): def __init__(self, parent=None): @@ -64,9 +69,13 @@ def __init__(self, parent=None): ) # focstim/neostim reload serial devices - self.refresh_serial_devices.clicked.connect(self.repopulate_serial_devices) + self.focstim_refresh_serial_devices.clicked.connect(self.repopulate_serial_devices) self.neostim_refresh_serial_devices.clicked.connect(self.repopulate_serial_devices) - + + # focstim buttons + self.focstim_read_ip.clicked.connect(self.read_focstim_ip) + self.focstim_sync.clicked.connect(self.upload_focstim_ssid) + def _cache_patterns_data(self): """Cache pattern data at startup to avoid late discovery issues""" try: @@ -133,6 +142,12 @@ def loadSettings(self): self.focstim_teleplot_prefix.setText(qt_ui.settings.focstim_teleplot_prefix.get()) self.focstim_dump_notifications.setChecked(qt_ui.settings.focstim_dump_notifications_to_file.get()) + self.focstim_radio_serial.setChecked(qt_ui.settings.focstim_communication_serial.get()) + self.focstim_radio_wifi.setChecked(qt_ui.settings.focstim_communication_wifi.get()) + self.focstim_ssid.setText(qt_ui.settings.focstim_ssid.get()) + self.focstim_password.setText(qt_ui.settings.focstim_password.get()) + self.focstim_ip.setText(qt_ui.settings.focstim_ip.get()) + # neostim settings self.neostim_port.setCurrentIndex(self.neostim_port.findData(qt_ui.settings.neostim_serial_port.get())) @@ -197,7 +212,6 @@ def refresh(control: QComboBox, setting: qt_ui.settings.Setting): refresh(self.focstim_port, qt_ui.settings.focstim_serial_port) refresh(self.neostim_port, qt_ui.settings.neostim_serial_port) - def refresh_audio_device_info(self): api_index = self.audio_api.currentIndex() device_name = self.audio_output_device.currentText() @@ -207,6 +221,57 @@ def refresh_audio_device_info(self): samplerate = device['default_samplerate'] self.audio_info.setText(f"channels: {out_channels}, samplerate: {samplerate}") + def upload_focstim_ssid(self): + helper = WifiUploadHelper() + fut = helper.upload( + self.focstim_port.currentData(), + self.focstim_ssid.text().encode("utf-8"), + self.focstim_password.text().encode("utf-8"), + ) + if fut is None: + return + self.focstim_sync.setEnabled(False) + + def timeout(): + helper.close() + logger.error("timeout uploading wifi settings") + self.focstim_sync.setEnabled(True) + + def result(result): + helper.close() + s = google.protobuf.text_format.MessageToString(result, as_one_line=True) + logger.info(f"response: {s}") + self.focstim_sync.setEnabled(True) + + fut.on_timeout.connect(timeout) + fut.on_result.connect(result) + + def read_focstim_ip(self): + helper = GrabIpHelper() + fut = helper.get_ip( + self.focstim_port.currentData(), + ) + if fut is None: + return + self.focstim_read_ip.setEnabled(False) + + def timeout(): + helper.close() + logger.error("timeout grabbing IP") + self.focstim_read_ip.setEnabled(True) + + def result(result): + helper.close() + s = google.protobuf.text_format.MessageToString(result, as_one_line=True) + logger.info(f"response: {s}") + ip = result.response_wifi_ip_get.ip + ip_string = f"{(ip >> 24) & 0xFF}.{(ip >> 16) & 0xFF}.{(ip >> 8) & 0xFF}.{ip & 0xFF}" + self.focstim_read_ip.setEnabled(True) + self.focstim_ip.setText(ip_string) + + fut.on_timeout.connect(timeout) + fut.on_result.connect(result) + def saveSettings(self): # network qt_ui.settings.websocket_enabled.set(self.gb_websocket_server.isChecked()) @@ -239,6 +304,11 @@ def saveSettings(self): qt_ui.settings.focstim_use_teleplot.set(self.focstim_use_teleplot.isChecked()) qt_ui.settings.focstim_teleplot_prefix.set(self.focstim_teleplot_prefix.text()) qt_ui.settings.focstim_dump_notifications_to_file.set(self.focstim_dump_notifications.isChecked()) + qt_ui.settings.focstim_communication_serial.set(self.focstim_radio_serial.isChecked()) + qt_ui.settings.focstim_communication_wifi.set(self.focstim_radio_wifi.isChecked()) + qt_ui.settings.focstim_ssid.set(self.focstim_ssid.text()) + qt_ui.settings.focstim_password.set(self.focstim_password.text()) + qt_ui.settings.focstim_ip.set(self.focstim_ip.text()) # neoStim qt_ui.settings.neostim_serial_port.set(str(self.neostim_port.currentData())) diff --git a/qt_ui/preferences_dialog_ui.py b/qt_ui/preferences_dialog_ui.py index b40e329..8768ac4 100644 --- a/qt_ui/preferences_dialog_ui.py +++ b/qt_ui/preferences_dialog_ui.py @@ -19,9 +19,9 @@ QComboBox, QDialogButtonBox, QDoubleSpinBox, QFormLayout, QFrame, QGridLayout, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPushButton, - QSizePolicy, QSpacerItem, QSpinBox, QTabWidget, - QTableWidget, QTableWidgetItem, QToolButton, QVBoxLayout, - QWidget) + QRadioButton, QSizePolicy, QSpacerItem, QSpinBox, + QTabWidget, QTableWidget, QTableWidgetItem, QToolButton, + QVBoxLayout, QWidget) from qt_ui.widgets.table_view_with_combobox import TableViewWithComboBox import restim_rc @@ -266,34 +266,31 @@ def setupUi(self, PreferencesDialog): self.tab_foc.setObjectName(u"tab_foc") self.verticalLayout_5 = QVBoxLayout(self.tab_foc) self.verticalLayout_5.setObjectName(u"verticalLayout_5") - self.groupBox_8 = QGroupBox(self.tab_foc) - self.groupBox_8.setObjectName(u"groupBox_8") - self.gridLayout_5 = QGridLayout(self.groupBox_8) - self.gridLayout_5.setObjectName(u"gridLayout_5") - self.focstim_teleplot_prefix = QLineEdit(self.groupBox_8) - self.focstim_teleplot_prefix.setObjectName(u"focstim_teleplot_prefix") + self.groupBox_11 = QGroupBox(self.tab_foc) + self.groupBox_11.setObjectName(u"groupBox_11") + self.formLayout_10 = QFormLayout(self.groupBox_11) + self.formLayout_10.setObjectName(u"formLayout_10") + self.focstim_radio_serial = QRadioButton(self.groupBox_11) + self.focstim_radio_serial.setObjectName(u"focstim_radio_serial") - self.gridLayout_5.addWidget(self.focstim_teleplot_prefix, 2, 2, 1, 1) + self.formLayout_10.setWidget(0, QFormLayout.ItemRole.LabelRole, self.focstim_radio_serial) - self.focstim_use_teleplot = QCheckBox(self.groupBox_8) - self.focstim_use_teleplot.setObjectName(u"focstim_use_teleplot") - - self.gridLayout_5.addWidget(self.focstim_use_teleplot, 1, 2, 1, 1) - - self.label_16 = QLabel(self.groupBox_8) - self.label_16.setObjectName(u"label_16") + self.focstim_radio_wifi = QRadioButton(self.groupBox_11) + self.focstim_radio_wifi.setObjectName(u"focstim_radio_wifi") - self.gridLayout_5.addWidget(self.label_16, 2, 0, 1, 1) + self.formLayout_10.setWidget(1, QFormLayout.ItemRole.LabelRole, self.focstim_radio_wifi) - self.refresh_serial_devices = QToolButton(self.groupBox_8) - self.refresh_serial_devices.setObjectName(u"refresh_serial_devices") - self.gridLayout_5.addWidget(self.refresh_serial_devices, 0, 3, 1, 1) + self.verticalLayout_5.addWidget(self.groupBox_11) - self.label_18 = QLabel(self.groupBox_8) - self.label_18.setObjectName(u"label_18") + self.groupBox_8 = QGroupBox(self.tab_foc) + self.groupBox_8.setObjectName(u"groupBox_8") + self.gridLayout_5 = QGridLayout(self.groupBox_8) + self.gridLayout_5.setObjectName(u"gridLayout_5") + self.label_14 = QLabel(self.groupBox_8) + self.label_14.setObjectName(u"label_14") - self.gridLayout_5.addWidget(self.label_18, 3, 0, 1, 1) + self.gridLayout_5.addWidget(self.label_14, 0, 0, 1, 1) self.focstim_port = QComboBox(self.groupBox_8) self.focstim_port.setObjectName(u"focstim_port") @@ -305,23 +302,97 @@ def setupUi(self, PreferencesDialog): self.gridLayout_5.addWidget(self.focstim_port, 0, 2, 1, 1) - self.label_15 = QLabel(self.groupBox_8) - self.label_15.setObjectName(u"label_15") + self.focstim_refresh_serial_devices = QToolButton(self.groupBox_8) + self.focstim_refresh_serial_devices.setObjectName(u"focstim_refresh_serial_devices") - self.gridLayout_5.addWidget(self.label_15, 1, 0, 1, 1) + self.gridLayout_5.addWidget(self.focstim_refresh_serial_devices, 0, 3, 1, 1) - self.label_14 = QLabel(self.groupBox_8) - self.label_14.setObjectName(u"label_14") - self.gridLayout_5.addWidget(self.label_14, 0, 0, 1, 1) + self.verticalLayout_5.addWidget(self.groupBox_8) + + self.groupBox_10 = QGroupBox(self.tab_foc) + 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") + + self.gridLayout_7.addWidget(self.focstim_ssid, 0, 1, 1, 2) + + self.focstim_sync = QToolButton(self.groupBox_10) + self.focstim_sync.setObjectName(u"focstim_sync") + + self.gridLayout_7.addWidget(self.focstim_sync, 2, 1, 1, 1) + + self.label_21 = QLabel(self.groupBox_10) + self.label_21.setObjectName(u"label_21") + + 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") - self.focstim_dump_notifications = QCheckBox(self.groupBox_8) + self.gridLayout_7.addWidget(self.focstim_password, 1, 1, 1, 2) + + self.label_20 = QLabel(self.groupBox_10) + self.label_20.setObjectName(u"label_20") + + self.gridLayout_7.addWidget(self.label_20, 0, 0, 1, 1) + + 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.verticalLayout_5.addWidget(self.groupBox_10) + + self.groupBox_9 = QGroupBox(self.tab_foc) + 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.formLayout_8.setWidget(2, QFormLayout.ItemRole.LabelRole, self.label_18) + + self.focstim_dump_notifications = QCheckBox(self.groupBox_9) self.focstim_dump_notifications.setObjectName(u"focstim_dump_notifications") - self.gridLayout_5.addWidget(self.focstim_dump_notifications, 3, 2, 1, 1) + self.formLayout_8.setWidget(2, QFormLayout.ItemRole.FieldRole, self.focstim_dump_notifications) + + self.label_16 = QLabel(self.groupBox_9) + self.label_16.setObjectName(u"label_16") + self.formLayout_8.setWidget(1, QFormLayout.ItemRole.LabelRole, self.label_16) - self.verticalLayout_5.addWidget(self.groupBox_8) + self.focstim_teleplot_prefix = QLineEdit(self.groupBox_9) + self.focstim_teleplot_prefix.setObjectName(u"focstim_teleplot_prefix") + + 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.formLayout_8.setWidget(0, QFormLayout.ItemRole.LabelRole, self.label_15) + + self.focstim_use_teleplot = QCheckBox(self.groupBox_9) + self.focstim_use_teleplot.setObjectName(u"focstim_use_teleplot") + + self.formLayout_8.setWidget(0, QFormLayout.ItemRole.FieldRole, self.focstim_use_teleplot) + + + self.verticalLayout_5.addWidget(self.groupBox_9) self.verticalSpacer_4 = QSpacerItem(20, 40, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding) @@ -613,15 +684,55 @@ def setupUi(self, PreferencesDialog): self.verticalLayout.addWidget(self.buttonBox) - QWidget.setTabOrder(self.udp_localhost_only, self.display_fps) - QWidget.setTabOrder(self.display_fps, self.display_latency_ms) - QWidget.setTabOrder(self.display_latency_ms, self.audio_api) - QWidget.setTabOrder(self.audio_api, self.audio_output_device) + QWidget.setTabOrder(self.focstim_radio_serial, self.focstim_radio_wifi) + QWidget.setTabOrder(self.focstim_radio_wifi, self.focstim_port) + QWidget.setTabOrder(self.focstim_port, self.focstim_refresh_serial_devices) + 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_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) + QWidget.setTabOrder(self.tcp_port, self.udp_port) + QWidget.setTabOrder(self.udp_port, self.gb_serial) + QWidget.setTabOrder(self.gb_serial, self.serial_auto_expand) + QWidget.setTabOrder(self.serial_auto_expand, self.serial_port) + QWidget.setTabOrder(self.serial_port, self.gb_buttplug_wsdm) + QWidget.setTabOrder(self.gb_buttplug_wsdm, self.buttplug_wsdm_address) + QWidget.setTabOrder(self.buttplug_wsdm_address, self.buttplug_wsdm_auto_expand) + QWidget.setTabOrder(self.buttplug_wsdm_auto_expand, self.audio_latency) + QWidget.setTabOrder(self.audio_latency, self.tabWidget) + QWidget.setTabOrder(self.tabWidget, self.gb_udp_server) + QWidget.setTabOrder(self.gb_udp_server, self.audio_api) + QWidget.setTabOrder(self.audio_api, self.display_latency_ms) + QWidget.setTabOrder(self.display_latency_ms, self.websocket_port) + QWidget.setTabOrder(self.websocket_port, self.neostim_refresh_serial_devices) + QWidget.setTabOrder(self.neostim_refresh_serial_devices, self.neostim_port) + QWidget.setTabOrder(self.neostim_port, self.mpc_address) + QWidget.setTabOrder(self.mpc_address, self.mpc_reload) + QWidget.setTabOrder(self.mpc_reload, self.heresphere_address) + QWidget.setTabOrder(self.heresphere_address, self.heresphere_reload) + QWidget.setTabOrder(self.heresphere_reload, self.vlc_reload) + QWidget.setTabOrder(self.vlc_reload, self.vlc_address) + QWidget.setTabOrder(self.vlc_address, self.vlc_username) + QWidget.setTabOrder(self.vlc_username, self.vlc_password) + QWidget.setTabOrder(self.vlc_password, self.kodi_address) + QWidget.setTabOrder(self.kodi_address, self.kodi_reload) + QWidget.setTabOrder(self.kodi_reload, self.tableView) + QWidget.setTabOrder(self.tableView, self.button_funscript_reset_defaults) + QWidget.setTabOrder(self.button_funscript_reset_defaults, self.button_patterns_enable_all) + QWidget.setTabOrder(self.button_patterns_enable_all, self.button_patterns_disable_all) + QWidget.setTabOrder(self.button_patterns_disable_all, self.patterns_table) + QWidget.setTabOrder(self.patterns_table, self.audio_output_device) QWidget.setTabOrder(self.audio_output_device, self.gb_websocket_server) - QWidget.setTabOrder(self.gb_websocket_server, self.websocket_localhost_only) - QWidget.setTabOrder(self.websocket_localhost_only, self.gb_tcp_server) - QWidget.setTabOrder(self.gb_tcp_server, self.tcp_localhost_only) - QWidget.setTabOrder(self.tcp_localhost_only, self.gb_udp_server) + QWidget.setTabOrder(self.gb_websocket_server, self.gb_tcp_server) + QWidget.setTabOrder(self.gb_tcp_server, self.udp_localhost_only) + QWidget.setTabOrder(self.udp_localhost_only, self.display_fps) + QWidget.setTabOrder(self.display_fps, self.tcp_localhost_only) + QWidget.setTabOrder(self.tcp_localhost_only, self.websocket_localhost_only) self.retranslateUi(PreferencesDialog) @@ -676,17 +787,27 @@ def retranslateUi(self, PreferencesDialog): self.label_27.setText(QCoreApplication.translate("PreferencesDialog", u"Info", None)) self.audio_info.setText(QCoreApplication.translate("PreferencesDialog", u"TextLabel", None)) self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_audio), QCoreApplication.translate("PreferencesDialog", u"Audio", None)) - self.groupBox_8.setTitle(QCoreApplication.translate("PreferencesDialog", u"FOC-Stim", None)) - self.focstim_use_teleplot.setText("") + self.groupBox_11.setTitle(QCoreApplication.translate("PreferencesDialog", u"Communication", None)) + self.focstim_radio_serial.setText(QCoreApplication.translate("PreferencesDialog", u"Serial", None)) + self.focstim_radio_wifi.setText(QCoreApplication.translate("PreferencesDialog", u"Wifi", None)) + self.groupBox_8.setTitle(QCoreApplication.translate("PreferencesDialog", u"Serial", None)) + 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.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.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("") #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.refresh_serial_devices.setText(QCoreApplication.translate("PreferencesDialog", u"Refresh", None)) - self.label_18.setText(QCoreApplication.translate("PreferencesDialog", u"Dump notifications to file", None)) self.label_15.setText(QCoreApplication.translate("PreferencesDialog", u"Use teleplot", None)) - self.label_14.setText(QCoreApplication.translate("PreferencesDialog", u"Serial port", None)) - self.focstim_dump_notifications.setText("") + self.focstim_use_teleplot.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)) diff --git a/qt_ui/settings.py b/qt_ui/settings.py index 95aa696..1ffbcbe 100644 --- a/qt_ui/settings.py +++ b/qt_ui/settings.py @@ -158,6 +158,11 @@ def set(self, value): focstim_use_teleplot = Setting("focstim/use-teleplot", True, bool) focstim_teleplot_prefix = Setting("focstim/teleplot_prefix", "", str) focstim_dump_notifications_to_file = Setting("focstim/dump_notifications_to_file", False, bool) +focstim_communication_serial = Setting("focstim/communication_serial", True, bool) +focstim_communication_wifi = Setting("focstim/communication_wifi", False, bool) +focstim_ssid = Setting("focstim/wifi_ssid", '', str) +focstim_password = Setting("focstim/wifi_password", '', str) +focstim_ip = Setting("focstim/wifi_ip", '', str) neostim_serial_port = Setting("neostim/serial_port", '', str) From f14207ac29b019ae97dcb7710438d880139f8fcd Mon Sep 17 00:00:00 2001 From: diglet48 Date: Tue, 30 Sep 2025 11:42:30 +0200 Subject: [PATCH 12/47] Add firmware flash dialog for FOC-Stim v4 --- build instructions.txt | 1 + designer/focstimflashdialog.ui | 81 ++++++++++++ designer/mainwindow.ui | 7 ++ qt_ui/focstim_flash_dialog.py | 207 +++++++++++++++++++++++++++++++ qt_ui/focstim_flash_dialog_ui.py | 98 +++++++++++++++ qt_ui/main_window_ui.py | 5 + qt_ui/mainwindow.py | 8 ++ requirements.txt | 1 + 8 files changed, 408 insertions(+) create mode 100644 designer/focstimflashdialog.ui create mode 100644 qt_ui/focstim_flash_dialog.py create mode 100644 qt_ui/focstim_flash_dialog_ui.py diff --git a/build instructions.txt b/build instructions.txt index 884acf4..c3994eb 100644 --- a/build instructions.txt +++ b/build instructions.txt @@ -18,6 +18,7 @@ ./venv/Scripts/pyside6-uic.exe -o ./qt_ui/ab_test_widget_ui.py ./designer/abtestwidget.ui ./venv/Scripts/pyside6-uic.exe -o ./qt_ui/neostim_settings_widget_ui.py ./designer/neostimsettingswidget.ui ./venv/Scripts/pyside6-uic.exe -o ./qt_ui/simfile_conversion_dialog_ui.py ./designer/simfileconversiondialog.ui +./venv/Scripts/pyside6-uic.exe -o ./qt_ui/focstim_flash_dialog_ui.py ./designer/focstimflashdialog.ui ./venv/Scripts/pyside6-uic.exe -o ./qt_ui/device_wizard/type_select_ui.py ./designer/device_wizard/type_select.ui ./venv/Scripts/pyside6-uic.exe -o ./qt_ui/device_wizard/waveform_select_ui.py ./designer/device_wizard/waveform_select.ui diff --git a/designer/focstimflashdialog.ui b/designer/focstimflashdialog.ui new file mode 100644 index 0000000..6b826eb --- /dev/null +++ b/designer/focstimflashdialog.ui @@ -0,0 +1,81 @@ + + + FocStimFlashDialog + + + + 0 + 0 + 522 + 395 + + + + FOC-Stim firmware flasher + + + + + + Settings + + + + + + Serial port + + + + + + + Firmware + + + + + + + + + + + + + Refresh + + + + + + + Open... + + + + + + + + + + Only for FOC-Stim V4 + + + + + + + Firmware update + + + + + + + + + + + diff --git a/designer/mainwindow.ui b/designer/mainwindow.ui index 0afe07f..7a2cdff 100644 --- a/designer/mainwindow.ui +++ b/designer/mainwindow.ui @@ -316,6 +316,8 @@ + + @@ -443,6 +445,11 @@ Funscript decomposition + + + Firmware updater + + diff --git a/qt_ui/focstim_flash_dialog.py b/qt_ui/focstim_flash_dialog.py new file mode 100644 index 0000000..71d26b6 --- /dev/null +++ b/qt_ui/focstim_flash_dialog.py @@ -0,0 +1,207 @@ +import serial +import time + +from pathlib import Path + +import stm32loader.bootloader +from PySide6.QtSerialPort import QSerialPortInfo +from serial.serialutil import SerialException + +from PySide6.QtWidgets import QDialog, QFileDialog +from PySide6.QtCore import Signal, QThread + +from qt_ui.focstim_flash_dialog_ui import Ui_FocStimFlashDialog +from qt_ui.file_dialog import FileDialog +from qt_ui import settings + + + +BAUD = 115200 + + +class FlashingThread(QThread): + def __init__(self, com_port, file_path, parent=None): + super(FlashingThread, self).__init__(parent) + self.com_port = com_port + self.file_path = file_path + + def run(self): + # firmware_binary = Path(PATH).read_bytes() + path = Path(self.file_path) + if not path.is_file(): + self.report_message.emit("ERROR: could not open firmware binary") + return + + firmware_binary = path.read_bytes() + self.report_message.emit(f"firmware size: {len(firmware_binary)} bytes") + + if len(firmware_binary) / 1024 > 128: # flash is 128k + 128k, only single-page firmware supported at this time + self.report_message.emit("ERROR: firmware too large") + + com_port = self.com_port + self.report_message.emit(f'opening {com_port}') + + # Note: due to windows driver shit, closing the serial connection + # toggles reset line and reboots the ESP32 + try: + serial_connection = serial.Serial( + port=com_port, + baudrate=BAUD, + bytesize=8, parity='E', stopbits=1, + xonxoff=0, # don't enable software flow control + rtscts=0, # don't enable RTS/CTS flow control + timeout=1, # set a timeout value, None for waiting forever + ) + except SerialException as e: + self.report_message.emit(e) + return + + # prevent toggle RTS/DTR on port closing + serial_connection.setRTS(False) + serial_connection.setDTR(False) + + with serial_connection: + stm32 = stm32loader.bootloader.Stm32Bootloader( + serial_connection, + verbosity=10, + show_progress=False, + device_family=None, + ) + + bootloader_active = self.poke_bootloader(serial_connection, stm32) + + if not bootloader_active: + self.report_message.emit('Sending command to enter bootloader') + self.enter_bootloader(serial_connection) + time.sleep(0.05) + bootloader_active = self.poke_bootloader(serial_connection, stm32) + + if not bootloader_active: + self.report_message.emit('ERROR: Bootloader not active, giving up.') + return + + self.report_message.emit('Bootloader activated!') + + stm32.detect_device() + flash_size = stm32.get_flash_size() + self.report_message.emit(f'device: {stm32.device}') + self.report_message.emit(f'flash size: {flash_size}Kb') + + if stm32.device.product_id != 0x469: + self.report_message.emit(f'ERROR: Unsupported CPU. Not a FOC-Stim v4 board?') + return + + if flash_size != 256: + self.report_message.emit(f'ERROR: Unsupported memory amount. Not a FOC-Stim v4 board?') + return + + self.report_message.emit('Erasing flash...') + stm32.extended_erase_memory() + page1 = firmware_binary[:0x20000] # 128k + self.report_message.emit('writing firmware...') + stm32.write_memory_data(0x0800_0000, page1) + self.report_message.emit('verifying...') + page1_readback = stm32.read_memory_data(0x0800_0000, len(page1)) + try: + stm32.verify_data(page1_readback, page1) + except stm32loader.bootloader.DataMismatchError as e: + self.report_message.emit("Verify failed, please retry") + self.report_message.emit(e) + return + + self.report_message.emit('verified!') + self.report_message.emit('starting program') + stm32.go(0x08000000) + + + def poke_bootloader(self, transport, stm32): + self.report_message.emit("trying to talk with bootloader...") + for attempt in range(4): + transport.reset_input_buffer() + stm32.write(stm32.Command.SYNCHRONIZE) + read_data = bytearray(transport.read()) + if read_data and read_data[0] in (stm32.Reply.ACK, stm32.Reply.NACK): + self.report_message.emit(f"Got {read_data}, OK, bootloader active.") + return True + elif read_data and read_data[0] == ord(b'~'): + self.report_message.emit(f"Got {read_data}, bootloader not active.") + return False + else: + self.report_message.emit(f"Got {read_data}, Retrying.") + self.report_message.emit("Maximum retries exceeded") + return False + + def enter_bootloader(self, transport): + # RequestDebugEnterBootloader + transport.write(b'~\x12\x05\x08{\xca>\x00\xa7\x8f~') + transport.reset_input_buffer() + + # report_progress = Signal() + report_message = Signal(str) + + + +class FocStimFlashDialog(QDialog, Ui_FocStimFlashDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setupUi(self) + + self.flashing_thread = None + + self.pushButton.clicked.connect(self.flash) + self.refresh.clicked.connect(self.refresh_serial_devices) + self.open.clicked.connect(self.open_file_picker) + + self.refresh_serial_devices() + + def flash(self): + self.textBrowser.clear() + self.pushButton.setEnabled(False) + + self.flashing_thread = FlashingThread( + self.focstim_port.currentData(), + self.firmware_path.text() + ) + self.flashing_thread.report_message.connect(self.textBrowser.append) + + self.flashing_thread.finished.connect(lambda : self.pushButton.setEnabled(True)) + self.flashing_thread.start() + + def refresh_serial_devices(self): + selected_port_name = self.focstim_port.currentData() + if selected_port_name is None: + selected_port_name = settings.focstim_serial_port.get() + + + self.focstim_port.clear() + for port in QSerialPortInfo.availablePorts(): + self.focstim_port.addItem( + f"{port.portName()} {port.description()}", + port.portName() + ) + + if selected_port_name: + index = self.focstim_port.findData(selected_port_name) + if index != -1: + self.focstim_port.setCurrentIndex(index) + else: + # if the port is no longer available, create a dummy port and add that. + self.focstim_port.addItem( + f"{selected_port_name}", + selected_port_name + ) + self.focstim_port.setCurrentIndex(self.focstim_port.count() - 1) + + def open_file_picker(self): + dialog = FileDialog(self) + dialog.setWindowTitle('Select FOC-Stim v4 firmware') + dialog.setFileMode(QFileDialog.ExistingFile) + dialog.setNameFilters(["*.bin"]) + ret = dialog.exec() + + if ret: + files = dialog.selectedFiles() + if files: + self.firmware_path.setText(files[0]) + else: + self.firmware_path.clear() diff --git a/qt_ui/focstim_flash_dialog_ui.py b/qt_ui/focstim_flash_dialog_ui.py new file mode 100644 index 0000000..53d6a84 --- /dev/null +++ b/qt_ui/focstim_flash_dialog_ui.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- + +################################################################################ +## Form generated from reading UI file 'focstimflashdialog.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, QComboBox, QDialog, QGridLayout, + QGroupBox, QLabel, QLineEdit, QPushButton, + QSizePolicy, QTextBrowser, QToolButton, QVBoxLayout, + QWidget) + +class Ui_FocStimFlashDialog(object): + def setupUi(self, FocStimFlashDialog): + if not FocStimFlashDialog.objectName(): + FocStimFlashDialog.setObjectName(u"FocStimFlashDialog") + FocStimFlashDialog.resize(522, 395) + self.verticalLayout = QVBoxLayout(FocStimFlashDialog) + self.verticalLayout.setObjectName(u"verticalLayout") + self.groupBox = QGroupBox(FocStimFlashDialog) + self.groupBox.setObjectName(u"groupBox") + self.gridLayout = QGridLayout(self.groupBox) + self.gridLayout.setObjectName(u"gridLayout") + self.label = QLabel(self.groupBox) + self.label.setObjectName(u"label") + + self.gridLayout.addWidget(self.label, 0, 0, 1, 1) + + self.label_2 = QLabel(self.groupBox) + self.label_2.setObjectName(u"label_2") + + self.gridLayout.addWidget(self.label_2, 1, 0, 1, 1) + + self.focstim_port = QComboBox(self.groupBox) + self.focstim_port.setObjectName(u"focstim_port") + + self.gridLayout.addWidget(self.focstim_port, 0, 1, 1, 1) + + self.firmware_path = QLineEdit(self.groupBox) + self.firmware_path.setObjectName(u"firmware_path") + + self.gridLayout.addWidget(self.firmware_path, 1, 1, 1, 1) + + self.refresh = QToolButton(self.groupBox) + self.refresh.setObjectName(u"refresh") + + self.gridLayout.addWidget(self.refresh, 0, 2, 1, 1) + + self.open = QToolButton(self.groupBox) + self.open.setObjectName(u"open") + + self.gridLayout.addWidget(self.open, 1, 2, 1, 1) + + + self.verticalLayout.addWidget(self.groupBox) + + self.label_3 = QLabel(FocStimFlashDialog) + self.label_3.setObjectName(u"label_3") + + self.verticalLayout.addWidget(self.label_3) + + self.pushButton = QPushButton(FocStimFlashDialog) + self.pushButton.setObjectName(u"pushButton") + + self.verticalLayout.addWidget(self.pushButton) + + self.textBrowser = QTextBrowser(FocStimFlashDialog) + self.textBrowser.setObjectName(u"textBrowser") + + self.verticalLayout.addWidget(self.textBrowser) + + + self.retranslateUi(FocStimFlashDialog) + + QMetaObject.connectSlotsByName(FocStimFlashDialog) + # setupUi + + def retranslateUi(self, FocStimFlashDialog): + FocStimFlashDialog.setWindowTitle(QCoreApplication.translate("FocStimFlashDialog", u"FOC-Stim firmware flasher", None)) + self.groupBox.setTitle(QCoreApplication.translate("FocStimFlashDialog", u"Settings", None)) + self.label.setText(QCoreApplication.translate("FocStimFlashDialog", u"Serial port", None)) + self.label_2.setText(QCoreApplication.translate("FocStimFlashDialog", u"Firmware", None)) + self.refresh.setText(QCoreApplication.translate("FocStimFlashDialog", u"Refresh", None)) + self.open.setText(QCoreApplication.translate("FocStimFlashDialog", u"Open...", None)) + self.label_3.setText(QCoreApplication.translate("FocStimFlashDialog", u"Only for FOC-Stim V4", None)) + self.pushButton.setText(QCoreApplication.translate("FocStimFlashDialog", u"Firmware update", None)) + # retranslateUi + diff --git a/qt_ui/main_window_ui.py b/qt_ui/main_window_ui.py index db40005..3649d62 100644 --- a/qt_ui/main_window_ui.py +++ b/qt_ui/main_window_ui.py @@ -84,6 +84,8 @@ def setupUi(self, MainWindow): self.actionSimfile_conversion.setObjectName(u"actionSimfile_conversion") self.actionFunscript_decomposition = QAction(MainWindow) self.actionFunscript_decomposition.setObjectName(u"actionFunscript_decomposition") + self.actionFirmware_updater = QAction(MainWindow) + self.actionFirmware_updater.setObjectName(u"actionFirmware_updater") self.centralwidget = QWidget(MainWindow) self.centralwidget.setObjectName(u"centralwidget") self.horizontalLayout = QHBoxLayout(self.centralwidget) @@ -254,6 +256,8 @@ def setupUi(self, MainWindow): self.menuTools.addAction(self.actionFunscript_conversion) self.menuTools.addAction(self.actionSimfile_conversion) self.menuTools.addAction(self.actionFunscript_decomposition) + self.menuTools.addSeparator() + self.menuTools.addAction(self.actionFirmware_updater) self.toolBar.addAction(self.actionControl) self.toolBar.addAction(self.actionMedia) self.toolBar.addAction(self.actionStart) @@ -292,6 +296,7 @@ def retranslateUi(self, MainWindow): #endif // QT_CONFIG(shortcut) self.actionSimfile_conversion.setText(QCoreApplication.translate("MainWindow", u"Simfile conversion", None)) self.actionFunscript_decomposition.setText(QCoreApplication.translate("MainWindow", u"Funscript decomposition", None)) + self.actionFirmware_updater.setText(QCoreApplication.translate("MainWindow", u"Firmware updater", None)) self.groupBox_volume.setTitle(QCoreApplication.translate("MainWindow", u"volume", None)) self.groupBox_pattern.setTitle(QCoreApplication.translate("MainWindow", u"Pattern generator", None)) self.comboBox_patternSelect.setItemText(0, QCoreApplication.translate("MainWindow", u"Mouse", None)) diff --git a/qt_ui/mainwindow.py b/qt_ui/mainwindow.py index 8746ae8..6adf27c 100644 --- a/qt_ui/mainwindow.py +++ b/qt_ui/mainwindow.py @@ -21,6 +21,7 @@ import net.tcpudpserver import qt_ui.funscript_conversion_dialog import qt_ui.simfile_conversion_dialog +import qt_ui.focstim_flash_dialog import qt_ui.funscript_decomposition_dialog import qt_ui.preferences_dialog import qt_ui.settings @@ -185,6 +186,9 @@ def __init__(self, parent=None): self.simfile_conversion_dialog = qt_ui.simfile_conversion_dialog.SimfileConversionDialog() self.actionSimfile_conversion.triggered.connect(self.open_simfile_conversion_dialog) + self.focstim_flash_dialog = qt_ui.focstim_flash_dialog.FocStimFlashDialog() + self.actionFirmware_updater.triggered.connect(self.open_focstim_flash_dialog) + self.funscript_decomposition_dialog = qt_ui.funscript_decomposition_dialog.FunscriptDecompositionDialog() self.actionFunscript_decomposition.triggered.connect(self.open_funscript_decomposition_dialog) @@ -509,6 +513,10 @@ def open_simfile_conversion_dialog(self): self.signal_stop(PlayState.STOPPED) self.simfile_conversion_dialog.exec() + def open_focstim_flash_dialog(self): + self.signal_stop(PlayState.STOPPED) + self.focstim_flash_dialog.exec() + def open_funscript_decomposition_dialog(self): self.signal_stop(PlayState.STOPPED) self.funscript_decomposition_dialog.exec() diff --git a/requirements.txt b/requirements.txt index 7db05c1..b810596 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,4 @@ 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 From d9fc3864ce2b0b74f038d150e691ef52be435450 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Fri, 17 Oct 2025 17:18:41 +0700 Subject: [PATCH 13/47] Stash EMA experiment --- device/coyote/algorithm.py | 68 +++++++++++++++++++++++++++++++++----- qt_ui/settings.py | 3 ++ 2 files changed, 63 insertions(+), 8 deletions(-) diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py index bbb4beb..a94d151 100644 --- a/device/coyote/algorithm.py +++ b/device/coyote/algorithm.py @@ -26,6 +26,11 @@ from stim_math.audio_gen.params import CoyoteAlgorithmParams, VolumeParams, SafetyParams from stim_math.audio_gen.various import ThreePhasePosition from device.coyote.device import CoyotePulse, CoyotePulses +try: + # Optional import; keeps algorithm functional in headless contexts + from qt_ui import settings as ui_settings +except Exception: # pragma: no cover - setting import is best-effort + ui_settings = None logger = logging.getLogger('restim.coyote') @@ -303,13 +308,8 @@ def get_pulse_at(self, current_time: float, base_intensity: float, pulse_index: # Debug log so we can see the modulation values print(f"[DEBUG] pulse={pulse_index} base={base_duration} swing={int(round(scaled_swing))} final={pulse_duration} depth={mod_depth_cycles:.2f}cycles phase={self.modulation_phase:.2f}") - # --- Intensity Modulation (subtle additive modulation) --- - # Apply subtle sine wave modulation as additive to positional intensity - # Use much smaller scaling (1% per cycle instead of 10%) - intensity_mod_depth = mod_depth_cycles * 0.01 # 1% per cycle for subtlety - raw_mod_value = np.sin(self.modulation_phase) # Continuous sine wave - modulation_amount = raw_mod_value * intensity_mod_depth * base_intensity - final_intensity = int(np.clip(base_intensity + modulation_amount, 1, 100)) + # Intensity is supplied by the caller (already smoothed/slewed). Do not modulate here. + final_intensity = int(np.clip(base_intensity, 1, 100)) return CoyotePulse( duration=pulse_duration, @@ -350,6 +350,20 @@ def __init__(self, media: AbstractMediaSync, params: CoyoteAlgorithmParams, safe self._cached_envelope = np.full(200, 0.5) # Default to a flat line self._cached_envelope_period = 0.0 + # Minimal per-channel intensity smoothing state + self._last_intensity_a = None # type: float | None + self._last_intensity_b = None # type: float | None + self._last_intensity_time_a = None # type: float | None + self._last_intensity_time_b = None # type: float | None + + # Global per-pulse cap (percentage points). Read from settings if available. + self._max_change_per_pulse = 3.0 + try: + if ui_settings is not None: + self._max_change_per_pulse = float(ui_settings.coyote_max_intensity_change_per_pulse.get()) + except Exception: + pass + def _get_positional_intensities(self, t: float, volume: float) -> Tuple[int, int]: """Barycentric phase diagram mapping: (beta, alpha) with left=+1, right=-1, neutral=+1 (top).""" alpha, beta = self.position.get_position(t) @@ -389,7 +403,45 @@ def _generate_channel_pulses(self, t: float, signal: 'ContinuousSignal', intensity_a, intensity_b = self._get_positional_intensities(t_pulse, volume) # Select appropriate intensity based on channel - intensity = intensity_a if channel_name == 'A' else intensity_b + target_intensity = float(intensity_a if channel_name == 'A' else intensity_b) + + # Convert pulse_rise_time (carrier cycles) to a time constant (seconds) + carrier_hz = float(self.params.carrier_frequency.interpolate(t_pulse)) + rise_cycles = float(self.params.pulse_rise_time.interpolate(t_pulse)) + tau_s = 0.0 if carrier_hz <= 0 else (rise_cycles / carrier_hz) + + # Minimal EMA smoothing of intensity (no extra magic numbers) + if channel_name == 'A': + last_y = self._last_intensity_a + last_t = self._last_intensity_time_a + if last_y is None or tau_s <= 0 or last_t is None: + y = target_intensity + else: + dt = max(0.0, t_pulse - last_t) + # Slew limit: base on tau, also cap by global per-pulse limit + allowed = (dt / tau_s) * 100.0 + if self._max_change_per_pulse > 0: + allowed = min(allowed, self._max_change_per_pulse) + delta = np.clip(target_intensity - last_y, -allowed, allowed) + y = last_y + delta + self._last_intensity_a = y + self._last_intensity_time_a = t_pulse + else: + last_y = self._last_intensity_b + last_t = self._last_intensity_time_b + if last_y is None or tau_s <= 0 or last_t is None: + y = target_intensity + else: + dt = max(0.0, t_pulse - last_t) + allowed = (dt / tau_s) * 100.0 + if self._max_change_per_pulse > 0: + allowed = min(allowed, self._max_change_per_pulse) + delta = np.clip(target_intensity - last_y, -allowed, allowed) + y = last_y + delta + self._last_intensity_b = y + self._last_intensity_time_b = t_pulse + + intensity = int(np.clip(round(y), 0, 100)) pulse = signal.get_pulse_at(t_pulse, intensity, i + 1) pulses.append(pulse) diff --git a/qt_ui/settings.py b/qt_ui/settings.py index bff295e..ef22e48 100644 --- a/qt_ui/settings.py +++ b/qt_ui/settings.py @@ -173,3 +173,6 @@ def set(self, value): coyote_channel_b_strength_max = Setting("coyote/channel_b_strength_max", 100, int) coyote_channel_b_freq_min = Setting("coyote/channel_b_freq_min", 20, int) coyote_channel_b_freq_max = Setting("coyote/channel_b_freq_max", 50, int) + +# Coyote smoothing: maximum allowed intensity change per pulse (percentage points) +coyote_max_intensity_change_per_pulse = Setting("coyote/max_intensity_change_per_pulse", 3.0, float) From 3d987ec6311012f5f20cc4e3976b05aadc9f0318 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Fri, 17 Oct 2025 19:25:57 +0700 Subject: [PATCH 14/47] Coyote: anchor pulse rate to funscript pulse_frequency; apply optional jitter; simplify envelope preview. Remove full-range LFO sweep; keep per-pulse intensity smoothing. --- device/coyote/algorithm.py | 236 +++++++------------------------------ 1 file changed, 44 insertions(+), 192 deletions(-) diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py index a94d151..8513603 100644 --- a/device/coyote/algorithm.py +++ b/device/coyote/algorithm.py @@ -136,17 +136,17 @@ def _get_normalized_parameters(params: CoyoteAlgorithmParams, t: float, class ContinuousSignal: - """Models a single channel's pulse generation using symmetric ramp envelopes. - - Generates pulses whose frequency *and intensity* are driven by a shared sine-wave LFO. - The LFO parameters come from the `pulse_*` axes so UI preview and runtime stay in sync. - Base intensity from volume/position is multiplied by an LFO factor (±50 % max) to - create a smooth pulsing effect while frequency receives the same LFO swing. + """Models a single channel's pulse generation. + + Revised to keep the pulse frequency anchored to the funscript/UI + `pulse_frequency` axis (with optional jitter), and to stop sweeping + across the full min/max range. This preserves the intention of + funscripts more faithfully while staying within hardware limits. """ - + ENVELOPE_RESOLUTION = 200 # Number of points in envelope lookup table - + def __init__(self, params: CoyoteAlgorithmParams, channel_params: 'CoyoteChannelParams', 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]): @@ -198,123 +198,46 @@ def _apply_frequency_randomization(self, base_frequency: float, randomization_st return np.clip(randomized_freq, min_freq, max_freq) def get_pulse_at(self, current_time: float, base_intensity: float, pulse_index: int = 0) -> CoyotePulse: - """Generate a pulse using sine-wave frequency modulation. - - - carrier_frequency: Sets the base frequency. - - pulse_frequency: Controls the speed of the frequency modulation (modulation frequency). - - pulse_width: Controls the depth of the frequency modulation. - - pulse_rise_time: Adds jitter/randomness to the frequency. - - intensity: Starts with volume × position distribution and is then multiplied by the - same sine-wave factor used for frequency, giving ±50 % pulsing around the base value. + """Generate a pulse anchored to the requested pulse_frequency with optional jitter. + + - pulse_frequency: controls the base repetition rate (Hz) + - pulse_interval_random: ±fractional jitter of the base duration + - intensity: provided by caller (volume × position, smoothed elsewhere) """ - # --- Timing and State Update --- + # Initialize timing state if self._start_time is None: self._start_time = current_time - delta_time = current_time - self._last_pulse_time self._last_pulse_time = current_time - # Advance modulation phase by the elapsed time since the previous pulse - _, pulse_freq_norm, _, _ = _get_normalized_parameters( - self.params, current_time, self.carrier_freq_limits, self.pulse_freq_limits) - phase_increment = delta_time * 2 * np.pi * (pulse_freq_norm / 100.0) * 10.0 # same scaling as scheduler - self.modulation_phase += phase_increment - - # --- Get Parameters --- - # carrier_frequency: raw Hz value (normalized to 0-100% range) - # pulse_frequency: raw Hz value (normalized to 0-100% range) - # pulse_width: raw carrier cycles (used directly) - # pulse_rise_time: raw carrier cycles (used directly) - carrier_freq_norm, mod_speed_norm, _, _ = _get_normalized_parameters( - self.params, current_time, self.carrier_freq_limits, self.pulse_freq_limits) - - # Use raw carrier cycle values directly - mod_depth_cycles = self.params.pulse_width.interpolate(current_time) - jitter_cycles = self.params.pulse_rise_time.interpolate(current_time) - - # Debug parameter values - print(f"[DEBUG] RAW: carrier={self.params.carrier_frequency.interpolate(current_time):.2f}Hz " - f"pulse_freq={self.params.pulse_frequency.interpolate(current_time):.2f}Hz " - f"pulse_width={mod_depth_cycles:.2f}cycles " - f"pulse_rise_time={jitter_cycles:.2f}cycles") - print(f"[DEBUG] NORMALIZED: carrier={carrier_freq_norm:.1f}% pulse_freq={mod_speed_norm:.1f}%") - randomization_strength = self.params.pulse_interval_random.interpolate(current_time) - - # --- Base Frequency Calculation --- + # Effective channel limits → convert to duration window min_freq, max_freq = self._calculate_effective_frequency_limits() - base_frequency = min_freq + (carrier_freq_norm / 100.0) * (max_freq - min_freq) - base_frequency = np.clip(base_frequency, min_freq, max_freq) - - # --- Duration Modulation (Sine Wave) --- - # We now drive duration directly so every pulse has at least 1 ms difference. - base_duration = int(1000.0 / base_frequency) - # Channel-specific duration limits derived from frequency limits min_dur = max(COYOTE_MIN_PULSE_DURATION, int(round(1000.0 / max_freq))) max_dur = min(COYOTE_MAX_PULSE_DURATION, int(round(1000.0 / min_freq))) - - # --- Duration Modulation (Sine Wave) --- - # Use raw carrier cycles directly for modulation depth - # Each cycle = 1ms swing, so 5 cycles = 5ms swing - # This gives us the full range based on actual cycle count - - mod_value = np.sin(self.modulation_phase) - - # Use the full duration range for maximum swing - base_duration = int(round((min_dur + max_dur) / 2)) # centre of the channel range - max_swing = max_dur - base_duration # distance to max boundary - min_swing = min_dur - base_duration # distance to min boundary - - # Ensure full frequency range coverage from min_dur to max_dur - # Map the sine wave directly to the full duration range - - # Calculate the actual swing needed to reach boundaries - # Use the actual distances to min and max boundaries - if mod_value >= 0: - # Positive modulation: scale to max boundary - scaled_swing = max_swing * mod_value - else: - # Negative modulation: scale to min boundary - scaled_swing = min_swing * mod_value - - # The raw cycles are now used for intensity modulation, not range limitation - - # Ensure we hit the exact boundaries - if mod_value >= 0.98: # near maximum - scaled_swing = max_swing - elif mod_value <= -0.98: # near minimum - scaled_swing = min_swing - - # Allow swing to use the full available range regardless of cycle count - # The cycle count now determines modulation depth, not range limitation - - pulse_duration = base_duration + int(round(scaled_swing)) - # Debug log every value for investigation - print(f"[DEBUG] freq_limits: min={min_freq:.1f} max={max_freq:.1f}") - print(f"[DEBUG] dur_limits: min={min_dur} max={max_dur}") - print(f"[DEBUG] base={base_duration} min_swing={min_swing:.1f} max_swing={max_swing:.1f} depth={mod_depth_cycles:.2f}cycles") - print(f"[DEBUG] mod_value={mod_value:.2f} scaled_swing={scaled_swing:.1f} final_dur={pulse_duration}") - print(f"[DEBUG] final_freq={int(1000.0/pulse_duration)} phase={self.modulation_phase:.2f}") - print("---") + # Base frequency from UI/funscript axis, clamped by channel limits + requested_freq = float(self.params.pulse_frequency.interpolate(current_time)) + requested_freq = float(np.clip(requested_freq, min_freq, max_freq)) + base_duration = 1000.0 / requested_freq if requested_freq > 0 else max_dur - # Ensure we stay within the channel-specific duration window - pulse_duration = int(np.clip(pulse_duration, min_dur, max_dur)) + # Optional jitter around base duration + jitter = float(self.params.pulse_interval_random.interpolate(current_time)) + # Treat jitter as a 0..1 fraction; clamp to ±50% to avoid pathological spans + jitter = float(np.clip(jitter, 0.0, 0.5)) + jitter_factor = 1.0 + (np.random.rand() * 2.0 - 1.0) * jitter + pulse_duration = int(round(base_duration * jitter_factor)) - # Clip to hardware limits - pulse_duration = np.clip(pulse_duration, COYOTE_MIN_PULSE_DURATION, COYOTE_MAX_PULSE_DURATION) - - # Derive frequency only for diagnostics - final_frequency = int(1000.0 / pulse_duration) + # Clamp to channel-specific duration window and hardware bounds + pulse_duration = int(np.clip(pulse_duration, min_dur, max_dur)) - # Debug log so we can see the modulation values - print(f"[DEBUG] pulse={pulse_index} base={base_duration} swing={int(round(scaled_swing))} final={pulse_duration} depth={mod_depth_cycles:.2f}cycles phase={self.modulation_phase:.2f}") + final_frequency = int(max(1, round(1000.0 / pulse_duration))) - # Intensity is supplied by the caller (already smoothed/slewed). Do not modulate here. - final_intensity = int(np.clip(base_intensity, 1, 100)) + # Intensity is supplied by the caller (already smoothed). Do not modulate here. + final_intensity = int(np.clip(base_intensity, 0, 100)) - return CoyotePulse( + return CoyotePulse( duration=pulse_duration, - intensity=final_intensity, - frequency=final_frequency + intensity=final_intensity, + frequency=final_frequency, ) @@ -561,43 +484,15 @@ def _log_packet_debug(self, current_time: float, alpha: float, beta: float, logger.debug("\n".join(log_lines)) def _update_envelope_preview(self, t: float): - """ - Generates and caches the UI envelope preview. - This must be called from the same state as pulse generation to ensure sync. - """ - # Get normalized modulation speed and depth - _, pulse_freq, pulse_width, _ = _get_normalized_parameters( - self.params, t, self.signal_a.carrier_freq_limits, self.signal_a.pulse_freq_limits) - - # The period is the inverse of the modulation frequency (speed). - self._cached_envelope_period = 1.0 / pulse_freq if pulse_freq > 0 else 0.0 + """Generate and cache a simple flat envelope synced to pulse_frequency. - # This visualization must precisely match the runtime logic in get_pulse_at. + The revised runtime no longer modulates frequency by a sine wave, so the + preview reflects a steady state: flat line with period = 1/pulse_frequency. + """ num_points = 200 - modulation_depth = pulse_width # This is already normalized to [0, 1] - - # To sync the preview with the dots, we must use the phase of the *next* packet. - # The state has already been advanced for the current time `t` in generate_packet. - current_phase = self.signal_a.modulation_phase - - # Generate the sine wave for the plot starting from the current phase. - x = np.linspace(current_phase, current_phase + 2 * np.pi, num_points) - modulation_wave = np.sin(x) # Range [-1, 1] - - # Calculate the frequency multiplier, exactly as in get_pulse_at. - frequency_multiplier = 1.0 + modulation_wave * modulation_depth - - # Normalize the multiplier from its runtime range to the [0, 1] range for the UI. - min_mult = 1.0 - modulation_depth - max_mult = 1.0 + modulation_depth - range_mult = max_mult - min_mult - - if range_mult == 0: - # If there's no modulation, the envelope is flat at the midpoint. - self._cached_envelope = np.full(num_points, 0.5) - return - - self._cached_envelope = (frequency_multiplier - min_mult) / range_mult + freq = float(self.params.pulse_frequency.interpolate(t)) + self._cached_envelope_period = 1.0 / freq if freq > 0 else 0.0 + self._cached_envelope = np.full(num_points, 0.5) def generate_packet(self, current_time: float) -> CoyotePulses: """Generate one packet of pulses for both channels.""" @@ -643,52 +538,9 @@ def get_next_update_time(self) -> float: return self.next_update_time def get_envelope_data(self) -> Tuple[np.ndarray, float]: - """Returns the current envelope shape and its period for UI visualization.""" + """Return the current flat envelope and its period for UI visualization.""" t = time.time() - - # Get normalized modulation speed and depth - # Note: We no longer need carrier_freq or rise_time for the preview - _, pulse_freq, pulse_width, _ = _get_normalized_parameters( - self.params, t, self.signal_a.carrier_freq_limits, self.signal_a.pulse_freq_limits) - - # The UI envelope now visualizes the sine-wave frequency modulation. - # The period is the inverse of the modulation frequency (speed). - period = 1.0 / pulse_freq if pulse_freq > 0 else 0.0 - - # This visualization must precisely match the runtime logic in get_pulse_at. + freq = float(self.params.pulse_frequency.interpolate(t)) + period = 1.0 / freq if freq > 0 else 0.0 num_points = 200 - modulation_depth = pulse_width # This is already normalized to [0, 1] - - # To sync the preview with the dots, we must predict the phase for the *next* packet. - # This involves simulating the state advancement that happens at the start of generate_packet(). - t = time.time() - delta_time_ms = (t - self.last_update_time_s) * 1000.0 - _, pulse_freq, _, _ = _get_normalized_parameters( - self.params, t, self.signal_a.carrier_freq_limits, self.signal_a.pulse_freq_limits) - - # 1. Predict the phase increment. - phase_increment = (delta_time_ms / 1000.0) * pulse_freq * 2 * np.pi - - # 2. Calculate the predicted starting phase for the next packet. - predicted_phase = self.signal_a.modulation_phase + phase_increment - - # 3. Generate the sine wave for the plot starting from the predicted phase. - x = np.linspace(predicted_phase, predicted_phase + 2 * np.pi, num_points) - modulation_wave = np.sin(x) # Range [-1, 1] - - # 2. Calculate the frequency multiplier, exactly as in get_pulse_at. - # The result is in the range [1 - depth, 1 + depth]. - frequency_multiplier = 1.0 + modulation_wave * modulation_depth - - # 3. Normalize the multiplier from its runtime range to the [0, 1] range for the UI. - min_mult = 1.0 - modulation_depth - max_mult = 1.0 + modulation_depth - range_mult = max_mult - min_mult - - if range_mult == 0: - # If there's no modulation, the envelope is flat at the midpoint. - return np.full(num_points, 0.5), period - - envelope = (frequency_multiplier - min_mult) / range_mult - - return envelope, period + return np.full(num_points, 0.5), period From 2f01ef8335b78b859736a8004a500fdeadb10d4a Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Fri, 17 Oct 2025 19:33:06 +0700 Subject: [PATCH 15/47] Coyote: keep debug prints; add pulse-generation trace and update channel state with set_new_packet for readiness. No behavior change to scheduling. --- device/coyote/algorithm.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py index 8513603..7d84981 100644 --- a/device/coyote/algorithm.py +++ b/device/coyote/algorithm.py @@ -234,6 +234,17 @@ def get_pulse_at(self, current_time: float, base_intensity: float, pulse_index: # Intensity is supplied by the caller (already smoothed). Do not modulate here. final_intensity = int(np.clip(base_intensity, 0, 100)) + # Debug: trace pulse generation values (do not remove) + try: + print( + f"[DEBUG] COYOTE get_pulse_at: t={current_time:.3f} idx={pulse_index} " + f"req_freq={requested_freq:.2f}Hz limits=({min_freq:.1f},{max_freq:.1f})Hz " + f"dur_limits=({min_dur},{max_dur})ms base_dur={base_duration:.1f}ms jitter={jitter:.2f} " + f"final_dur={pulse_duration}ms final_freq={final_frequency}Hz intensity={final_intensity}%" + ) + except Exception: + pass + return CoyotePulse( duration=pulse_duration, intensity=final_intensity, @@ -525,6 +536,13 @@ def generate_packet(self, current_time: float) -> CoyotePulses: pulses_a, duration_a = self._generate_channel_pulses(current_time, self.signal_a, 'A') pulses_b, duration_b = self._generate_channel_pulses(current_time, self.signal_b, 'B') + # Update channel states so readiness reflects packet progress + try: + self.channel_a.set_new_packet(current_time, pulses_a, duration_a / 1000.0) + self.channel_b.set_new_packet(current_time, pulses_b, duration_b / 1000.0) + except Exception: + pass + # Log debug information self._log_packet_debug(current_time, alpha, beta, pulses_a, pulses_b, duration_a, duration_b) From 16087386e7cbb3dec3374d06dcb99df909b5926d Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Fri, 17 Oct 2025 19:38:09 +0700 Subject: [PATCH 16/47] Coyote: add per-channel pulse queues and packet assembler. Fill ahead by horizon, pop 4 pulses per packet, atomic A+B updates. Keep debug prints and proactive scheduling when queues low. --- device/coyote/algorithm.py | 120 +++++++++++++++++++++++++++++++++++-- 1 file changed, 114 insertions(+), 6 deletions(-) diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py index 7d84981..6bd9097 100644 --- a/device/coyote/algorithm.py +++ b/device/coyote/algorithm.py @@ -280,6 +280,13 @@ def __init__(self, media: AbstractMediaSync, params: CoyoteAlgorithmParams, safe self.last_update_time_s = 0.0 self.next_update_time = 0.0 + # Per-channel pulse queues and horizons + self.queue_a: Deque[CoyotePulse] = deque() + self.queue_b: Deque[CoyotePulse] = deque() + self.queue_end_time_a: float | None = None + self.queue_end_time_b: float | None = None + self.queue_horizon_s: float = 0.75 + # UI Preview Cache self._cached_envelope = np.full(200, 0.5) # Default to a flat line self._cached_envelope_period = 0.0 @@ -298,6 +305,90 @@ def __init__(self, media: AbstractMediaSync, params: CoyoteAlgorithmParams, safe except Exception: pass + def _generate_single_pulse(self, t_pulse: float, signal: 'ContinuousSignal', channel_name: str, + seq_index: int) -> CoyotePulse: + volume = compute_volume(self.media, self.params.volume, t_pulse) + intensity_a, intensity_b = self._get_positional_intensities(t_pulse, volume) + target_intensity = float(intensity_a if channel_name == 'A' else intensity_b) + + carrier_hz = float(self.params.carrier_frequency.interpolate(t_pulse)) + rise_cycles = float(self.params.pulse_rise_time.interpolate(t_pulse)) + tau_s = 0.0 if carrier_hz <= 0 else (rise_cycles / carrier_hz) + + if channel_name == 'A': + last_y = self._last_intensity_a + last_t = self._last_intensity_time_a + if last_y is None or tau_s <= 0 or last_t is None: + y = target_intensity + else: + dt = max(0.0, t_pulse - last_t) + allowed = (dt / tau_s) * 100.0 + if self._max_change_per_pulse > 0: + allowed = min(allowed, self._max_change_per_pulse) + delta = np.clip(target_intensity - last_y, -allowed, allowed) + y = last_y + delta + self._last_intensity_a = y + self._last_intensity_time_a = t_pulse + else: + last_y = self._last_intensity_b + last_t = self._last_intensity_time_b + if last_y is None or tau_s <= 0 or last_t is None: + y = target_intensity + else: + dt = max(0.0, t_pulse - last_t) + allowed = (dt / tau_s) * 100.0 + if self._max_change_per_pulse > 0: + allowed = min(allowed, self._max_change_per_pulse) + delta = np.clip(target_intensity - last_y, -allowed, allowed) + y = last_y + delta + self._last_intensity_b = y + self._last_intensity_time_b = t_pulse + + intensity = int(np.clip(round(y), 0, 100)) + pulse = signal.get_pulse_at(t_pulse, intensity, seq_index) + return pulse + + def _fill_channel_queue(self, now_s: float, channel_name: str, signal: 'ContinuousSignal') -> None: + if channel_name == 'A': + q = self.queue_a + end_time = self.queue_end_time_a if self.queue_end_time_a is not None else now_s + else: + q = self.queue_b + end_time = self.queue_end_time_b if self.queue_end_time_b is not None else now_s + + horizon_end = now_s + self.queue_horizon_s + seq_index = 0 + while end_time < horizon_end or len(q) < 4: + pulse = self._generate_single_pulse(end_time, signal, channel_name, seq_index) + q.append(pulse) + end_time += pulse.duration / 1000.0 + seq_index += 1 + + if channel_name == 'A': + self.queue_end_time_a = end_time + else: + self.queue_end_time_b = end_time + + try: + print(f"[DEBUG] COYOTE fill_queue {channel_name}: size={len(q)} until={end_time-now_s:.3f}s horizon={self.queue_horizon_s:.3f}s") + except Exception: + pass + + def _pop_packet_from_queue(self, channel_name: str) -> List[CoyotePulse]: + if channel_name == 'A': + q = self.queue_a + else: + q = self.queue_b + + packet: List[CoyotePulse] = [] + while len(packet) < COYOTE_PULSES_PER_PACKET: + if q: + packet.append(q.popleft()) + else: + # Fallback safe pulse + packet.append(CoyotePulse(frequency=0, intensity=0, duration=COYOTE_MIN_PULSE_DURATION)) + return packet + def _get_positional_intensities(self, t: float, volume: float) -> Tuple[int, int]: """Barycentric phase diagram mapping: (beta, alpha) with left=+1, right=-1, neutral=+1 (top).""" alpha, beta = self.position.get_position(t) @@ -454,15 +545,26 @@ def _advance_channel_states(self, current_time: float, delta_time_ms: float) -> self.signal_b.modulation_phase = (self.signal_b.modulation_phase + phase_change) % (2 * np.pi) def _is_packet_generation_needed(self) -> bool: - """Check if either channel needs a new packet.""" - return (self.channel_a.is_ready_for_next_packet() or - self.channel_b.is_ready_for_next_packet()) + """Check if either channel needs a new packet. + + We also allow proactive generation if queues are running low, to avoid + starving the device with repeats for too long. + """ + low_queue = (len(self.queue_a) < COYOTE_PULSES_PER_PACKET or + len(self.queue_b) < COYOTE_PULSES_PER_PACKET) + ready = (self.channel_a.is_ready_for_next_packet() or + self.channel_b.is_ready_for_next_packet()) + return ready or low_queue def _schedule_next_update(self, current_time: float, packet_duration_a: float, packet_duration_b: float, margin: float = 0.8) -> None: """Schedule the next update time based on packet durations.""" min_duration = min(packet_duration_a, packet_duration_b) self.next_update_time = current_time + min_duration * margin + try: + print(f"[DEBUG] COYOTE schedule: next_update in {min_duration*margin:.3f}s (A={packet_duration_a:.3f}s B={packet_duration_b:.3f}s)") + except Exception: + pass def _log_packet_debug(self, current_time: float, alpha: float, beta: float, pulses_a: List[CoyotePulse], pulses_b: List[CoyotePulse], @@ -531,10 +633,16 @@ def generate_packet(self, current_time: float) -> CoyotePulses: # Update the UI preview cache from the current state self._update_envelope_preview(current_time) - # Generate pulses for both channels + # Ensure queues are filled ahead of time + self._fill_channel_queue(current_time, 'A', self.signal_a) + self._fill_channel_queue(current_time, 'B', self.signal_b) + + # Assemble packets by popping from queues (atomic update for A and B) alpha, beta = self.position.get_position(current_time) - pulses_a, duration_a = self._generate_channel_pulses(current_time, self.signal_a, 'A') - pulses_b, duration_b = self._generate_channel_pulses(current_time, self.signal_b, 'B') + pulses_a = self._pop_packet_from_queue('A') + pulses_b = self._pop_packet_from_queue('B') + duration_a = sum(p.duration for p in pulses_a) + duration_b = sum(p.duration for p in pulses_b) # Update channel states so readiness reflects packet progress try: From 6dd9d1c6bffb6e71e7e94dd07db6c736b589e96f Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Fri, 17 Oct 2025 19:43:44 +0700 Subject: [PATCH 17/47] Coyote: refactor channel-specific logic into ChannelController to eliminate A/B duplication. Controllers manage queues, smoothing, and packet assembly; algorithm orchestrates and schedules. Keep debug prints. --- device/coyote/algorithm.py | 262 +++++++++++++++---------------------- 1 file changed, 104 insertions(+), 158 deletions(-) diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py index 6bd9097..f67566b 100644 --- a/device/coyote/algorithm.py +++ b/device/coyote/algorithm.py @@ -254,6 +254,88 @@ def get_pulse_at(self, current_time: float, base_intensity: float, pulse_index: +class ChannelController: + """Encapsulates per-channel queuing, smoothing, and packet assembly.""" + + def __init__(self, + name: str, + media: AbstractMediaSync, + params: CoyoteAlgorithmParams, + signal: ContinuousSignal, + get_positional_intensities, + max_change_per_pulse: float, + queue_horizon_s: float = 0.75): + self.name = name # 'A' or 'B' + self.media = media + self.params = params + self.signal = signal + self.get_positional_intensities = get_positional_intensities + self.max_change_per_pulse = max_change_per_pulse + self.queue_horizon_s = queue_horizon_s + + self.queue: Deque[CoyotePulse] = deque() + self.queue_end_time: float | None = None + + # Smoothing state + self._last_intensity: float | None = None + self._last_intensity_time: float | None = None + + def _generate_single_pulse(self, t_pulse: float, seq_index: int) -> CoyotePulse: + volume = compute_volume(self.media, self.params.volume, t_pulse) + ia, ib = self.get_positional_intensities(t_pulse, volume) + target_intensity = float(ia if self.name == 'A' else ib) + + carrier_hz = float(self.params.carrier_frequency.interpolate(t_pulse)) + rise_cycles = float(self.params.pulse_rise_time.interpolate(t_pulse)) + tau_s = 0.0 if carrier_hz <= 0 else (rise_cycles / carrier_hz) + + last_y = self._last_intensity + last_t = self._last_intensity_time + if last_y is None or tau_s <= 0 or last_t is None: + y = target_intensity + else: + dt = max(0.0, t_pulse - last_t) + allowed = (dt / tau_s) * 100.0 + if self.max_change_per_pulse > 0: + allowed = min(allowed, self.max_change_per_pulse) + delta = np.clip(target_intensity - last_y, -allowed, allowed) + y = last_y + delta + + self._last_intensity = y + self._last_intensity_time = t_pulse + + intensity = int(np.clip(round(y), 0, 100)) + pulse = self.signal.get_pulse_at(t_pulse, intensity, seq_index) + return pulse + + def fill_queue(self, now_s: float) -> None: + end_time = self.queue_end_time if self.queue_end_time is not None else now_s + horizon_end = now_s + self.queue_horizon_s + seq_index = 0 + while end_time < horizon_end or len(self.queue) < COYOTE_PULSES_PER_PACKET: + pulse = self._generate_single_pulse(end_time, seq_index) + self.queue.append(pulse) + end_time += pulse.duration / 1000.0 + seq_index += 1 + + self.queue_end_time = end_time + try: + print(f"[DEBUG] COYOTE fill_queue {self.name}: size={len(self.queue)} until={end_time-now_s:.3f}s horizon={self.queue_horizon_s:.3f}s") + except Exception: + pass + + def pop_packet(self) -> List[CoyotePulse]: + packet: List[CoyotePulse] = [] + while len(packet) < COYOTE_PULSES_PER_PACKET: + if self.queue: + packet.append(self.queue.popleft()) + else: + packet.append(CoyotePulse(frequency=0, intensity=0, duration=COYOTE_MIN_PULSE_DURATION)) + return packet + + def has_minimum_pulses(self, n: int) -> bool: + return len(self.queue) >= n + class CoyoteAlgorithm: """Coyote 3.0 pulse generation algorithm with symmetric ramp envelope modulation. @@ -280,22 +362,25 @@ def __init__(self, media: AbstractMediaSync, params: CoyoteAlgorithmParams, safe self.last_update_time_s = 0.0 self.next_update_time = 0.0 - # Per-channel pulse queues and horizons - self.queue_a: Deque[CoyotePulse] = deque() - self.queue_b: Deque[CoyotePulse] = deque() - self.queue_end_time_a: float | None = None - self.queue_end_time_b: float | None = None - self.queue_horizon_s: float = 0.75 + # Per-channel controllers (queues, smoothing, assembly) + self.ctrl_a = ChannelController( + 'A', self.media, self.params, self.signal_a, + get_positional_intensities=self._get_positional_intensities, + max_change_per_pulse=self._max_change_per_pulse, + queue_horizon_s=0.75, + ) + self.ctrl_b = ChannelController( + 'B', self.media, self.params, self.signal_b, + get_positional_intensities=self._get_positional_intensities, + max_change_per_pulse=self._max_change_per_pulse, + queue_horizon_s=0.75, + ) # UI Preview Cache self._cached_envelope = np.full(200, 0.5) # Default to a flat line self._cached_envelope_period = 0.0 - # Minimal per-channel intensity smoothing state - self._last_intensity_a = None # type: float | None - self._last_intensity_b = None # type: float | None - self._last_intensity_time_a = None # type: float | None - self._last_intensity_time_b = None # type: float | None + # Smoothing state handled by ChannelController # Global per-pulse cap (percentage points). Read from settings if available. self._max_change_per_pulse = 3.0 @@ -305,89 +390,7 @@ def __init__(self, media: AbstractMediaSync, params: CoyoteAlgorithmParams, safe except Exception: pass - def _generate_single_pulse(self, t_pulse: float, signal: 'ContinuousSignal', channel_name: str, - seq_index: int) -> CoyotePulse: - volume = compute_volume(self.media, self.params.volume, t_pulse) - intensity_a, intensity_b = self._get_positional_intensities(t_pulse, volume) - target_intensity = float(intensity_a if channel_name == 'A' else intensity_b) - - carrier_hz = float(self.params.carrier_frequency.interpolate(t_pulse)) - rise_cycles = float(self.params.pulse_rise_time.interpolate(t_pulse)) - tau_s = 0.0 if carrier_hz <= 0 else (rise_cycles / carrier_hz) - - if channel_name == 'A': - last_y = self._last_intensity_a - last_t = self._last_intensity_time_a - if last_y is None or tau_s <= 0 or last_t is None: - y = target_intensity - else: - dt = max(0.0, t_pulse - last_t) - allowed = (dt / tau_s) * 100.0 - if self._max_change_per_pulse > 0: - allowed = min(allowed, self._max_change_per_pulse) - delta = np.clip(target_intensity - last_y, -allowed, allowed) - y = last_y + delta - self._last_intensity_a = y - self._last_intensity_time_a = t_pulse - else: - last_y = self._last_intensity_b - last_t = self._last_intensity_time_b - if last_y is None or tau_s <= 0 or last_t is None: - y = target_intensity - else: - dt = max(0.0, t_pulse - last_t) - allowed = (dt / tau_s) * 100.0 - if self._max_change_per_pulse > 0: - allowed = min(allowed, self._max_change_per_pulse) - delta = np.clip(target_intensity - last_y, -allowed, allowed) - y = last_y + delta - self._last_intensity_b = y - self._last_intensity_time_b = t_pulse - - intensity = int(np.clip(round(y), 0, 100)) - pulse = signal.get_pulse_at(t_pulse, intensity, seq_index) - return pulse - - def _fill_channel_queue(self, now_s: float, channel_name: str, signal: 'ContinuousSignal') -> None: - if channel_name == 'A': - q = self.queue_a - end_time = self.queue_end_time_a if self.queue_end_time_a is not None else now_s - else: - q = self.queue_b - end_time = self.queue_end_time_b if self.queue_end_time_b is not None else now_s - - horizon_end = now_s + self.queue_horizon_s - seq_index = 0 - while end_time < horizon_end or len(q) < 4: - pulse = self._generate_single_pulse(end_time, signal, channel_name, seq_index) - q.append(pulse) - end_time += pulse.duration / 1000.0 - seq_index += 1 - - if channel_name == 'A': - self.queue_end_time_a = end_time - else: - self.queue_end_time_b = end_time - - try: - print(f"[DEBUG] COYOTE fill_queue {channel_name}: size={len(q)} until={end_time-now_s:.3f}s horizon={self.queue_horizon_s:.3f}s") - except Exception: - pass - - def _pop_packet_from_queue(self, channel_name: str) -> List[CoyotePulse]: - if channel_name == 'A': - q = self.queue_a - else: - q = self.queue_b - - packet: List[CoyotePulse] = [] - while len(packet) < COYOTE_PULSES_PER_PACKET: - if q: - packet.append(q.popleft()) - else: - # Fallback safe pulse - packet.append(CoyotePulse(frequency=0, intensity=0, duration=COYOTE_MIN_PULSE_DURATION)) - return packet + # Channel-specific pulse generation and queues moved to ChannelController def _get_positional_intensities(self, t: float, volume: float) -> Tuple[int, int]: """Barycentric phase diagram mapping: (beta, alpha) with left=+1, right=-1, neutral=+1 (top).""" @@ -416,64 +419,7 @@ def _get_positional_intensities(self, t: float, volume: float) -> Tuple[int, int return intensity_a, intensity_b - def _generate_channel_pulses(self, t: float, signal: 'ContinuousSignal', - channel_name: str) -> Tuple[List[CoyotePulse], float]: - """Generate pulses for a single channel, eliminating code duplication.""" - pulses: List[CoyotePulse] = [] - time_advanced_in_packet_ms = 0.0 - - for i in range(COYOTE_PULSES_PER_PACKET): - t_pulse = t + (time_advanced_in_packet_ms / 1000.0) - volume = compute_volume(self.media, self.params.volume, t_pulse) - intensity_a, intensity_b = self._get_positional_intensities(t_pulse, volume) - - # Select appropriate intensity based on channel - target_intensity = float(intensity_a if channel_name == 'A' else intensity_b) - - # Convert pulse_rise_time (carrier cycles) to a time constant (seconds) - carrier_hz = float(self.params.carrier_frequency.interpolate(t_pulse)) - rise_cycles = float(self.params.pulse_rise_time.interpolate(t_pulse)) - tau_s = 0.0 if carrier_hz <= 0 else (rise_cycles / carrier_hz) - - # Minimal EMA smoothing of intensity (no extra magic numbers) - if channel_name == 'A': - last_y = self._last_intensity_a - last_t = self._last_intensity_time_a - if last_y is None or tau_s <= 0 or last_t is None: - y = target_intensity - else: - dt = max(0.0, t_pulse - last_t) - # Slew limit: base on tau, also cap by global per-pulse limit - allowed = (dt / tau_s) * 100.0 - if self._max_change_per_pulse > 0: - allowed = min(allowed, self._max_change_per_pulse) - delta = np.clip(target_intensity - last_y, -allowed, allowed) - y = last_y + delta - self._last_intensity_a = y - self._last_intensity_time_a = t_pulse - else: - last_y = self._last_intensity_b - last_t = self._last_intensity_time_b - if last_y is None or tau_s <= 0 or last_t is None: - y = target_intensity - else: - dt = max(0.0, t_pulse - last_t) - allowed = (dt / tau_s) * 100.0 - if self._max_change_per_pulse > 0: - allowed = min(allowed, self._max_change_per_pulse) - delta = np.clip(target_intensity - last_y, -allowed, allowed) - y = last_y + delta - self._last_intensity_b = y - self._last_intensity_time_b = t_pulse - - intensity = int(np.clip(round(y), 0, 100)) - - pulse = signal.get_pulse_at(t_pulse, intensity, i + 1) - pulses.append(pulse) - time_advanced_in_packet_ms += pulse.duration - - total_duration = sum(p.duration for p in pulses) - return pulses, total_duration + # Per-channel pulse generation was refactored into ChannelController def _get_media_type(self) -> str: """Determine the media type for logging purposes.""" @@ -550,8 +496,8 @@ def _is_packet_generation_needed(self) -> bool: We also allow proactive generation if queues are running low, to avoid starving the device with repeats for too long. """ - low_queue = (len(self.queue_a) < COYOTE_PULSES_PER_PACKET or - len(self.queue_b) < COYOTE_PULSES_PER_PACKET) + low_queue = (not self.ctrl_a.has_minimum_pulses(COYOTE_PULSES_PER_PACKET) or + not self.ctrl_b.has_minimum_pulses(COYOTE_PULSES_PER_PACKET)) ready = (self.channel_a.is_ready_for_next_packet() or self.channel_b.is_ready_for_next_packet()) return ready or low_queue @@ -634,13 +580,13 @@ def generate_packet(self, current_time: float) -> CoyotePulses: self._update_envelope_preview(current_time) # Ensure queues are filled ahead of time - self._fill_channel_queue(current_time, 'A', self.signal_a) - self._fill_channel_queue(current_time, 'B', self.signal_b) + self.ctrl_a.fill_queue(current_time) + self.ctrl_b.fill_queue(current_time) # Assemble packets by popping from queues (atomic update for A and B) alpha, beta = self.position.get_position(current_time) - pulses_a = self._pop_packet_from_queue('A') - pulses_b = self._pop_packet_from_queue('B') + pulses_a = self.ctrl_a.pop_packet() + pulses_b = self.ctrl_b.pop_packet() duration_a = sum(p.duration for p in pulses_a) duration_b = sum(p.duration for p in pulses_b) From d4ec350852dd5ab063c2fb6eb05ab3176e38eb75 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Fri, 17 Oct 2025 19:45:43 +0700 Subject: [PATCH 18/47] =?UTF-8?q?Coyote:=20fix=20init=20order=20=E2=80=94?= =?UTF-8?q?=20set=20=5Fmax=5Fchange=5Fper=5Fpulse=20before=20creating=20Ch?= =?UTF-8?q?annelController=20instances=20to=20avoid=20AttributeError=20dur?= =?UTF-8?q?ing=20construction.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- device/coyote/algorithm.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py index f67566b..96409e3 100644 --- a/device/coyote/algorithm.py +++ b/device/coyote/algorithm.py @@ -362,6 +362,14 @@ def __init__(self, media: AbstractMediaSync, params: CoyoteAlgorithmParams, safe self.last_update_time_s = 0.0 self.next_update_time = 0.0 + # Global per-pulse cap (percentage points). Read from settings if available. + self._max_change_per_pulse = 3.0 + try: + if ui_settings is not None: + self._max_change_per_pulse = float(ui_settings.coyote_max_intensity_change_per_pulse.get()) + except Exception: + pass + # Per-channel controllers (queues, smoothing, assembly) self.ctrl_a = ChannelController( 'A', self.media, self.params, self.signal_a, @@ -379,17 +387,8 @@ def __init__(self, media: AbstractMediaSync, params: CoyoteAlgorithmParams, safe # UI Preview Cache self._cached_envelope = np.full(200, 0.5) # Default to a flat line self._cached_envelope_period = 0.0 - # Smoothing state handled by ChannelController - # Global per-pulse cap (percentage points). Read from settings if available. - self._max_change_per_pulse = 3.0 - try: - if ui_settings is not None: - self._max_change_per_pulse = float(ui_settings.coyote_max_intensity_change_per_pulse.get()) - except Exception: - pass - # Channel-specific pulse generation and queues moved to ChannelController def _get_positional_intensities(self, t: float, volume: float) -> Tuple[int, int]: From 4cbcd625968895946157651231f763606aecd77c Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Fri, 17 Oct 2025 19:54:05 +0700 Subject: [PATCH 19/47] Coyote: reduce duration jaggedness with fractional ms accumulator; clamp residual on bounds; fix queue debug to show coverage not absolute end time. Keep debug prints. --- device/coyote/algorithm.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py index 96409e3..c27cdae 100644 --- a/device/coyote/algorithm.py +++ b/device/coyote/algorithm.py @@ -161,6 +161,7 @@ def __init__(self, params: CoyoteAlgorithmParams, channel_params: 'CoyoteChannel self._last_pulse_time = 0.0 self._start_time = None self.modulation_phase = 0.0 + self._duration_residual_ms = 0.0 # fractional ms accumulator to reduce rounding jitter # Envelope cache self._envelope_lookup_table = None @@ -224,10 +225,21 @@ def get_pulse_at(self, current_time: float, base_intensity: float, pulse_index: # Treat jitter as a 0..1 fraction; clamp to ±50% to avoid pathological spans jitter = float(np.clip(jitter, 0.0, 0.5)) jitter_factor = 1.0 + (np.random.rand() * 2.0 - 1.0) * jitter - pulse_duration = int(round(base_duration * jitter_factor)) + desired_ms = base_duration * jitter_factor + + # Fractional-duration accumulation to reduce jagged 10↔11ms toggling + accum = self._duration_residual_ms + desired_ms + pulse_duration = int(np.floor(accum + 0.5)) # nearest int + self._duration_residual_ms = accum - pulse_duration # Clamp to channel-specific duration window and hardware bounds - pulse_duration = int(np.clip(pulse_duration, min_dur, max_dur)) + if pulse_duration < min_dur: + # Accumulate shortfall so subsequent pulses compensate when possible + self._duration_residual_ms += (pulse_duration - min_dur) + pulse_duration = min_dur + elif pulse_duration > max_dur: + self._duration_residual_ms += (pulse_duration - max_dur) + pulse_duration = max_dur final_frequency = int(max(1, round(1000.0 / pulse_duration))) @@ -240,6 +252,7 @@ def get_pulse_at(self, current_time: float, base_intensity: float, pulse_index: f"[DEBUG] COYOTE get_pulse_at: t={current_time:.3f} idx={pulse_index} " f"req_freq={requested_freq:.2f}Hz limits=({min_freq:.1f},{max_freq:.1f})Hz " f"dur_limits=({min_dur},{max_dur})ms base_dur={base_duration:.1f}ms jitter={jitter:.2f} " + f"desired={desired_ms:.2f}ms residual={self._duration_residual_ms:+.2f}ms " f"final_dur={pulse_duration}ms final_freq={final_frequency}Hz intensity={final_intensity}%" ) except Exception: @@ -320,7 +333,8 @@ def fill_queue(self, now_s: float) -> None: self.queue_end_time = end_time try: - print(f"[DEBUG] COYOTE fill_queue {self.name}: size={len(self.queue)} until={end_time-now_s:.3f}s horizon={self.queue_horizon_s:.3f}s") + coverage_s = sum(p.duration for p in self.queue) / 1000.0 + print(f"[DEBUG] COYOTE fill_queue {self.name}: size={len(self.queue)} coverage={coverage_s:.3f}s horizon={self.queue_horizon_s:.3f}s") except Exception: pass From 6a2d789d0ab27ea10b4e7e7a112d9dad5513f0d6 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Fri, 17 Oct 2025 20:10:31 +0700 Subject: [PATCH 20/47] =?UTF-8?q?Coyote:=20use=20all=20funscript=20axes=20?= =?UTF-8?q?coherently.=20Add=20zero-mean=20micro-texture=20on=20durations:?= =?UTF-8?q?=20depth=20from=20pulse=5Fwidth=20(normalized),=20speed=20from?= =?UTF-8?q?=20carrier=20(mapped=20to=200.5=E2=80=935=20Hz),=20combined=20w?= =?UTF-8?q?ith=20pulse=5Finterval=5Frandom=20jitter.=20Keep=20average=20ra?= =?UTF-8?q?te=20anchored=20to=20pulse=5Ffrequency=20via=20fractional=20ms?= =?UTF-8?q?=20accumulator.=20Debug=20logs=20include=20texture=20info.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- device/coyote/algorithm.py | 39 ++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py index c27cdae..914a19f 100644 --- a/device/coyote/algorithm.py +++ b/device/coyote/algorithm.py @@ -203,6 +203,7 @@ def get_pulse_at(self, current_time: float, base_intensity: float, pulse_index: - pulse_frequency: controls the base repetition rate (Hz) - pulse_interval_random: ±fractional jitter of the base duration + - pulse_width: controls zero-mean micro-texture depth (duration modulation) - intensity: provided by caller (volume × position, smoothed elsewhere) """ # Initialize timing state @@ -220,12 +221,28 @@ def get_pulse_at(self, current_time: float, base_intensity: float, pulse_index: requested_freq = float(np.clip(requested_freq, min_freq, max_freq)) base_duration = 1000.0 / requested_freq if requested_freq > 0 else max_dur - # Optional jitter around base duration + # Optional jitter around base duration (from funscript) jitter = float(self.params.pulse_interval_random.interpolate(current_time)) # Treat jitter as a 0..1 fraction; clamp to ±50% to avoid pathological spans jitter = float(np.clip(jitter, 0.0, 0.5)) jitter_factor = 1.0 + (np.random.rand() * 2.0 - 1.0) * jitter - desired_ms = base_duration * jitter_factor + + # Micro-texture from pulse_width (depth) with phase advanced in _advance_channel_states + # Normalize pulse_width cycles to 0..1 using limits + width_cycles = float(self.params.pulse_width.interpolate(current_time)) + min_w, max_w = self.pulse_width_limits + width_norm = 0.0 if max_w <= min_w else np.clip((width_cycles - min_w) / (max_w - min_w), 0.0, 1.0) + + # Available symmetric headroom around base duration + headroom_ms = min(max_dur - base_duration, base_duration - min_dur) + headroom_ms = max(0.0, headroom_ms) + + # Cap texture depth to a fraction of headroom; scale by width_norm + TEXTURE_MAX_DEPTH_FRACTION = 0.5 + texture_amplitude_ms = headroom_ms * TEXTURE_MAX_DEPTH_FRACTION * width_norm + texture_ms = texture_amplitude_ms * np.sin(self.modulation_phase) + + desired_ms = base_duration * jitter_factor + texture_ms # Fractional-duration accumulation to reduce jagged 10↔11ms toggling accum = self._duration_residual_ms + desired_ms @@ -252,7 +269,7 @@ def get_pulse_at(self, current_time: float, base_intensity: float, pulse_index: f"[DEBUG] COYOTE get_pulse_at: t={current_time:.3f} idx={pulse_index} " f"req_freq={requested_freq:.2f}Hz limits=({min_freq:.1f},{max_freq:.1f})Hz " f"dur_limits=({min_dur},{max_dur})ms base_dur={base_duration:.1f}ms jitter={jitter:.2f} " - f"desired={desired_ms:.2f}ms residual={self._duration_residual_ms:+.2f}ms " + f"texture_amp={texture_amplitude_ms:.2f}ms desired={desired_ms:.2f}ms residual={self._duration_residual_ms:+.2f}ms " f"final_dur={pulse_duration}ms final_freq={final_frequency}Hz intensity={final_intensity}%" ) except Exception: @@ -489,17 +506,15 @@ def _advance_channel_states(self, current_time: float, delta_time_ms: float) -> self.channel_a.advance_time(delta_time_ms) self.channel_b.advance_time(delta_time_ms) - # Get normalized modulation speed - # We use signal_a's limits, assuming they are the same for both channels. - _, mod_speed_norm, _, _ = _get_normalized_parameters( + # Advance shared micro-texture phase using carrier axis as speed control (mapped to ~0.5..5 Hz) + carrier_norm, _, _, _ = _get_normalized_parameters( self.params, current_time, self.signal_a.carrier_freq_limits, self.signal_a.pulse_freq_limits) - - # Calculate phase change based on elapsed time and modulation speed - # This logic is now centralized here from its previous location in get_pulse_at. + TEXTURE_MIN_HZ = 0.5 + TEXTURE_MAX_HZ = 5.0 + texture_speed_hz = TEXTURE_MIN_HZ + (TEXTURE_MAX_HZ - TEXTURE_MIN_HZ) * (carrier_norm / 100.0) delta_time_s = delta_time_ms / 1000.0 - phase_change = (delta_time_s * mod_speed_norm / 10.0) * 2 * np.pi # Simplified from / 50.0 * 5.0 - - # Apply the same phase change to both signals to keep them in sync + phase_change = (delta_time_s * texture_speed_hz) * 2 * np.pi + # Keep both channels in sync for texture phase self.signal_a.modulation_phase = (self.signal_a.modulation_phase + phase_change) % (2 * np.pi) self.signal_b.modulation_phase = (self.signal_b.modulation_phase + phase_change) % (2 * np.pi) From 685afc83cbfff898c4e59689ff6203619d4aef55 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Fri, 17 Oct 2025 20:24:19 +0700 Subject: [PATCH 21/47] =?UTF-8?q?Coyote:=20fix=20queue=20horizon=20(comput?= =?UTF-8?q?e=20end=5Ftime=20from=20live=20coverage);=20prevent=20residual?= =?UTF-8?q?=20blowup=20on=20clamping=20and=20bound=20residual=20to=20?= =?UTF-8?q?=C2=B10.49ms.=20This=20preserves=20funscript=20base=20while=20a?= =?UTF-8?q?llowing=20jitter/texture=20within=20limits.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- device/coyote/algorithm.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py index 914a19f..94476f5 100644 --- a/device/coyote/algorithm.py +++ b/device/coyote/algorithm.py @@ -248,15 +248,23 @@ def get_pulse_at(self, current_time: float, base_intensity: float, pulse_index: accum = self._duration_residual_ms + desired_ms pulse_duration = int(np.floor(accum + 0.5)) # nearest int self._duration_residual_ms = accum - pulse_duration + # Keep residual bounded for numerical stability + if self._duration_residual_ms > 0.49: + self._duration_residual_ms = 0.49 + elif self._duration_residual_ms < -0.49: + self._duration_residual_ms = -0.49 # Clamp to channel-specific duration window and hardware bounds + clamped = False if pulse_duration < min_dur: - # Accumulate shortfall so subsequent pulses compensate when possible - self._duration_residual_ms += (pulse_duration - min_dur) pulse_duration = min_dur + clamped = True elif pulse_duration > max_dur: - self._duration_residual_ms += (pulse_duration - max_dur) pulse_duration = max_dur + clamped = True + # If clamped to bounds, do not let residual drift; rounding is only for integer fairness + if clamped: + self._duration_residual_ms = 0.0 final_frequency = int(max(1, round(1000.0 / pulse_duration))) @@ -339,7 +347,9 @@ def _generate_single_pulse(self, t_pulse: float, seq_index: int) -> CoyotePulse: return pulse def fill_queue(self, now_s: float) -> None: - end_time = self.queue_end_time if self.queue_end_time is not None else now_s + # Compute end_time from current queue coverage relative to now + coverage_s = sum(p.duration for p in self.queue) / 1000.0 + end_time = now_s + coverage_s horizon_end = now_s + self.queue_horizon_s seq_index = 0 while end_time < horizon_end or len(self.queue) < COYOTE_PULSES_PER_PACKET: @@ -350,7 +360,6 @@ def fill_queue(self, now_s: float) -> None: self.queue_end_time = end_time try: - coverage_s = sum(p.duration for p in self.queue) / 1000.0 print(f"[DEBUG] COYOTE fill_queue {self.name}: size={len(self.queue)} coverage={coverage_s:.3f}s horizon={self.queue_horizon_s:.3f}s") except Exception: pass From d5c46b8bee2aef7393e8df282d0b2761e9118cfb Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Fri, 17 Oct 2025 20:34:58 +0700 Subject: [PATCH 22/47] Coyote: asymmetric texture around funscript base using pulse_width. Use float headroom up/down; symmetric sine when both sides available; one-sided rectified-sine with DC removal when at a boundary; keep zero-mean so average rate stays at pulse_frequency. Improve debug to show tex_up/tex_dn. --- device/coyote/algorithm.py | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py index 94476f5..bedb741 100644 --- a/device/coyote/algorithm.py +++ b/device/coyote/algorithm.py @@ -233,14 +233,33 @@ def get_pulse_at(self, current_time: float, base_intensity: float, pulse_index: min_w, max_w = self.pulse_width_limits width_norm = 0.0 if max_w <= min_w else np.clip((width_cycles - min_w) / (max_w - min_w), 0.0, 1.0) - # Available symmetric headroom around base duration - headroom_ms = min(max_dur - base_duration, base_duration - min_dur) - headroom_ms = max(0.0, headroom_ms) + # Floating headroom on each side of base (use float limits, clamp to >=0) + min_dur_f = 1000.0 / max_freq + max_dur_f = 1000.0 / min_freq + amp_up_ms = max(0.0, max_dur_f - base_duration) # can increase duration up to this much + amp_dn_ms = max(0.0, base_duration - min_dur_f) # can decrease duration up to this much - # Cap texture depth to a fraction of headroom; scale by width_norm TEXTURE_MAX_DEPTH_FRACTION = 0.5 - texture_amplitude_ms = headroom_ms * TEXTURE_MAX_DEPTH_FRACTION * width_norm - texture_ms = texture_amplitude_ms * np.sin(self.modulation_phase) + amp_up_ms *= TEXTURE_MAX_DEPTH_FRACTION * width_norm + amp_dn_ms *= TEXTURE_MAX_DEPTH_FRACTION * width_norm + + # Zero-mean texture respecting asymmetric headroom + s = np.sin(self.modulation_phase) + if amp_up_ms > 1e-6 and amp_dn_ms > 1e-6: + # Symmetric case: use sine with symmetric amplitude + texture_amplitude_ms = min(amp_up_ms, amp_dn_ms) + texture_ms = texture_amplitude_ms * s + elif amp_up_ms > 1e-6: + # One-sided (can only go up). Use rectified sine and subtract DC (E|sin|=2/π) + texture_amplitude_ms = amp_up_ms + texture_ms = amp_up_ms * (abs(s) - 2.0/np.pi) + elif amp_dn_ms > 1e-6: + # One-sided (can only go down). Negative rectified sine with DC removed + texture_amplitude_ms = amp_dn_ms + texture_ms = -amp_dn_ms * (abs(s) - 2.0/np.pi) + else: + texture_amplitude_ms = 0.0 + texture_ms = 0.0 desired_ms = base_duration * jitter_factor + texture_ms @@ -276,8 +295,8 @@ def get_pulse_at(self, current_time: float, base_intensity: float, pulse_index: print( f"[DEBUG] COYOTE get_pulse_at: t={current_time:.3f} idx={pulse_index} " f"req_freq={requested_freq:.2f}Hz limits=({min_freq:.1f},{max_freq:.1f})Hz " - f"dur_limits=({min_dur},{max_dur})ms base_dur={base_duration:.1f}ms jitter={jitter:.2f} " - f"texture_amp={texture_amplitude_ms:.2f}ms desired={desired_ms:.2f}ms residual={self._duration_residual_ms:+.2f}ms " + f"dur_limits=({min_dur},{max_dur})ms base_dur={base_duration:.2f}ms jitter={jitter:.2f} " + f"tex_up={amp_up_ms:.2f}ms tex_dn={amp_dn_ms:.2f}ms tex_used={texture_amplitude_ms:.2f}ms desired={desired_ms:.2f}ms residual={self._duration_residual_ms:+.2f}ms " f"final_dur={pulse_duration}ms final_freq={final_frequency}Hz intensity={final_intensity}%" ) except Exception: From 095d9f218142cc5da24df3715767fe0a04e7ce62 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Fri, 17 Oct 2025 20:42:07 +0700 Subject: [PATCH 23/47] Coyote: map funscript pulse_frequency proportionally into each channel's preferred range. Normalize using kit pulse_freq_limits, then scale to channel [min,max]. Update debug to show pf_raw/pf_norm/mapped. --- device/coyote/algorithm.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py index bedb741..f67bfa3 100644 --- a/device/coyote/algorithm.py +++ b/device/coyote/algorithm.py @@ -216,10 +216,18 @@ def get_pulse_at(self, current_time: float, base_intensity: float, pulse_index: min_dur = max(COYOTE_MIN_PULSE_DURATION, int(round(1000.0 / max_freq))) max_dur = min(COYOTE_MAX_PULSE_DURATION, int(round(1000.0 / min_freq))) - # Base frequency from UI/funscript axis, clamped by channel limits - requested_freq = float(self.params.pulse_frequency.interpolate(current_time)) - requested_freq = float(np.clip(requested_freq, min_freq, max_freq)) - base_duration = 1000.0 / requested_freq if requested_freq > 0 else max_dur + # Map global funscript pulse_frequency into the channel's preferred range + # 1) Normalize funscript value using global pulse_freq_limits (kit limits) + raw_pf = float(self.params.pulse_frequency.interpolate(current_time)) + pf_min, pf_max = self.pulse_freq_limits + if pf_max <= pf_min: + pf_norm = 0.0 + else: + pf_norm = float(np.clip((raw_pf - pf_min) / (pf_max - pf_min), 0.0, 1.0)) + # 2) Map normalized value into channel-specific [min_freq, max_freq] + mapped_freq = min_freq + pf_norm * (max_freq - min_freq) + mapped_freq = float(np.clip(mapped_freq, min_freq, max_freq)) + base_duration = 1000.0 / mapped_freq if mapped_freq > 0 else max_dur # Optional jitter around base duration (from funscript) jitter = float(self.params.pulse_interval_random.interpolate(current_time)) @@ -294,7 +302,7 @@ def get_pulse_at(self, current_time: float, base_intensity: float, pulse_index: try: print( f"[DEBUG] COYOTE get_pulse_at: t={current_time:.3f} idx={pulse_index} " - f"req_freq={requested_freq:.2f}Hz limits=({min_freq:.1f},{max_freq:.1f})Hz " + f"pf_raw={raw_pf:.2f}Hz pf_norm={pf_norm:.2f} mapped={mapped_freq:.2f}Hz limits=({min_freq:.1f},{max_freq:.1f})Hz " f"dur_limits=({min_dur},{max_dur})ms base_dur={base_duration:.2f}ms jitter={jitter:.2f} " f"tex_up={amp_up_ms:.2f}ms tex_dn={amp_dn_ms:.2f}ms tex_used={texture_amplitude_ms:.2f}ms desired={desired_ms:.2f}ms residual={self._duration_residual_ms:+.2f}ms " f"final_dur={pulse_duration}ms final_freq={final_frequency}Hz intensity={final_intensity}%" From bd93acb6ea9b47bd95bc58265a6f6894208ca186 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Fri, 17 Oct 2025 21:14:04 +0700 Subject: [PATCH 24/47] Debug: add width_norm and texture mode (sym/up/down/none) to logs. UI: repurpose Coyote envelope widget to 'Pulse Period Preview (last 2 s)'; remove waveform dropdown; draw pulses over time with normalized duration per channel range; stop polling algorithm envelope. --- device/coyote/algorithm.py | 6 ++- qt_ui/coyote_settings_widget.py | 94 +++++++++++++-------------------- 2 files changed, 43 insertions(+), 57 deletions(-) diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py index f67bfa3..212a413 100644 --- a/device/coyote/algorithm.py +++ b/device/coyote/algorithm.py @@ -257,17 +257,21 @@ def get_pulse_at(self, current_time: float, base_intensity: float, pulse_index: # Symmetric case: use sine with symmetric amplitude texture_amplitude_ms = min(amp_up_ms, amp_dn_ms) texture_ms = texture_amplitude_ms * s + tex_mode = 'sym' elif amp_up_ms > 1e-6: # One-sided (can only go up). Use rectified sine and subtract DC (E|sin|=2/π) texture_amplitude_ms = amp_up_ms texture_ms = amp_up_ms * (abs(s) - 2.0/np.pi) + tex_mode = 'up' elif amp_dn_ms > 1e-6: # One-sided (can only go down). Negative rectified sine with DC removed texture_amplitude_ms = amp_dn_ms texture_ms = -amp_dn_ms * (abs(s) - 2.0/np.pi) + tex_mode = 'down' else: texture_amplitude_ms = 0.0 texture_ms = 0.0 + tex_mode = 'none' desired_ms = base_duration * jitter_factor + texture_ms @@ -303,7 +307,7 @@ def get_pulse_at(self, current_time: float, base_intensity: float, pulse_index: print( f"[DEBUG] COYOTE get_pulse_at: t={current_time:.3f} idx={pulse_index} " f"pf_raw={raw_pf:.2f}Hz pf_norm={pf_norm:.2f} mapped={mapped_freq:.2f}Hz limits=({min_freq:.1f},{max_freq:.1f})Hz " - f"dur_limits=({min_dur},{max_dur})ms base_dur={base_duration:.2f}ms jitter={jitter:.2f} " + f"dur_limits=({min_dur},{max_dur})ms base_dur={base_duration:.2f}ms jitter={jitter:.2f} width_norm={width_norm:.2f} tex_mode={tex_mode} " f"tex_up={amp_up_ms:.2f}ms tex_dn={amp_dn_ms:.2f}ms tex_used={texture_amplitude_ms:.2f}ms desired={desired_ms:.2f}ms residual={self._duration_residual_ms:+.2f}ms " f"final_dur={pulse_duration}ms final_freq={final_frequency}Hz intensity={final_intensity}%" ) diff --git a/qt_ui/coyote_settings_widget.py b/qt_ui/coyote_settings_widget.py index 035edf3..899917f 100644 --- a/qt_ui/coyote_settings_widget.py +++ b/qt_ui/coyote_settings_widget.py @@ -425,9 +425,9 @@ def __init__(self, parent=None): self.layout().addWidget(self.view) - # Store envelope data - self.envelope_data = np.zeros(100) # Default empty envelope - self.envelope_period = 1.0 + # Period preview mode: not using an envelope curve + self.envelope_data = np.array([]) + self.envelope_period = 0.0 # Store recent pulses for overlay - increased to show more self.recent_pulses = [] @@ -542,8 +542,7 @@ def refresh(self): # Draw simple grid self._drawGrid(width, height, graph_top, graph_bottom) - # Draw envelope - self._drawEnvelope(width, height, graph_top, graph_bottom, graph_height) + # No envelope curve in period preview mode # Draw pulses self._drawPulses(width, height, graph_top, graph_bottom, graph_height, current_time) @@ -579,21 +578,19 @@ def _drawEnvelope(self, width, height, graph_top, graph_bottom, graph_height): self.scene.addPath(path, pen) def _drawPulses(self, width, height, graph_top, graph_bottom, graph_height, current_time): - """Draw pulse dots on the envelope""" - if not self.recent_pulses or len(self.envelope_data) == 0 or self.envelope_period <= 0: + """Draw pulse dots using normalized duration and time window""" + if not self.recent_pulses: return usable_width = width - 2 * self.margin + # Drop old pulses + cutoff = current_time - self.max_pulse_age + self.recent_pulses = [p for p in self.recent_pulses if p['timestamp'] >= cutoff] for pulse in self.recent_pulses: age = current_time - pulse['timestamp'] - phase = (age % self.envelope_period) / self.envelope_period - phase_reversed = 1.0 - phase # Newer pulses on right - x = self.margin + phase_reversed * usable_width - if len(self.envelope_data) > 1: - env_idx = min(int(phase_reversed * (len(self.envelope_data) - 1)), len(self.envelope_data) - 1) - env_value = self.envelope_data[env_idx] - y = graph_bottom - (env_value * graph_height) - else: - y = (graph_top + graph_bottom) / 2 + frac = max(0.0, min(1.0, 1.0 - age / self.max_pulse_age)) + x = self.margin + frac * usable_width + norm = pulse.get('norm', 0.5) + y = graph_bottom - (norm * graph_height) intensity = pulse['intensity'] size = 4 + (12 * intensity / 100.0) channel = pulse['channel'] @@ -602,18 +599,14 @@ def _drawPulses(self, width, height, graph_top, graph_bottom, graph_height, curr dot.setPen(QPen(Qt.NoPen)) dot.setToolTip(f"Channel: {'A' if channel == 0 else 'B'}\n" f"Intensity: {intensity}%\n" - f"Duration: {pulse['duration']} ms") + f"Duration: {pulse['duration']} ms\n" + f"Normalized: {norm:.2f}") dot.setAcceptHoverEvents(True) self.scene.addItem(dot) def _drawFrequencyLabel(self, width, height): """Draw frequency information""" - if self.envelope_period > 0: - freq_hz = 1.0 / self.envelope_period - label_text = f"{freq_hz:.1f} Hz ({self.envelope_period*1000:.0f} ms)" - label = self.scene.addText(label_text) - label.setPos(width - 180, 5) - label.setDefaultTextColor(QColor(60, 60, 60)) + # No frequency label in period preview mode class EnvelopeGraphContainer(QWidget): """ @@ -630,21 +623,14 @@ def __init__(self, parent=None): top_row = QHBoxLayout() # Add description - self.description = QLabel("Envelope Pattern") + self.description = QLabel("Pulse Period Preview (last 2 s)") self.description.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) top_row.addWidget(self.description) # Add spacer top_row.addStretch(1) - # Add waveform selector - self.waveform_label = QLabel("Waveform:") - top_row.addWidget(self.waveform_label) - - self.waveform_selector = QtWidgets.QComboBox() - self.waveform_selector.addItem("Sine") - # Add more waveforms here when supported - top_row.addWidget(self.waveform_selector) + # No waveform selector in period preview mode self.layout.addLayout(top_row) @@ -657,18 +643,10 @@ def __init__(self, parent=None): self.received_real_data = False def setEnvelopeData(self, envelope_data, envelope_period): - """Pass envelope data to the graph""" - self.received_real_data = True - - # Update envelope description based on real data - if envelope_period > 0: - freq_hz = 1.0 / envelope_period - self.description.setText(f"Envelope Pattern - {freq_hz:.1f} Hz ({envelope_period*1000:.0f} ms)") - - # Pass data to the graph - self.graph.setEnvelopeData(envelope_data, envelope_period) + # Not used in period preview mode + pass - def addPulse(self, channel_id, intensity, duration, strength=100): + def addPulse(self, channel_id, intensity, duration, strength=100, min_hz=None, max_hz=None): """ Add a pulse to the visualization @@ -688,8 +666,18 @@ def addPulse(self, channel_id, intensity, duration, strength=100): # Convert channel ID to index (0 for A, 1 for B) channel_idx = 0 if channel_id == 'A' else 1 - # Add directly to the graph without buffering + # Compute normalized duration using provided channel range + if min_hz and max_hz and max_hz > min_hz and min_hz > 0 and max_hz > 0: + d_min = 1000.0 / max_hz + d_max = 1000.0 / min_hz + norm = (duration - d_min) / max(1e-6, (d_max - d_min)) + norm = max(0.0, min(1.0, norm)) + else: + norm = 0.5 + # Add to graph and attach normalized value self.graph.addPulse(channel_idx, intensity, duration) + if self.graph.recent_pulses: + self.graph.recent_pulses[0]['norm'] = norm class CoyoteSettingsWidget(QtWidgets.QWidget): def __init__(self, parent=None): @@ -1015,7 +1003,8 @@ def on_pulse_sent(self, pulses: CoyotePulses): # Add to envelope graph only if effective intensity is > 0 if effective_intensity > 0: - self.envelope_graph.addPulse('A', pulse.intensity, pulse.duration, strength_a) + self.envelope_graph.addPulse('A', pulse.intensity, pulse.duration, strength_a, + min_hz=self.freq_min_a.value(), max_hz=self.freq_max_a.value()) # Update Channel B if pulses.channel_b: @@ -1038,7 +1027,8 @@ def on_pulse_sent(self, pulses: CoyotePulses): # Add to envelope graph only if effective intensity is > 0 if effective_intensity > 0: - self.envelope_graph.addPulse('B', pulse.intensity, pulse.duration, strength_b) + self.envelope_graph.addPulse('B', pulse.intensity, pulse.duration, strength_b, + min_hz=self.freq_min_b.value(), max_hz=self.freq_max_b.value()) def update_freq_min_a(self, value): """Update minimum frequency for channel A""" @@ -1115,13 +1105,5 @@ def update_strength_max_b(self, value): self.update_channel_b(self.volume_b_slider.value()) def fetch_envelope_data(self): - """Fetch shared envelope data and update the graph""" - if self.device is None or self.device.algorithm is None or not self.isVisible(): - return - - try: - env_data, env_period = self.device.algorithm.get_envelope_data() - if env_data is not None and env_data.size > 0 and env_period > 0: - self.envelope_graph.setEnvelopeData(env_data, env_period) - except Exception as e: - print(f"Error fetching envelope data: {str(e)}") \ No newline at end of file + """Not used in period preview mode; pulses drive the preview.""" + return From 3ba16d9193689229040b1e34d84ea9f4071922d2 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Fri, 17 Oct 2025 21:20:55 +0700 Subject: [PATCH 25/47] Coyote UI: improve period preview. Use 2s time window with up to 400 pulses; decimate drawing to ~200 points; dots now span the full width over time rather than clustering at the right edge. --- qt_ui/coyote_settings_widget.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/qt_ui/coyote_settings_widget.py b/qt_ui/coyote_settings_widget.py index 899917f..fe729cd 100644 --- a/qt_ui/coyote_settings_widget.py +++ b/qt_ui/coyote_settings_widget.py @@ -429,10 +429,11 @@ def __init__(self, parent=None): self.envelope_data = np.array([]) self.envelope_period = 0.0 - # Store recent pulses for overlay - increased to show more + # Store recent pulses for overlay self.recent_pulses = [] - self.max_pulses = 50 # Show plenty of pulses - self.max_pulse_age = 2.0 # Show a longer history (2 seconds) + # Keep enough pulses to cover ~2s even at higher rates (both channels) + self.max_pulses = 400 + self.max_pulse_age = 2.0 # seconds # Colors for visualization self.envelope_color = QColor(0, 180, 255, 150) @@ -585,7 +586,11 @@ def _drawPulses(self, width, height, graph_top, graph_bottom, graph_height, curr # Drop old pulses cutoff = current_time - self.max_pulse_age self.recent_pulses = [p for p in self.recent_pulses if p['timestamp'] >= cutoff] - for pulse in self.recent_pulses: + # Decimate if there are too many points to draw + step = max(1, int(len(self.recent_pulses) / 200)) + for i, pulse in enumerate(self.recent_pulses): + if i % step != 0: + continue age = current_time - pulse['timestamp'] frac = max(0.0, min(1.0, 1.0 - age / self.max_pulse_age)) x = self.margin + frac * usable_width From ffdf8b908c704777c7ee7c5efa893a21f9d656a0 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Fri, 17 Oct 2025 21:24:42 +0700 Subject: [PATCH 26/47] Coyote UI: make period preview useful. Draw per-channel polylines of normalized duration across the 2s window, add intensity dots and right-side current Hz labels, add light grid. Decimate to ~300 points for performance. --- qt_ui/coyote_settings_widget.py | 81 +++++++++++++++++++++++---------- 1 file changed, 58 insertions(+), 23 deletions(-) diff --git a/qt_ui/coyote_settings_widget.py b/qt_ui/coyote_settings_widget.py index fe729cd..76c73e3 100644 --- a/qt_ui/coyote_settings_widget.py +++ b/qt_ui/coyote_settings_widget.py @@ -437,10 +437,8 @@ def __init__(self, parent=None): # Colors for visualization self.envelope_color = QColor(0, 180, 255, 150) - self.pulse_colors = [ - CHANNEL_A_COLOR, - CHANNEL_B_COLOR - ] + self.pulse_colors = [CHANNEL_A_COLOR, CHANNEL_B_COLOR] + self.line_colors = [QColor(170, 120, 255, 200), QColor(255, 190, 80, 200)] # Add margin to avoid clipping self.margin = 20 @@ -552,8 +550,18 @@ def refresh(self): self._drawFrequencyLabel(width, height) def _drawGrid(self, width, height, graph_top, graph_bottom): - # No axes, no grid lines, nothing drawn - pass + # Simple horizontal lines at 0, 0.5, 1 and vertical lines every 0.5 s + pen = QPen(QColor(60, 60, 60)) + pen.setStyle(Qt.DashLine) + self.scene.addLine(self.margin, graph_bottom, width - self.margin, graph_bottom, pen) + self.scene.addLine(self.margin, (graph_top + graph_bottom)/2, width - self.margin, (graph_top + graph_bottom)/2, pen) + self.scene.addLine(self.margin, graph_top, width - self.margin, graph_top, pen) + # Vertical ticks + usable_width = width - 2 * self.margin + n_ticks = 4 + for i in range(1, n_ticks): + x = self.margin + (i / n_ticks) * usable_width + self.scene.addLine(x, graph_top, x, graph_bottom, pen) def _drawEnvelope(self, width, height, graph_top, graph_bottom, graph_height): """Draw envelope curve (0 at bottom, 1 at top)""" @@ -586,28 +594,55 @@ def _drawPulses(self, width, height, graph_top, graph_bottom, graph_height, curr # Drop old pulses cutoff = current_time - self.max_pulse_age self.recent_pulses = [p for p in self.recent_pulses if p['timestamp'] >= cutoff] - # Decimate if there are too many points to draw - step = max(1, int(len(self.recent_pulses) / 200)) - for i, pulse in enumerate(self.recent_pulses): + # Split pulses by channel and build polylines (newest on right) + series = {0: [], 1: []} + step = max(1, int(len(self.recent_pulses) / 300)) + for i, p in enumerate(self.recent_pulses): if i % step != 0: continue - age = current_time - pulse['timestamp'] + age = current_time - p['timestamp'] frac = max(0.0, min(1.0, 1.0 - age / self.max_pulse_age)) x = self.margin + frac * usable_width - norm = pulse.get('norm', 0.5) + norm = p.get('norm', 0.5) y = graph_bottom - (norm * graph_height) - intensity = pulse['intensity'] - size = 4 + (12 * intensity / 100.0) - channel = pulse['channel'] - dot = QGraphicsEllipseItem(x - size/2, y - size/2, size, size) - dot.setBrush(QBrush(self.pulse_colors[channel])) - dot.setPen(QPen(Qt.NoPen)) - dot.setToolTip(f"Channel: {'A' if channel == 0 else 'B'}\n" - f"Intensity: {intensity}%\n" - f"Duration: {pulse['duration']} ms\n" - f"Normalized: {norm:.2f}") - dot.setAcceptHoverEvents(True) - self.scene.addItem(dot) + series[p['channel']].append((x, y, p)) + + for ch in (0, 1): + pts = series[ch] + if len(pts) < 2: + continue + # Sort by x to draw from left to right + pts.sort(key=lambda t: t[0]) + path = QPainterPath() + path.moveTo(pts[0][0], pts[0][1]) + for x, y, _ in pts[1:]: + path.lineTo(x, y) + pen = QPen(self.line_colors[ch], 2) + self.scene.addPath(path, pen) + + # Draw dots on top with intensity-sized markers + for x, y, p in pts: + size = 3 + (9 * p['intensity'] / 100.0) + dot = QGraphicsEllipseItem(x - size/2, y - size/2, size, size) + dot.setBrush(QBrush(self.pulse_colors[ch])) + dot.setPen(QPen(Qt.NoPen)) + hz = 0 if p['duration'] <= 0 else int(round(1000.0 / p['duration'])) + dot.setToolTip(f"Channel: {'A' if ch == 0 else 'B'}\n" + f"Intensity: {p['intensity']}%\n" + f"Duration: {p['duration']} ms ({hz} Hz)\n" + f"Normalized: {p.get('norm', 0.5):.2f}") + self.scene.addItem(dot) + + # Right-side labels: current freq estimate per channel + for ch in (0, 1): + pts = series[ch] + if not pts: + continue + _, _, last = pts[-1] + hz = 0 if last['duration'] <= 0 else int(round(1000.0 / last['duration'])) + label = self.scene.addText(f"{'A' if ch == 0 else 'B'}: {hz} Hz") + label.setDefaultTextColor(self.pulse_colors[ch]) + label.setPos(width - self.margin - 80, graph_top + ch * 18) def _drawFrequencyLabel(self, width, height): """Draw frequency information""" From 778eae9fd8c49fc258b89291ab8670fdd26694b5 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Fri, 17 Oct 2025 21:29:45 +0700 Subject: [PATCH 27/47] Coyote UI: add per-channel stats to preview (current Hz, 2s avg/min/max, jitter %, count). Keeps polyline + dots but surfaces actionable numbers. --- qt_ui/coyote_settings_widget.py | 41 +++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/qt_ui/coyote_settings_widget.py b/qt_ui/coyote_settings_widget.py index 76c73e3..7d0a0b9 100644 --- a/qt_ui/coyote_settings_widget.py +++ b/qt_ui/coyote_settings_widget.py @@ -674,6 +674,17 @@ def __init__(self, parent=None): self.layout.addLayout(top_row) + # Stats row + stats_row = QHBoxLayout() + self.statsA = QLabel("A: —") + self.statsB = QLabel("B: —") + self.statsA.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + self.statsB.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + stats_row.addWidget(self.statsA) + stats_row.addStretch(1) + stats_row.addWidget(self.statsB) + self.layout.addLayout(stats_row) + # Create envelope graph self.graph = EnvelopeGraph() self.layout.addWidget(self.graph) @@ -718,6 +729,36 @@ def addPulse(self, channel_id, intensity, duration, strength=100, min_hz=None, m self.graph.addPulse(channel_idx, intensity, duration) if self.graph.recent_pulses: self.graph.recent_pulses[0]['norm'] = norm + # Update stats after each pulse + self.updateStats() + + def updateStats(self): + def fmt_stats(ch): + pulses = [p for p in self.graph.recent_pulses if p['channel'] == ch] + if not pulses: + return "—" + # Current from most recent + now_hz = int(round(1000.0 / pulses[0]['duration'])) if pulses[0]['duration'] > 0 else 0 + # Compute frequency and period arrays + hz = [1000.0 / p['duration'] for p in pulses if p['duration'] > 0] + if not hz: + return f"{'A' if ch == 0 else 'B'}: —" + avg_hz = sum(hz) / len(hz) + min_hz = int(min(hz)) + max_hz = int(max(hz)) + # Period jitter (% of mean period) + periods = [p['duration'] for p in pulses] + mu = sum(periods) / len(periods) + if len(periods) > 1: + var = sum((x - mu) ** 2 for x in periods) / (len(periods) - 1) + sd = var ** 0.5 + jitter = int(round((sd / mu) * 100)) if mu > 0 else 0 + else: + jitter = 0 + return f"{'A' if ch == 0 else 'B'}: {now_hz} Hz • avg {int(round(avg_hz))} ({min_hz}–{max_hz}) • jitter {jitter}% • n={len(pulses)}" + + self.statsA.setText(fmt_stats(0)) + self.statsB.setText(fmt_stats(1)) class CoyoteSettingsWidget(QtWidgets.QWidget): def __init__(self, parent=None): From 9d16d44d34b5c1d2d7a6e8297cd6a1241841f484 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Fri, 17 Oct 2025 21:35:05 +0700 Subject: [PATCH 28/47] Coyote UI: remove the envelope/period preview entirely. Drop layout section, legend, timer, fetch method, and preview updates in on_pulse_sent. The two per-channel graphs remain as the primary visualization. --- qt_ui/coyote_settings_widget.py | 89 ++------------------------------- 1 file changed, 5 insertions(+), 84 deletions(-) diff --git a/qt_ui/coyote_settings_widget.py b/qt_ui/coyote_settings_widget.py index 7d0a0b9..6c8ecee 100644 --- a/qt_ui/coyote_settings_widget.py +++ b/qt_ui/coyote_settings_widget.py @@ -793,75 +793,7 @@ def setupUi(self, CoyoteSettingsWidget): status_layout.addWidget(self.label_battery_level) self.layout().addLayout(status_layout) - # Add envelope graph with matching layout to pulse graphs - envelope_section = QHBoxLayout() - - # Left section - make it the same width as channel controls - envelope_left = QVBoxLayout() - left_widget = QWidget() - left_widget.setMinimumWidth(130) # Increased width to match channel control sections - left_widget.setMaximumWidth(130) # Increased width to match channel control sections - envelope_left.addWidget(left_widget) - envelope_section.addLayout(envelope_left) - - # Add envelope graph in the center with stretch factor - self.envelope_graph = EnvelopeGraphContainer() - self.envelope_graph.setMinimumHeight(120) - envelope_section.addWidget(self.envelope_graph, 1) # Use stretch factor of 1 - - # Right side controls with proper width - envelope_right = QHBoxLayout() # Changed to horizontal layout - - # Add a visual legend for dots with proper size - legend_layout = QVBoxLayout() - legend_layout.setSpacing(2) - - # Use shared channel colors - channel_a_qcolor = CHANNEL_A_COLOR - channel_b_qcolor = CHANNEL_B_COLOR - - legend_label = QLabel("Dots:") - legend_label.setAlignment(Qt.AlignCenter) - legend_layout.addWidget(legend_label) - - channel_a_legend = QHBoxLayout() - channel_a_color = QLabel("●") - channel_a_color.setFont(channel_a_color.font()) - channel_a_palette = channel_a_color.palette() - channel_a_palette.setColor(channel_a_color.foregroundRole(), channel_a_qcolor) - channel_a_color.setPalette(channel_a_palette) - channel_a_color.setStyleSheet("font-size: 16px;") - channel_a_text = QLabel("Channel A") - channel_a_legend.addWidget(channel_a_color) - channel_a_legend.addWidget(channel_a_text) - legend_layout.addLayout(channel_a_legend) - - channel_b_legend = QHBoxLayout() - channel_b_color = QLabel("●") - channel_b_color.setFont(channel_b_color.font()) - channel_b_palette = channel_b_color.palette() - channel_b_palette.setColor(channel_b_color.foregroundRole(), channel_b_qcolor) - channel_b_color.setPalette(channel_b_palette) - channel_b_color.setStyleSheet("font-size: 16px;") - channel_b_text = QLabel("Channel B") - channel_b_legend.addWidget(channel_b_color) - channel_b_legend.addWidget(channel_b_text) - legend_layout.addLayout(channel_b_legend) - - size_legend = QHBoxLayout() - size_label = QLabel("Size = Intensity") - size_legend.addWidget(size_label) - legend_layout.addLayout(size_legend) - - # Create a widget to contain the legend with proper width - legend_widget = QWidget() - legend_widget.setLayout(legend_layout) - legend_widget.setMinimumWidth(130) # Match the width of volume sliders - envelope_right.addWidget(legend_widget) - - envelope_section.addLayout(envelope_right) - - self.layout().addLayout(envelope_section) + # No envelope/period preview section # Channel A Row channel_a_layout = QHBoxLayout() @@ -1000,10 +932,7 @@ def setup_device(self, device: CoyoteDevice): self.update_channel_a(0) self.update_channel_b(0) - # Set up timer to periodically fetch envelope data directly - self.envelope_timer = QTimer() - self.envelope_timer.timeout.connect(self.fetch_envelope_data) - self.envelope_timer.start(500) # Fetch every 500ms + # No envelope preview polling def update_channel_a(self, value): """Update channel A strength (volume) in the device.""" @@ -1082,10 +1011,7 @@ def on_pulse_sent(self, pulses: CoyotePulses): channel_limit=max_strength_a ) - # Add to envelope graph only if effective intensity is > 0 - if effective_intensity > 0: - self.envelope_graph.addPulse('A', pulse.intensity, pulse.duration, strength_a, - min_hz=self.freq_min_a.value(), max_hz=self.freq_max_a.value()) + # No envelope preview # Update Channel B if pulses.channel_b: @@ -1106,10 +1032,7 @@ def on_pulse_sent(self, pulses: CoyotePulses): channel_limit=max_strength_b ) - # Add to envelope graph only if effective intensity is > 0 - if effective_intensity > 0: - self.envelope_graph.addPulse('B', pulse.intensity, pulse.duration, strength_b, - min_hz=self.freq_min_b.value(), max_hz=self.freq_max_b.value()) + # No envelope preview def update_freq_min_a(self, value): """Update minimum frequency for channel A""" @@ -1185,6 +1108,4 @@ def update_strength_max_b(self, value): # Send updated strength to device self.update_channel_b(self.volume_b_slider.value()) - def fetch_envelope_data(self): - """Not used in period preview mode; pulses drive the preview.""" - return + # No envelope preview From 27d195f9aa9a047568842b2a180602d1584419d5 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Sat, 18 Oct 2025 12:59:40 +0700 Subject: [PATCH 29/47] Coyote: fix null pulses error --- device/coyote/algorithm.py | 6 +++--- device/coyote/device.py | 14 ++------------ 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py index 212a413..0f24f26 100644 --- a/device/coyote/algorithm.py +++ b/device/coyote/algorithm.py @@ -19,7 +19,7 @@ import time import numpy as np from collections import deque -from typing import List, Tuple, Deque +from typing import List, Tuple, Deque, Optional from stim_math.axis import AbstractMediaSync, AbstractAxis from stim_math.threephase import ThreePhaseCenterCalibration @@ -621,7 +621,7 @@ def _update_envelope_preview(self, t: float): self._cached_envelope_period = 1.0 / freq if freq > 0 else 0.0 self._cached_envelope = np.full(num_points, 0.5) - def generate_packet(self, current_time: float) -> CoyotePulses: + def generate_packet(self, current_time: float) -> Optional[CoyotePulses]: """Generate one packet of pulses for both channels.""" PACKET_MARGIN = 0.8 # Request next packet after 80% of current one has played @@ -642,7 +642,7 @@ def generate_packet(self, current_time: float) -> CoyotePulses: self.channel_b.get_remaining_time_ms() ) self.next_update_time = current_time + (remaining_time_ms / 1000.0) * PACKET_MARGIN - return CoyotePulses([], []) + return None # Update the UI preview cache from the current state self._update_envelope_preview(current_time) diff --git a/device/coyote/device.py b/device/coyote/device.py index 1fde790..4a9186a 100644 --- a/device/coyote/device.py +++ b/device/coyote/device.py @@ -327,17 +327,6 @@ async def _scan_for_device(self): await self.disconnect() return False - # async def connect_and_start(self, algorithm: CoyoteThreePhaseAlgorithm, params: CoyoteParams): - # """Connect to device and start operation""" - # self.algorithm = algorithm - # self.parameters = params - # self.is_connected = True # Set connected status - - # # Start the update loop if not already running - # if not self.running: - # self.running = True - # asyncio.create_task(self.update_loop()) - async def send_command(self, strengths: Optional[CoyoteStrengths] = None, pulses: Optional[CoyotePulses] = None): @@ -446,7 +435,8 @@ async def update_loop(self): # 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) - await self.send_command(pulses=pulses) + if pulses is not None: + await self.send_command(pulses=pulses) sleep_time = max(0.001, self.algorithm.next_update_time - time.time()) else: sleep_time = 0.01 From 82f2b88a5b6a83c9eaa7a17caaab04a29ee93e35 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Sat, 18 Oct 2025 13:24:07 +0700 Subject: [PATCH 30/47] Coyote UI: improve frequency labels clarity - Rename "Min (Hz)" to "Min Freq (Hz)" and "Max (Hz)" to "Max Freq (Hz)" - Makes it clearer these are frequency range controls - Affects both Channel A and B frequency controls --- qt_ui/coyote_settings_widget.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/qt_ui/coyote_settings_widget.py b/qt_ui/coyote_settings_widget.py index 6c8ecee..262813f 100644 --- a/qt_ui/coyote_settings_widget.py +++ b/qt_ui/coyote_settings_widget.py @@ -808,7 +808,7 @@ def setupUi(self, CoyoteSettingsWidget): self.freq_min_a.setRange(10, 500) self.freq_min_a.setValue(settings.coyote_channel_a_freq_min.get()) self.freq_min_a.setSingleStep(10) - freq_min_a_controls.addWidget(QLabel("Min (Hz)")) + freq_min_a_controls.addWidget(QLabel("Min Freq (Hz)")) freq_min_a_controls.addWidget(self.freq_min_a) freq_max_a_controls = QHBoxLayout() @@ -816,7 +816,7 @@ def setupUi(self, CoyoteSettingsWidget): self.freq_max_a.setRange(10, 500) self.freq_max_a.setValue(settings.coyote_channel_a_freq_max.get()) self.freq_max_a.setSingleStep(10) - freq_max_a_controls.addWidget(QLabel("Max (Hz)")) + freq_max_a_controls.addWidget(QLabel("Max Freq (Hz)")) freq_max_a_controls.addWidget(self.freq_max_a) # Max strength controls for Channel A @@ -867,7 +867,7 @@ def setupUi(self, CoyoteSettingsWidget): self.freq_min_b.setRange(10, 500) self.freq_min_b.setValue(settings.coyote_channel_b_freq_min.get()) self.freq_min_b.setSingleStep(10) - freq_min_b_controls.addWidget(QLabel("Min (Hz)")) + freq_min_b_controls.addWidget(QLabel("Min Freq (Hz)")) freq_min_b_controls.addWidget(self.freq_min_b) freq_max_b_controls = QHBoxLayout() @@ -875,7 +875,7 @@ def setupUi(self, CoyoteSettingsWidget): self.freq_max_b.setRange(10, 500) self.freq_max_b.setValue(settings.coyote_channel_b_freq_max.get()) self.freq_max_b.setSingleStep(10) - freq_max_b_controls.addWidget(QLabel("Max (Hz)")) + freq_max_b_controls.addWidget(QLabel("Max Freq (Hz)")) freq_max_b_controls.addWidget(self.freq_max_b) # Max strength controls for Channel B From d4ff3626b02f37a39d7d3ad20d223878f233417f Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Sat, 18 Oct 2025 14:20:38 +0700 Subject: [PATCH 31/47] Coyote UI: refactor settings widget --- qt_ui/coyote_settings_widget.py | 1067 +++++++++---------------------- 1 file changed, 318 insertions(+), 749 deletions(-) diff --git a/qt_ui/coyote_settings_widget.py b/qt_ui/coyote_settings_widget.py index 262813f..8ea2445 100644 --- a/qt_ui/coyote_settings_widget.py +++ b/qt_ui/coyote_settings_widget.py @@ -1,18 +1,293 @@ import asyncio import time -import numpy as np -from PySide6 import QtCore, QtWidgets -from PySide6.QtWidgets import (QWidget, QVBoxLayout, QLabel, QSlider, QHBoxLayout, - QGraphicsView, QGraphicsScene, QGraphicsLineItem, QDoubleSpinBox, QSpinBox, - QGraphicsRectItem, QToolTip, QGraphicsItem, QGraphicsEllipseItem, QGraphicsPathItem) -from PySide6.QtCore import Qt, QTimer, QPointF, QRectF +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 Qt, QTimer from PySide6.QtGui import QPen, QColor, QBrush, QPainterPath from device.coyote.device import CoyoteDevice, CoyotePulse, CoyotePulses, CoyoteStrengths from qt_ui import settings -# Channel color constants for use throughout the UI -CHANNEL_A_COLOR = QColor(160, 90, 255) # Purple -CHANNEL_B_COLOR = QColor(255, 170, 50) # Orange +class CoyoteSettingsWidget(QtWidgets.QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.device: Optional[CoyoteDevice] = None + self.channel_controls: Dict[str, ChannelControl] = {} + self.setupUi() + + def setupUi(self): + self.setLayout(QVBoxLayout()) + + self.label_connection_status = QLabel("Disconnected") + self.label_connection_stage = QLabel("") + self.label_battery_level = QLabel("") + 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("Connected" if connected else "Disconnected") + if stage: + 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) + +@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.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, freq_min: QSpinBox, freq_max: QSpinBox, *args, **kwargs): @@ -20,26 +295,26 @@ def __init__(self, freq_min: QSpinBox, freq_max: QSpinBox, *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 = 3.0 # Match the graph's time window - + # Create layout self.layout = QVBoxLayout(self) - + # Create plot widget self.plot = PulseGraph(*args, **kwargs) - - # Create and setup label - self.label = QLabel("Intensity: 0%\nFrequency: 0 Hz") - self.label.setAlignment(Qt.AlignCenter) - - # Add widgets to layout self.layout.addWidget(self.plot) - self.layout.addWidget(self.label) + + # 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.""" @@ -89,8 +364,8 @@ def update_label_text(self): intensities = [entry.intensity for entry in recent_entries] intensity_text = self.format_intensity_text(intensities) - # Update label with frequency and intensity information - self.label.setText(f"Intensity: {intensity_text}\nFrequency: {freq_text}") + 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 @@ -117,33 +392,6 @@ def add_pulse(self, frequency, intensity, duration, current_strength, channel_li # Update the plot - even zero intensity pulses are sent through for visualization self.plot.add_pulse(pulse, effective_intensity, channel_limit) -# Create a custom graphics rect item with hover capability -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) - class PulseGraph(QWidget): def __init__(self, parent=None): super().__init__(parent) @@ -404,708 +652,29 @@ def refresh(self): tick.setPen(QPen(QColor("white"), 1)) self.scene.addItem(tick) -class EnvelopeGraph(QWidget): - """ - Displays a dynamic visualization of how the envelope pattern affects pulses. - """ - def __init__(self, parent=None): - super().__init__(parent) - self.setLayout(QVBoxLayout()) - - self.view = QGraphicsView() - self.scene = QGraphicsScene() - self.view.setScene(self.scene) - - # Disable scrolling and user interaction - self.view.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.view.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - self.view.setInteractive(True) # Enable for tooltip hover events - self.view.setDragMode(QGraphicsView.NoDrag) - self.view.setViewportUpdateMode(QGraphicsView.SmartViewportUpdate) - - self.layout().addWidget(self.view) - - # Period preview mode: not using an envelope curve - self.envelope_data = np.array([]) - self.envelope_period = 0.0 - - # Store recent pulses for overlay - self.recent_pulses = [] - # Keep enough pulses to cover ~2s even at higher rates (both channels) - self.max_pulses = 400 - self.max_pulse_age = 2.0 # seconds - - # Colors for visualization - self.envelope_color = QColor(0, 180, 255, 150) - self.pulse_colors = [CHANNEL_A_COLOR, CHANNEL_B_COLOR] - self.line_colors = [QColor(170, 120, 255, 200), QColor(255, 190, 80, 200)] - - # Add margin to avoid clipping - self.margin = 20 - - # Simplified timer - self.timer = QTimer(self) - self.timer.timeout.connect(self.refresh) - self.timer.start(100) # 10fps - - # Initialize scene size - self.updateSceneRect() - - def resizeEvent(self, event): - """Handle resize events by updating the scene rectangle""" - super().resizeEvent(event) - self.updateSceneRect() - - 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 setEnvelopeData(self, envelope_data, envelope_period): - """ - Set new envelope data to display - - Args: - envelope_data: numpy array of envelope values (0 to 1) - 0 = minimum envelope, 1 = maximum envelope - envelope_period: period of the envelope in seconds (duration of one full envelope cycle) - """ - if envelope_data is None or not isinstance(envelope_data, np.ndarray) or len(envelope_data) == 0: - return - - # Make a copy to avoid reference issues - self.envelope_data = np.array(envelope_data).copy() - - # Make sure we have valid data - if np.isnan(self.envelope_data).any() or np.isinf(self.envelope_data).any(): - self.envelope_data = np.nan_to_num(self.envelope_data) - - # Validate period - if envelope_period > 0: - self.envelope_period = envelope_period - - def addPulse(self, channel_idx, intensity, duration, timestamp=None): - """ - Add a pulse to visualize - - Args: - channel_idx: 0 for channel A, 1 for channel B - intensity: Pulse intensity (0-100) - duration: Pulse duration in ms - timestamp: When the pulse occurred (defaults to now) - """ - if timestamp is None: - timestamp = time.time() - - # Create a new pulse entry - new_pulse = { - 'channel': channel_idx, - 'intensity': intensity, - 'duration': duration, - 'timestamp': timestamp - } - - # Add to beginning for efficient removal of old ones - self.recent_pulses.insert(0, new_pulse) - - # Limit the number of pulses - while len(self.recent_pulses) > self.max_pulses: - self.recent_pulses.pop() - - def refresh(self): - """Simple redraw method without any complex error handling""" - # Skip if not visible - if not self.isVisible(): - return - - # Get current time for pulse age calculations - current_time = time.time() - - # Clean old pulses first - self.recent_pulses = [p for p in self.recent_pulses if current_time - p['timestamp'] <= self.max_pulse_age] - - # Clear scene - self.scene.clear() - - # Get dimensions - width = self.view.viewport().width() - height = self.view.viewport().height() - - # Calculate scaling for [0, 1] envelope (y=0 at bottom, y=1 at top) - graph_top = self.margin - graph_bottom = height - self.margin - graph_height = graph_bottom - graph_top - - # Draw simple grid - self._drawGrid(width, height, graph_top, graph_bottom) - - # No envelope curve in period preview mode - - # Draw pulses - self._drawPulses(width, height, graph_top, graph_bottom, graph_height, current_time) - - # Draw frequency label - self._drawFrequencyLabel(width, height) - - def _drawGrid(self, width, height, graph_top, graph_bottom): - # Simple horizontal lines at 0, 0.5, 1 and vertical lines every 0.5 s - pen = QPen(QColor(60, 60, 60)) - pen.setStyle(Qt.DashLine) - self.scene.addLine(self.margin, graph_bottom, width - self.margin, graph_bottom, pen) - self.scene.addLine(self.margin, (graph_top + graph_bottom)/2, width - self.margin, (graph_top + graph_bottom)/2, pen) - self.scene.addLine(self.margin, graph_top, width - self.margin, graph_top, pen) - # Vertical ticks - usable_width = width - 2 * self.margin - n_ticks = 4 - for i in range(1, n_ticks): - x = self.margin + (i / n_ticks) * usable_width - self.scene.addLine(x, graph_top, x, graph_bottom, pen) - - def _drawEnvelope(self, width, height, graph_top, graph_bottom, graph_height): - """Draw envelope curve (0 at bottom, 1 at top)""" - if len(self.envelope_data) == 0: - return - usable_width = width - 2 * self.margin - path = QPainterPath() - num_points = min(len(self.envelope_data), int(usable_width / 3)) - if num_points < 2: - return - step = (len(self.envelope_data) - 1) / (num_points - 1) - x = self.margin - y = graph_bottom - (self.envelope_data[0] * graph_height) - path.moveTo(x, y) - for i in range(1, num_points): - x = self.margin + (i / (num_points - 1)) * usable_width - idx = int(i * step) - if idx >= len(self.envelope_data): - idx = len(self.envelope_data) - 1 - y = graph_bottom - (self.envelope_data[idx] * graph_height) - path.lineTo(x, y) - pen = QPen(self.envelope_color, 2) - self.scene.addPath(path, pen) - - def _drawPulses(self, width, height, graph_top, graph_bottom, graph_height, current_time): - """Draw pulse dots using normalized duration and time window""" - if not self.recent_pulses: - return - usable_width = width - 2 * self.margin - # Drop old pulses - cutoff = current_time - self.max_pulse_age - self.recent_pulses = [p for p in self.recent_pulses if p['timestamp'] >= cutoff] - # Split pulses by channel and build polylines (newest on right) - series = {0: [], 1: []} - step = max(1, int(len(self.recent_pulses) / 300)) - for i, p in enumerate(self.recent_pulses): - if i % step != 0: - continue - age = current_time - p['timestamp'] - frac = max(0.0, min(1.0, 1.0 - age / self.max_pulse_age)) - x = self.margin + frac * usable_width - norm = p.get('norm', 0.5) - y = graph_bottom - (norm * graph_height) - series[p['channel']].append((x, y, p)) - - for ch in (0, 1): - pts = series[ch] - if len(pts) < 2: - continue - # Sort by x to draw from left to right - pts.sort(key=lambda t: t[0]) - path = QPainterPath() - path.moveTo(pts[0][0], pts[0][1]) - for x, y, _ in pts[1:]: - path.lineTo(x, y) - pen = QPen(self.line_colors[ch], 2) - self.scene.addPath(path, pen) - - # Draw dots on top with intensity-sized markers - for x, y, p in pts: - size = 3 + (9 * p['intensity'] / 100.0) - dot = QGraphicsEllipseItem(x - size/2, y - size/2, size, size) - dot.setBrush(QBrush(self.pulse_colors[ch])) - dot.setPen(QPen(Qt.NoPen)) - hz = 0 if p['duration'] <= 0 else int(round(1000.0 / p['duration'])) - dot.setToolTip(f"Channel: {'A' if ch == 0 else 'B'}\n" - f"Intensity: {p['intensity']}%\n" - f"Duration: {p['duration']} ms ({hz} Hz)\n" - f"Normalized: {p.get('norm', 0.5):.2f}") - self.scene.addItem(dot) - - # Right-side labels: current freq estimate per channel - for ch in (0, 1): - pts = series[ch] - if not pts: - continue - _, _, last = pts[-1] - hz = 0 if last['duration'] <= 0 else int(round(1000.0 / last['duration'])) - label = self.scene.addText(f"{'A' if ch == 0 else 'B'}: {hz} Hz") - label.setDefaultTextColor(self.pulse_colors[ch]) - label.setPos(width - self.margin - 80, graph_top + ch * 18) - - def _drawFrequencyLabel(self, width, height): - """Draw frequency information""" - # No frequency label in period preview mode - -class EnvelopeGraphContainer(QWidget): - """ - Container for the envelope graph with title and labels. - """ - def __init__(self, parent=None): - super().__init__(parent) - - # Create layout - self.layout = QVBoxLayout(self) - self.layout.setContentsMargins(0, 0, 0, 0) # Remove margins for better alignment - - # Add top controls row - top_row = QHBoxLayout() - - # Add description - self.description = QLabel("Pulse Period Preview (last 2 s)") - self.description.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) - top_row.addWidget(self.description) - - # Add spacer - top_row.addStretch(1) - - # No waveform selector in period preview mode - - self.layout.addLayout(top_row) - - # Stats row - stats_row = QHBoxLayout() - self.statsA = QLabel("A: —") - self.statsB = QLabel("B: —") - self.statsA.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) - self.statsB.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) - stats_row.addWidget(self.statsA) - stats_row.addStretch(1) - stats_row.addWidget(self.statsB) - self.layout.addLayout(stats_row) - - # Create envelope graph - self.graph = EnvelopeGraph() - self.layout.addWidget(self.graph) - - # We don't need a complex buffer or cleanup timer since - # the graph now handles its own pulse cleanup and display - self.received_real_data = False - - def setEnvelopeData(self, envelope_data, envelope_period): - # Not used in period preview mode - pass - - def addPulse(self, channel_id, intensity, duration, strength=100, min_hz=None, max_hz=None): - """ - Add a pulse to the visualization - - Args: - channel_id: 'A' or 'B' - intensity: Pulse intensity - duration: Pulse duration in ms - strength: Current channel strength (0-100) - """ - # Calculate effective intensity - effective_intensity = intensity * (strength / 100) - - # Skip very low intensity pulses - if effective_intensity < 1: - return - - # Convert channel ID to index (0 for A, 1 for B) - channel_idx = 0 if channel_id == 'A' else 1 - - # Compute normalized duration using provided channel range - if min_hz and max_hz and max_hz > min_hz and min_hz > 0 and max_hz > 0: - d_min = 1000.0 / max_hz - d_max = 1000.0 / min_hz - norm = (duration - d_min) / max(1e-6, (d_max - d_min)) - norm = max(0.0, min(1.0, norm)) - else: - norm = 0.5 - # Add to graph and attach normalized value - self.graph.addPulse(channel_idx, intensity, duration) - if self.graph.recent_pulses: - self.graph.recent_pulses[0]['norm'] = norm - # Update stats after each pulse - self.updateStats() - - def updateStats(self): - def fmt_stats(ch): - pulses = [p for p in self.graph.recent_pulses if p['channel'] == ch] - if not pulses: - return "—" - # Current from most recent - now_hz = int(round(1000.0 / pulses[0]['duration'])) if pulses[0]['duration'] > 0 else 0 - # Compute frequency and period arrays - hz = [1000.0 / p['duration'] for p in pulses if p['duration'] > 0] - if not hz: - return f"{'A' if ch == 0 else 'B'}: —" - avg_hz = sum(hz) / len(hz) - min_hz = int(min(hz)) - max_hz = int(max(hz)) - # Period jitter (% of mean period) - periods = [p['duration'] for p in pulses] - mu = sum(periods) / len(periods) - if len(periods) > 1: - var = sum((x - mu) ** 2 for x in periods) / (len(periods) - 1) - sd = var ** 0.5 - jitter = int(round((sd / mu) * 100)) if mu > 0 else 0 - else: - jitter = 0 - return f"{'A' if ch == 0 else 'B'}: {now_hz} Hz • avg {int(round(avg_hz))} ({min_hz}–{max_hz}) • jitter {jitter}% • n={len(pulses)}" - - self.statsA.setText(fmt_stats(0)) - self.statsB.setText(fmt_stats(1)) - -class CoyoteSettingsWidget(QtWidgets.QWidget): - def __init__(self, parent=None): - super().__init__(parent) - - self.setupUi(self) - - # Always initialize volume sliders to 0 (non-persistent) - self.volume_a_slider.setValue(0) - self.volume_b_slider.setValue(0) - - # Connect signals - self.volume_a_slider.valueChanged.connect(self.update_channel_a) - self.volume_b_slider.valueChanged.connect(self.update_channel_b) - self.freq_min_a.valueChanged.connect(self.update_freq_min_a) - self.freq_max_a.valueChanged.connect(self.update_freq_max_a) - self.freq_min_b.valueChanged.connect(self.update_freq_min_b) - self.freq_max_b.valueChanged.connect(self.update_freq_max_b) - self.strength_max_a.valueChanged.connect(self.update_strength_max_a) - self.strength_max_b.valueChanged.connect(self.update_strength_max_b) - - def setupUi(self, CoyoteSettingsWidget): - self.setLayout(QVBoxLayout()) - - # Connection/Battery Status - self.label_connection_status = QLabel("Disconnected") - self.label_connection_stage = QLabel("") - self.label_battery_level = QLabel("") - 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) - - # No envelope/period preview section - - # Channel A Row - channel_a_layout = QHBoxLayout() - - # Left side layout for Channel A (label and frequency controls) - channel_a_left = QVBoxLayout() - channel_a_label = QLabel("Channel A") - - # Frequency controls in horizontal layouts - freq_min_a_controls = QHBoxLayout() - self.freq_min_a = QSpinBox() - self.freq_min_a.setRange(10, 500) - self.freq_min_a.setValue(settings.coyote_channel_a_freq_min.get()) - self.freq_min_a.setSingleStep(10) - freq_min_a_controls.addWidget(QLabel("Min Freq (Hz)")) - freq_min_a_controls.addWidget(self.freq_min_a) - - freq_max_a_controls = QHBoxLayout() - self.freq_max_a = QSpinBox() - self.freq_max_a.setRange(10, 500) - self.freq_max_a.setValue(settings.coyote_channel_a_freq_max.get()) - self.freq_max_a.setSingleStep(10) - freq_max_a_controls.addWidget(QLabel("Max Freq (Hz)")) - freq_max_a_controls.addWidget(self.freq_max_a) - - # Max strength controls for Channel A - strength_max_a_controls = QHBoxLayout() - strength_max_a_controls.addWidget(QLabel("Max Strength")) - self.strength_max_a = QSpinBox() - self.strength_max_a.setRange(1, 200) - self.strength_max_a.setValue(settings.coyote_channel_a_strength_max.get()) - self.strength_max_a.setSingleStep(1) - self.strength_max_a.valueChanged.connect(self.update_strength_max_a) - strength_max_a_controls.addWidget(self.strength_max_a) - - channel_a_left.addWidget(channel_a_label) - channel_a_left.addLayout(freq_min_a_controls) - channel_a_left.addLayout(freq_max_a_controls) - channel_a_left.addLayout(strength_max_a_controls) - - # Pulse graph for Channel A - self.pulse_graph_a = PulseGraphContainer(self.freq_min_a, self.freq_max_a) - self.pulse_graph_a.plot.setMinimumHeight(100) - - # Volume slider layout for Channel A - volume_a_layout = QVBoxLayout() - self.volume_a_label = QLabel("0 (0%)") - self.volume_a_label.setAlignment(Qt.AlignHCenter) - self.volume_a_slider = QSlider(Qt.Vertical) - self.volume_a_slider.setRange(0, settings.coyote_channel_a_strength_max.get()) - self.volume_a_slider.valueChanged.connect(self.update_volume_a_label) - volume_a_layout.addWidget(self.volume_a_slider) - volume_a_layout.addWidget(self.volume_a_label) - - channel_a_layout.addLayout(channel_a_left) - channel_a_layout.addWidget(self.pulse_graph_a) - channel_a_layout.addLayout(volume_a_layout) - - self.layout().addLayout(channel_a_layout) - - # Channel B Row - channel_b_layout = QHBoxLayout() - - # Left side layout for Channel B (label and frequency controls) - channel_b_left = QVBoxLayout() - channel_b_label = QLabel("Channel B") - - # Frequency controls in horizontal layouts - freq_min_b_controls = QHBoxLayout() - self.freq_min_b = QSpinBox() - self.freq_min_b.setRange(10, 500) - self.freq_min_b.setValue(settings.coyote_channel_b_freq_min.get()) - self.freq_min_b.setSingleStep(10) - freq_min_b_controls.addWidget(QLabel("Min Freq (Hz)")) - freq_min_b_controls.addWidget(self.freq_min_b) - - freq_max_b_controls = QHBoxLayout() - self.freq_max_b = QSpinBox() - self.freq_max_b.setRange(10, 500) - self.freq_max_b.setValue(settings.coyote_channel_b_freq_max.get()) - self.freq_max_b.setSingleStep(10) - freq_max_b_controls.addWidget(QLabel("Max Freq (Hz)")) - freq_max_b_controls.addWidget(self.freq_max_b) - - # Max strength controls for Channel B - strength_max_b_controls = QHBoxLayout() - strength_max_b_controls.addWidget(QLabel("Max Strength")) - self.strength_max_b = QSpinBox() - self.strength_max_b.setRange(1, 200) - self.strength_max_b.setValue(settings.coyote_channel_b_strength_max.get()) - self.strength_max_b.setSingleStep(1) - self.strength_max_b.valueChanged.connect(self.update_strength_max_b) - strength_max_b_controls.addWidget(self.strength_max_b) - - channel_b_left.addWidget(channel_b_label) - channel_b_left.addLayout(freq_min_b_controls) - channel_b_left.addLayout(freq_max_b_controls) - channel_b_left.addLayout(strength_max_b_controls) - - # Pulse graph for Channel B - self.pulse_graph_b = PulseGraphContainer(self.freq_min_b, self.freq_max_b) - self.pulse_graph_b.plot.setMinimumHeight(100) - - # Volume slider layout for Channel B - volume_b_layout = QVBoxLayout() - self.volume_b_label = QLabel("0 (0%)") - self.volume_b_label.setAlignment(Qt.AlignHCenter) - self.volume_b_slider = QSlider(Qt.Vertical) - self.volume_b_slider.setRange(0, settings.coyote_channel_b_strength_max.get()) - self.volume_b_slider.valueChanged.connect(self.update_volume_b_label) - volume_b_layout.addWidget(self.volume_b_slider) - volume_b_layout.addWidget(self.volume_b_label) - - channel_b_layout.addLayout(channel_b_left) - channel_b_layout.addWidget(self.pulse_graph_b) - channel_b_layout.addLayout(volume_b_layout) - - self.layout().addLayout(channel_b_layout) - - def setup_device(self, device: CoyoteDevice): - self.device = device - - # Connect device signals - 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) - - # Initialize labels - self.update_volume_a_label(0) - self.update_volume_b_label(0) - - # If we are already connected to a device, initialize with its values - if device.strengths: - self.update_channel_a(0) - self.update_channel_b(0) - - # No envelope preview polling - - def update_channel_a(self, value): - """Update channel A strength (volume) in the device.""" - if self.device._event_loop: - # value is already the actual strength value (not a percentage) - asyncio.run_coroutine_threadsafe( - self.device.send_command(CoyoteStrengths(value, self.device.strengths.channel_b)), - self.device._event_loop - ) - - def update_channel_b(self, value): - """Update channel B strength (volume) in the device.""" - if self.device._event_loop: - # value is already the actual strength value (not a percentage) - asyncio.run_coroutine_threadsafe( - self.device.send_command(CoyoteStrengths(self.device.strengths.channel_a, value)), - self.device._event_loop - ) - - def on_connection_status_changed(self, connected: bool, stage: str = None): - """Update connection status and stage in UI""" - self.label_connection_status.setText("Connected" if connected else "Disconnected") - if stage: - self.label_connection_stage.setText(stage) - # Enable/disable sliders based on connection status - # self.volume_a_slider.setEnabled(connected) - # self.volume_b_slider.setEnabled(connected) - - def on_battery_level_changed(self, level: int): - """Update battery level display""" - self.label_battery_level.setText(f"Battery: {level}%") - - def on_parameters_changed(self): - """Update UI when device parameters change""" - self.volume_a_slider.blockSignals(True) - self.volume_b_slider.blockSignals(True) - - # self.volume_a_slider.setValue(self.device.parameters.channel_a_intensity_balance) - # self.volume_b_slider.setValue(self.device.parameters.channel_b_intensity_balance) - - self.volume_a_slider.blockSignals(False) - self.volume_b_slider.blockSignals(False) - - def on_power_levels_changed(self, strengths: CoyoteStrengths): - """Update sliders when device power levels change""" - self.volume_a_slider.blockSignals(True) - self.volume_b_slider.blockSignals(True) - - self.volume_a_slider.setValue(strengths.channel_a) - self.volume_b_slider.setValue(strengths.channel_b) - - self.volume_a_slider.blockSignals(False) - self.volume_b_slider.blockSignals(False) - - # Update labels - self.update_volume_a_label(strengths.channel_a) - self.update_volume_b_label(strengths.channel_b) - - def on_pulse_sent(self, pulses: CoyotePulses): - # Update Channel A - if pulses.channel_a: - # Get the actual strength value - strength_a = self.device.strengths.channel_a - # Get the max strength from settings - max_strength_a = settings.coyote_channel_a_strength_max.get() - - for pulse in pulses.channel_a: - # Calculate effective intensity - effective_intensity = pulse.intensity * (strength_a / 100) - - self.pulse_graph_a.add_pulse( - frequency=pulse.frequency, - intensity=pulse.intensity, - duration=pulse.duration, - current_strength=strength_a, - channel_limit=max_strength_a - ) - - # No envelope preview - - # Update Channel B - if pulses.channel_b: - # Get the actual strength value - strength_b = self.device.strengths.channel_b - # Get the max strength from settings - max_strength_b = settings.coyote_channel_b_strength_max.get() - - for pulse in pulses.channel_b: - # Calculate effective intensity - effective_intensity = pulse.intensity * (strength_b / 100) - - self.pulse_graph_b.add_pulse( - frequency=pulse.frequency, - intensity=pulse.intensity, - duration=pulse.duration, - current_strength=strength_b, - channel_limit=max_strength_b - ) - - # No envelope preview - - def update_freq_min_a(self, value): - """Update minimum frequency for channel A""" - if value >= self.freq_max_a.value(): - self.freq_min_a.setValue(self.freq_max_a.value() - 10) - else: - settings.coyote_channel_a_freq_min.set(value) - - def update_freq_max_a(self, value): - """Update maximum frequency for channel A""" - if value <= self.freq_min_a.value(): - self.freq_max_a.setValue(self.freq_min_a.value() + 10) - else: - settings.coyote_channel_a_freq_max.set(value) - - def update_freq_min_b(self, value): - """Update minimum frequency for channel B""" - if value >= self.freq_max_b.value(): - self.freq_min_b.setValue(self.freq_max_b.value() - 10) - else: - settings.coyote_channel_b_freq_min.set(value) - - def update_freq_max_b(self, value): - """Update maximum frequency for channel B""" - if value <= self.freq_min_b.value(): - self.freq_max_b.setValue(self.freq_min_b.value() + 10) - else: - settings.coyote_channel_b_freq_max.set(value) - - def update_volume_a_label(self, value): - # Calculate percentage based on max strength - percentage = int((value / max(1, settings.coyote_channel_a_strength_max.get())) * 100) - self.volume_a_label.setText(f"{value} ({percentage}%)") - - def update_volume_b_label(self, value): - # Calculate percentage based on max strength - percentage = int((value / max(1, settings.coyote_channel_b_strength_max.get())) * 100) - self.volume_b_label.setText(f"{value} ({percentage}%)") - - def update_strength_max_a(self, value): - """Update max strength for channel A and save to settings.""" - settings.coyote_channel_a_strength_max.set(value) - - # Update volume slider range - current_value = self.volume_a_slider.value() - self.volume_a_slider.setRange(0, value) - - # Update the volume label to reflect the new max strength - self.update_volume_a_label(current_value) - - # If the current value exceeds the new max, cap it - if current_value > value: - self.volume_a_slider.setValue(value) +class PulseRectItem(QGraphicsRectItem): + def __init__(self, x, y, width, height, pulse): + super().__init__(x, y, width, height) + self.pulse = pulse + self.setAcceptHoverEvents(True) - # Send updated strength to device - self.update_channel_a(self.volume_a_slider.value()) + def hoverEnterEvent(self, event): + # Show tooltip with pulse information + freq = self.pulse.frequency + intensity = self.pulse.intensity + duration = self.pulse.duration - def update_strength_max_b(self, value): - """Update max strength for channel B and save to settings.""" - settings.coyote_channel_b_strength_max.set(value) + tooltip_text = f"Frequency: {freq} Hz\nIntensity: {intensity}%\nDuration: {duration} ms" + QToolTip.showText(event.screenPos(), tooltip_text) - # Update volume slider range - current_value = self.volume_b_slider.value() - self.volume_b_slider.setRange(0, value) + # Change appearance on hover + current_pen = self.pen() + current_pen.setWidth(2) # Make border thicker + self.setPen(current_pen) - # Update the volume label to reflect the new max strength - self.update_volume_b_label(current_value) + def hoverLeaveEvent(self, event): + # Restore original appearance + current_pen = self.pen() + current_pen.setWidth(1) # Restore original border width + self.setPen(current_pen) - # If the current value exceeds the new max, cap it - if current_value > value: - self.volume_b_slider.setValue(value) - - # Send updated strength to device - self.update_channel_b(self.volume_b_slider.value()) - - # No envelope preview From c8945f6f39403888df903ffaf3e1e157208d0099 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Sat, 18 Oct 2025 16:20:58 +0700 Subject: [PATCH 32/47] Coyote: improve configurable debug logging --- designer/preferencesdialog.ui | 14 ++++ device/coyote/device.py | 123 +++++++++++++++++--------------- qt_ui/coyote_settings_widget.py | 25 +++++-- qt_ui/preferences_dialog.py | 15 +++- qt_ui/preferences_dialog_ui.py | 13 +++- qt_ui/settings.py | 1 + 6 files changed, 128 insertions(+), 63 deletions(-) diff --git a/designer/preferencesdialog.ui b/designer/preferencesdialog.ui index b5d70aa..3a2eb48 100644 --- a/designer/preferencesdialog.ui +++ b/designer/preferencesdialog.ui @@ -741,6 +741,20 @@ + + + + Debug logging + + + + + + + + + +
diff --git a/device/coyote/device.py b/device/coyote/device.py index 4a9186a..b4d2728 100644 --- a/device/coyote/device.py +++ b/device/coyote/device.py @@ -12,6 +12,7 @@ from PySide6.QtCore import QObject, Signal logger = logging.getLogger('restim.coyote') +LOG_PREFIX = "[Coyote]" # Coyote BLE UUIDs BATTERY_SERVICE_UUID = "0000180A-0000-1000-8000-00805f9b34fb" @@ -101,7 +102,7 @@ def _start_connection_loop(self): asyncio.set_event_loop(loop) def run_loop(): - logger.info("Starting asyncio loop thread") + logger.info(f"{LOG_PREFIX} Starting asyncio loop thread") loop.run_until_complete(self._connection_loop()) loop.run_forever() @@ -109,69 +110,73 @@ def run_loop(): async def _connection_loop(self): """Main connection loop that runs the state machine""" - logger.info("Starting connection loop") + 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("Device disconnected unexpectedly") + logger.warning(f"{LOG_PREFIX} Device disconnected unexpectedly") await self.disconnect() continue if self.connection_stage == ConnectionStage.DISCONNECTED: - logger.info("Starting connection process") + 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(): - logger.info("Device found, connecting...") + attempt_counter = 0 + logger.info(f"{LOG_PREFIX} Device found, connecting...") self.connection_stage = ConnectionStage.CONNECTING else: - logger.info("Device not found, retrying in 5 seconds...") + attempt_counter += 1 + logger.info(f"{LOG_PREFIX} Device not found (attempt {attempt_counter}); retrying in 5 seconds...") await asyncio.sleep(5) elif self.connection_stage == ConnectionStage.CONNECTING: if await self.client.connect(): - logger.info("Connected, discovering services...") + logger.info(f"{LOG_PREFIX} Connected, discovering services...") self.connection_stage = ConnectionStage.SERVICE_DISCOVERY else: - logger.error("Connection failed") + logger.error(f"{LOG_PREFIX} Connection failed") await self.disconnect() elif self.connection_stage == ConnectionStage.SERVICE_DISCOVERY: if await self.client.get_services(): - logger.info("Services discovered, subscribing to battery...") + logger.info(f"{LOG_PREFIX} Services discovered, subscribing to battery...") self.connection_stage = ConnectionStage.BATTERY_SUBSCRIBE else: - logger.error("Service discovery failed") + 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("Battery subscribed, subscribing to status...") + logger.info(f"{LOG_PREFIX} Battery subscribed, subscribing to status...") self.connection_stage = ConnectionStage.STATUS_SUBSCRIBE else: - logger.error("Battery subscription failed") + 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("Status subscribed, syncing parameters...") + logger.info(f"{LOG_PREFIX} Status subscribed, syncing parameters...") self.connection_stage = ConnectionStage.SYNC_PARAMETERS else: - logger.error("Status subscription failed") + 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("Parameters synced, connection complete") + 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("Parameter sync failed") + logger.error(f"{LOG_PREFIX} Parameter sync failed") await self.disconnect() elif self.connection_stage == ConnectionStage.CONNECTED: @@ -185,7 +190,7 @@ async def _connection_loop(self): prev_stage = self.connection_stage except Exception as e: - logger.error(f"Connection loop error: {e}") + logger.error(f"{LOG_PREFIX} Connection loop error: {e}") # raise e await self.disconnect() @@ -193,25 +198,25 @@ async def _connection_loop(self): await asyncio.sleep(0.1) def start_updates(self, algorithm: Optional[any]): - logger.info("start_updates called") + logger.info(f"{LOG_PREFIX} start_updates called") self.algorithm = algorithm self.running = True future = None if self._event_loop: - logger.info("scheduling update_loop in 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("No event loop present!") + logger.error(f"{LOG_PREFIX} No event loop present!") if future: - logger.info("Future scheduled") + logger.info(f"{LOG_PREFIX} Future scheduled") else: - logger.warning("Update loop not scheduled") + logger.warning(f"{LOG_PREFIX} Update loop not scheduled") def stop_updates(self): """Stop the update loop but maintain connection""" - logger.info("Stopping updates") + logger.info(f"{LOG_PREFIX} Stopping updates") self.running = False self.algorithm = None @@ -219,7 +224,7 @@ async def _handle_battery_notification(self, sender, data: bytearray): """Handle battery level notifications""" battery_level = data[0] - logger.info(f"Battery level notification received: {battery_level}%") + logger.info(f"{LOG_PREFIX} Battery level notification received: {battery_level}%") self.battery_level = battery_level self.battery_level_changed.emit(battery_level) @@ -228,7 +233,7 @@ async def _handle_status_notification(self, sender, data: bytearray): """Handle incoming status notifications from the device.""" if not data: - logger.warning("Received empty status notification") + logger.warning(f"{LOG_PREFIX} Received empty status notification") return # if len(data) != 4: @@ -241,23 +246,23 @@ async def _handle_status_notification(self, sender, data: bytearray): power_b = data[3] if command_id == 0xB1: - logger.debug(f"Power level update (seq={sequence_number}) - Channel A: {power_a}, Channel B: {power_b}") + logger.debug(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 == 0x51: - logger.debug(f"Command acknowledged (seq={sequence_number})") + logger.debug(f"{LOG_PREFIX} Command acknowledged (seq={sequence_number})") elif command_id == 0x53: if len(data) < 4: - logger.warning(f"Malformed active power notification: {list(data)}") + logger.warning(f"{LOG_PREFIX} Malformed active power notification: {list(data)}") return power_a = data[2] power_b = data[3] - logger.debug(f"Active power update - Channel A: {power_a}, Channel B: {power_b}") + logger.debug(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 @@ -268,13 +273,13 @@ async def _handle_status_notification(self, sender, data: bytearray): # logger.debug(f"Extra fields in 0x53 notification (undocumented): {list(extra)}") else: - logger.warning(f"Unknown notification type: 0x{command_id:02X} (seq={sequence_number})") - logger.debug(f"Raw notification: {list(data)}") + logger.warning(f"{LOG_PREFIX} Unknown notification type: 0x{command_id:02X} (seq={sequence_number})") + logger.debug(f"{LOG_PREFIX} Raw notification: {list(data)}") async def _send_parameters(self): """Send device parameters""" logger.info( - f"Syncing parameters - " + 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}" @@ -294,7 +299,7 @@ async def _send_parameters(self): await self.client.write_gatt_char(WRITE_CHAR_UUID, command) return True except Exception as e: - logger.error(f"Failed to sync parameters: {str(e)}") + logger.error(f"{LOG_PREFIX} Failed to sync parameters: {e}") return False async def _subscribe_to_notifications(self, char_uuid: str) -> bool: @@ -305,25 +310,24 @@ async def _subscribe_to_notifications(self, char_uuid: str) -> bool: else self._handle_status_notification) return True except Exception as e: - logger.error(f"Failed to subscribe to {char_uuid}: {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"Scanning for device: {self.device_name}") + 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"Found device: {device.name} ({device.address})") + logger.info(f"{LOG_PREFIX} Found device: {device.name} ({device.address})") self.client = BleakClient(device) self.connection_stage = ConnectionStage.CONNECTING return True else: - logger.warning(f"Device not found: {self.device_name}") - await self.disconnect() + logger.debug(f"{LOG_PREFIX} No BLE advertisement for {self.device_name} detected during scan window") return False except Exception as e: - logger.error(f"Scan error: {str(e)}") + logger.error(f"{LOG_PREFIX} Scan error: {e}") await self.disconnect() return False @@ -352,7 +356,7 @@ async def send_command(self, return if not strengths and not pulses: - logger.warning("send_command called with no data") + logger.warning(f"{LOG_PREFIX} send_command called with no data") return # Determine strength interpretation (default absolute set if new strength provided) @@ -385,25 +389,32 @@ async def send_command(self, command.extend([0] * 16) # No pulses = zero padding # Log what we're sending - logger.info(f"Sending command (seq={self.sequence_number}): ") - # f"Channel A = {strengths.channel_a if strengths else self.strengths.channel_a}, " - # f"Channel B = {strengths.channel_b if strengths else 'N/A'}") + logger.info(f"{LOG_PREFIX} Sending command (seq={self.sequence_number}):") - if pulses: - pulses_a = "\n".join([f" Pulse {i+1}: Freq={a.frequency} Hz, Intensity={a.intensity}" for i, a in enumerate(pulses.channel_a)]) - pulses_b = "\n".join([f" Pulse {i+1}: Freq={b.frequency} Hz, Intensity={b.intensity}" for i, b in enumerate(pulses.channel_b)]) - logger.debug(f"Channel A ({self.strengths.channel_a}):\n{pulses_a}\nChannel B ({self.strengths.channel_b}):\n{pulses_b}") + if pulses and logger.isEnabledFor(logging.DEBUG): + 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) % 16 # Wrap seq at 4 bits (0-15) except Exception as e: - logger.error(f"Failed to send command: {e}") + logger.error(f"{LOG_PREFIX} Failed to send command: {e}") async def disconnect(self): """Disconnect from device""" - logger.info("Disconnecting from Coyote device") + logger.info(f"{LOG_PREFIX} Disconnecting from device") if self.client: self.running = False @@ -419,15 +430,15 @@ async def disconnect(self): self.connection_stage = ConnectionStage.DISCONNECTED async def update_loop(self): - logger.info(f"Starting update loop, running={self.running}, algorithm={self.algorithm}") - + logger.info(f"{LOG_PREFIX} Starting update loop, running={self.running}, algorithm={self.algorithm}") + try: - logger.info(f"Update loop started, running={self.running}") + logger.info(f"{LOG_PREFIX} Update loop started, running={self.running}") while self.running: try: if not self.algorithm: - logger.debug("Algorithm not yet set") + logger.debug(f"{LOG_PREFIX} Algorithm not yet set") await asyncio.sleep(0.1) continue @@ -444,14 +455,14 @@ async def update_loop(self): await asyncio.sleep(sleep_time) except Exception as inner_e: - logger.exception(f"Exception inside update loop iteration: {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"Fatal exception in update_loop: {outer_e}") + logger.exception(f"{LOG_PREFIX} Fatal exception in update_loop: {outer_e}") finally: - logger.info("Update loop stopped") + logger.info(f"{LOG_PREFIX} Update loop stopped") def is_connected_and_running(self) -> bool: return (self.connection_stage == ConnectionStage.CONNECTED and diff --git a/qt_ui/coyote_settings_widget.py b/qt_ui/coyote_settings_widget.py index 8ea2445..2a84e28 100644 --- a/qt_ui/coyote_settings_widget.py +++ b/qt_ui/coyote_settings_widget.py @@ -1,4 +1,5 @@ import asyncio +import logging import time from dataclasses import dataclass from typing import Dict, Optional @@ -16,14 +17,17 @@ 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.setupUi() + self.apply_debug_logging(settings.coyote_debug_logging.get()) def setupUi(self): self.setLayout(QVBoxLayout()) - self.label_connection_status = QLabel("Disconnected") - self.label_connection_stage = QLabel("") - self.label_battery_level = QLabel("") + 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) @@ -81,9 +85,16 @@ def update_channel_strength(self, control: 'ChannelControl', value: int): self.device.strengths = strengths def on_connection_status_changed(self, connected: bool, stage: str = None): - self.label_connection_status.setText("Connected" if connected else "Disconnected") + self.label_connection_status.setText("Device: Connected" if connected else "Device: Disconnected") if stage: - self.label_connection_stage.setText(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}%") @@ -102,6 +113,10 @@ def on_pulse_sent(self, pulses: CoyotePulses): 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 diff --git a/qt_ui/preferences_dialog.py b/qt_ui/preferences_dialog.py index f60ad35..b889c14 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 @@ -159,6 +162,7 @@ def loadSettings(self): 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_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()) @@ -178,6 +182,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() @@ -330,6 +336,7 @@ def saveSettings(self): 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_debug_logging.set(self.coyote_debug_logging.isChecked()) # media sync settings qt_ui.settings.media_sync_mpc_address.set(self.mpc_address.text()) @@ -358,6 +365,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() @@ -435,4 +449,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 16b3fbb..222d5f2 100644 --- a/qt_ui/preferences_dialog_ui.py +++ b/qt_ui/preferences_dialog_ui.py @@ -520,6 +520,16 @@ def setupUi(self, PreferencesDialog): self.formLayout_coyote.setWidget(6, QFormLayout.FieldRole, self.coyote_channel_b_intensity_balance) + self.label_coyote_debug_logging = QLabel(self.tab_coyote) + self.label_coyote_debug_logging.setObjectName(u"label_coyote_debug_logging") + + self.formLayout_coyote.setWidget(7, QFormLayout.LabelRole, self.label_coyote_debug_logging) + + self.coyote_debug_logging = QCheckBox(self.tab_coyote) + self.coyote_debug_logging.setObjectName(u"coyote_debug_logging") + + self.formLayout_coyote.setWidget(7, QFormLayout.FieldRole, self.coyote_debug_logging) + self.verticalLayout_coyote.addLayout(self.formLayout_coyote) @@ -913,6 +923,8 @@ def retranslateUi(self, PreferencesDialog): 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.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)) @@ -944,4 +956,3 @@ def retranslateUi(self, PreferencesDialog): ___qtablewidgetitem1.setText(QCoreApplication.translate("PreferencesDialog", u"Enabled", None)); self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_patterns), QCoreApplication.translate("PreferencesDialog", u"Patterns", None)) # retranslateUi - diff --git a/qt_ui/settings.py b/qt_ui/settings.py index af8c766..205b72a 100644 --- a/qt_ui/settings.py +++ b/qt_ui/settings.py @@ -182,6 +182,7 @@ def set(self, value): # Coyote smoothing: maximum allowed intensity change per pulse (percentage points) coyote_max_intensity_change_per_pulse = Setting("coyote/max_intensity_change_per_pulse", 3.0, float) +coyote_debug_logging = Setting("coyote/debug_logging", False, bool) # Pattern preferences - we'll store this as a JSON string and convert to dict import json From c27c2a30af431d37a5017cefca31d3c4f10d0c7d Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Sat, 18 Oct 2025 16:23:37 +0700 Subject: [PATCH 33/47] Coyote: update default settings --- qt_ui/settings.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/qt_ui/settings.py b/qt_ui/settings.py index 205b72a..25c4ffa 100644 --- a/qt_ui/settings.py +++ b/qt_ui/settings.py @@ -173,14 +173,12 @@ def set(self, value): 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", 100, int) -coyote_channel_a_freq_min = Setting("coyote/channel_a_freq_min", 50, 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", 100, int) -coyote_channel_b_freq_min = Setting("coyote/channel_b_freq_min", 20, int) -coyote_channel_b_freq_max = Setting("coyote/channel_b_freq_max", 50, int) - -# Coyote smoothing: maximum allowed intensity change per pulse (percentage points) +coyote_channel_a_strength_max = Setting("coyote/channel_a_strength_max", 50, int) +coyote_channel_a_freq_min = Setting("coyote/channel_a_freq_min", 90, int) +coyote_channel_a_freq_max = Setting("coyote/channel_a_freq_max", 120, 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", 3.0, float) coyote_debug_logging = Setting("coyote/debug_logging", False, bool) From 06e39503c512d3201350b2276b43939c98df7cd5 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Sat, 18 Oct 2025 16:59:02 +0700 Subject: [PATCH 34/47] Coyote: improve algorithm logging --- device/coyote/algorithm.py | 131 ++++++++++++++++++++++++++----------- 1 file changed, 93 insertions(+), 38 deletions(-) diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py index 0f24f26..b91d8cc 100644 --- a/device/coyote/algorithm.py +++ b/device/coyote/algorithm.py @@ -149,13 +149,15 @@ class ContinuousSignal: def __init__(self, params: CoyoteAlgorithmParams, channel_params: 'CoyoteChannelParams', 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]): + pulse_width_limits: Tuple[float, float], pulse_rise_time_limits: Tuple[float, float], + channel_name: str = ""): self.params = params self.channel_params = channel_params self.carrier_freq_limits = carrier_freq_limits self.pulse_freq_limits = pulse_freq_limits self.pulse_width_limits = pulse_width_limits self.pulse_rise_time_limits = pulse_rise_time_limits + self.channel_name = channel_name # Timing state self._last_pulse_time = 0.0 @@ -302,17 +304,18 @@ def get_pulse_at(self, current_time: float, base_intensity: float, pulse_index: # Intensity is supplied by the caller (already smoothed). Do not modulate here. final_intensity = int(np.clip(base_intensity, 0, 100)) - # Debug: trace pulse generation values (do not remove) - try: - print( - f"[DEBUG] COYOTE get_pulse_at: t={current_time:.3f} idx={pulse_index} " - f"pf_raw={raw_pf:.2f}Hz pf_norm={pf_norm:.2f} mapped={mapped_freq:.2f}Hz limits=({min_freq:.1f},{max_freq:.1f})Hz " - f"dur_limits=({min_dur},{max_dur})ms base_dur={base_duration:.2f}ms jitter={jitter:.2f} width_norm={width_norm:.2f} tex_mode={tex_mode} " - f"tex_up={amp_up_ms:.2f}ms tex_dn={amp_dn_ms:.2f}ms tex_used={texture_amplitude_ms:.2f}ms desired={desired_ms:.2f}ms residual={self._duration_residual_ms:+.2f}ms " - f"final_dur={pulse_duration}ms final_freq={final_frequency}Hz intensity={final_intensity}%" + # Debug: log pulse generation details + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + f" [{self.channel_name}] pulse #{pulse_index}: " + f"freq_raw={raw_pf:.1f} Hz, freq_norm={pf_norm:.2f}, freq_mapped={mapped_freq:.1f} Hz, " + f"freq_limits=({min_freq:.1f}-{max_freq:.1f}) Hz | " + f"base_dur={base_duration:.1f} ms, dur_limits=({min_dur}-{max_dur}) ms, width_norm={width_norm:.2f}, " + f"jitter={jitter:.0%} | " + f"texture_mode={tex_mode}, texture_up={amp_up_ms:.2f} ms, texture_dn={amp_dn_ms:.2f} ms, texture_used={texture_amplitude_ms:.2f} ms | " + f"desired={desired_ms:.2f} ms, residual={self._duration_residual_ms:+.2f} ms | " + f"result: dur={pulse_duration} ms, freq={final_frequency} Hz, intensity={final_intensity}%" ) - except Exception: - pass return CoyotePulse( duration=pulse_duration, @@ -348,6 +351,9 @@ def __init__(self, # Smoothing state self._last_intensity: float | None = None self._last_intensity_time: float | None = None + + # Fill summary for logging + self._last_fill_summary = None def _generate_single_pulse(self, t_pulse: float, seq_index: int) -> CoyotePulse: volume = compute_volume(self.media, self.params.volume, t_pulse) @@ -382,18 +388,51 @@ def fill_queue(self, now_s: float) -> None: coverage_s = sum(p.duration for p in self.queue) / 1000.0 end_time = now_s + coverage_s horizon_end = now_s + self.queue_horizon_s + seq_index = 0 + new_pulses = [] while end_time < horizon_end or len(self.queue) < COYOTE_PULSES_PER_PACKET: pulse = self._generate_single_pulse(end_time, seq_index) self.queue.append(pulse) + new_pulses.append(pulse) end_time += pulse.duration / 1000.0 seq_index += 1 self.queue_end_time = end_time - try: - print(f"[DEBUG] COYOTE fill_queue {self.name}: size={len(self.queue)} coverage={coverage_s:.3f}s horizon={self.queue_horizon_s:.3f}s") - except Exception: - pass + + # Log queue fill summary + if logger.isEnabledFor(logging.DEBUG): + if new_pulses: + durations = [p.duration for p in new_pulses] + frequencies = [p.frequency for p in new_pulses] + total_ms = sum(durations) + logger.debug( + f" [{self.name}] Queue filled: " + f"added={len(new_pulses)}, " + f"dur_range={min(durations)}-{max(durations)} ms, " + f"freq_range={min(frequencies)}-{max(frequencies)} Hz, " + f"total_added={total_ms} ms | " + f"queue_size={len(self.queue)}, " + f"coverage={coverage_s * 1000:.0f} ms, " + f"horizon={self.queue_horizon_s * 1000:.0f} ms\n" + ) + self._last_fill_summary = ( + len(new_pulses), + min(durations), + max(durations), + min(frequencies), + max(frequencies) + ) + else: + logger.debug( + f" [{self.name}] Queue status: " + f"queue_size={len(self.queue)}, " + f"coverage={coverage_s * 1000:.0f} ms, " + f"horizon={self.queue_horizon_s * 1000:.0f} ms (no refill needed)\n" + ) + self._last_fill_summary = None + else: + self._last_fill_summary = None def pop_packet(self) -> List[CoyotePulse]: packet: List[CoyotePulse] = [] @@ -422,9 +461,9 @@ def __init__(self, media: AbstractMediaSync, params: CoyoteAlgorithmParams, safe self.position = ThreePhasePosition(params.position, params.transform) self.signal_a = ContinuousSignal(params, params.channel_a, carrier_freq_limits, pulse_freq_limits, - pulse_width_limits, pulse_rise_time_limits) + pulse_width_limits, pulse_rise_time_limits, channel_name="A") self.signal_b = ContinuousSignal(params, params.channel_b, carrier_freq_limits, pulse_freq_limits, - pulse_width_limits, pulse_rise_time_limits) + pulse_width_limits, pulse_rise_time_limits, channel_name="B") self.channel_a = ChannelState() self.channel_b = ChannelState() @@ -571,42 +610,53 @@ def _is_packet_generation_needed(self) -> bool: return ready or low_queue def _schedule_next_update(self, current_time: float, packet_duration_a: float, - packet_duration_b: float, margin: float = 0.8) -> None: - """Schedule the next update time based on packet durations.""" + packet_duration_b: float, margin: float = 0.8) -> float: + """Schedule the next update time based on packet durations. Returns next update delta in ms.""" min_duration = min(packet_duration_a, packet_duration_b) self.next_update_time = current_time + min_duration * margin - try: - print(f"[DEBUG] COYOTE schedule: next_update in {min_duration*margin:.3f}s (A={packet_duration_a:.3f}s B={packet_duration_b:.3f}s)") - except Exception: - pass + return min_duration * margin * 1000 def _log_packet_debug(self, current_time: float, alpha: float, beta: float, pulses_a: List[CoyotePulse], pulses_b: List[CoyotePulse], - total_duration_a: float, total_duration_b: float) -> None: + total_duration_a: float, total_duration_b: float, next_update_ms: float, margin: float) -> None: """Log debug information for generated packet.""" + if not logger.isEnabledFor(logging.DEBUG): + return + hours, minutes, seconds, millis = self._get_display_time(current_time) media_type = self._get_media_type() - - # Calculate volume for logging (using first pulse time) volume = compute_volume(self.media, self.params.volume, current_time) + # Get common intensity (they should all be the same within a channel) + intensity_a = pulses_a[0].intensity if pulses_a else 0 + intensity_b = pulses_b[0].intensity if pulses_b else 0 + log_lines = [ + "=" * 72, + f"Packet Generated @ {hours:02}:{minutes:02}:{seconds:02}.{millis:03} [{media_type}]", + "=" * 72, + f"Position: alpha={alpha:+.2f}, beta={beta:+.2f}, volume={volume:.0%}", "", - f"=== Generating packet at {hours:02}:{minutes:02}:{seconds:02}:{millis:03} === [{media_type}]", - f" Position: alpha={alpha:.2f}, beta={beta:.2f}, volume={volume:.2f}", - f"Channel A ({total_duration_a:.0f} ms):" + f"Channel A: duration={total_duration_a:.0f} ms, intensity={intensity_a}%", ] - for i, pulse in enumerate(pulses_a): - log_lines.append(f" Pulse {i+1}: duration={pulse.duration} ms, freq={pulse.frequency} Hz, intensity={pulse.intensity}%") + for i, p in enumerate(pulses_a, 1): + log_lines.append(f" Pulse {i}: {p.duration} ms @ {p.frequency} Hz") log_lines.extend([ "", - f"Channel B ({total_duration_b:.0f} ms):" + f"Channel B: duration={total_duration_b:.0f} ms, intensity={intensity_b}%" ]) - for i, pulse in enumerate(pulses_b): - log_lines.append(f" Pulse {i+1}: duration={pulse.duration} ms, freq={pulse.frequency} Hz, intensity={pulse.intensity}%") + for i, p in enumerate(pulses_b, 1): + log_lines.append(f" Pulse {i}: {p.duration} ms @ {p.frequency} Hz") + + log_lines.extend([ + "", + f"Next update: {next_update_ms:.0f} ms (packet_dur_a={total_duration_a:.0f} ms, packet_dur_b={total_duration_b:.0f} ms, margin={margin:.0%})", + "=" * 72, + "" + ]) logger.debug("\n".join(log_lines)) @@ -648,7 +698,12 @@ def generate_packet(self, current_time: float) -> Optional[CoyotePulses]: self._update_envelope_preview(current_time) # Ensure queues are filled ahead of time + if logger.isEnabledFor(logging.DEBUG): + logger.debug("=== Channel A: Filling Queue ===") self.ctrl_a.fill_queue(current_time) + + if logger.isEnabledFor(logging.DEBUG): + logger.debug("=== Channel B: Filling Queue ===") self.ctrl_b.fill_queue(current_time) # Assemble packets by popping from queues (atomic update for A and B) @@ -665,11 +720,11 @@ def generate_packet(self, current_time: float) -> Optional[CoyotePulses]: except Exception: pass + # Schedule next update and get the delta for logging + next_update_ms = self._schedule_next_update(current_time, duration_a / 1000.0, duration_b / 1000.0, PACKET_MARGIN) + # Log debug information - self._log_packet_debug(current_time, alpha, beta, pulses_a, pulses_b, duration_a, duration_b) - - # Schedule next update - self._schedule_next_update(current_time, duration_a / 1000.0, duration_b / 1000.0, PACKET_MARGIN) + self._log_packet_debug(current_time, alpha, beta, pulses_a, pulses_b, duration_a, duration_b, next_update_ms, PACKET_MARGIN) return CoyotePulses(pulses_a, pulses_b) From 2a7a04bec595d68a2c7720b46e55c19c34edcf8b Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Sat, 18 Oct 2025 17:09:08 +0700 Subject: [PATCH 35/47] Coyote: fix shutdown race condition with algorithm.next_update_time --- device/coyote/device.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/device/coyote/device.py b/device/coyote/device.py index b4d2728..905b94d 100644 --- a/device/coyote/device.py +++ b/device/coyote/device.py @@ -448,7 +448,11 @@ async def update_loop(self): pulses = self.algorithm.generate_packet(current_time) if pulses is not None: await self.send_command(pulses=pulses) - sleep_time = max(0.001, self.algorithm.next_update_time - time.time()) + # 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 From acb680a5c85f5177f6f81452d3adf3ec2286f81e Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Sat, 18 Oct 2025 17:09:46 +0700 Subject: [PATCH 36/47] Coyote: enable new threephase motion patterns (wow!) --- qt_ui/mainwindow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qt_ui/mainwindow.py b/qt_ui/mainwindow.py index fdca6bb..59a217b 100644 --- a/qt_ui/mainwindow.py +++ b/qt_ui/mainwindow.py @@ -583,7 +583,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) From 986580dab9a19ff7ce2489193978baffa5768f98 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Sat, 18 Oct 2025 17:13:45 +0700 Subject: [PATCH 37/47] Coyote: remove dead ASR envelope code --- device/coyote/algorithm.py | 54 -------------------------------------- device/coyote/device.py | 2 -- 2 files changed, 56 deletions(-) diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py index b91d8cc..e01b4ca 100644 --- a/device/coyote/algorithm.py +++ b/device/coyote/algorithm.py @@ -88,27 +88,6 @@ def get_remaining_time_ms(self) -> float: return max(0.0, self.total_packet_duration_ms - self.time_in_packet_ms) -# --- Envelope Generators --- - -def generate_ramp_envelope(attack: float, plateau: float, release: float, num_points: int) -> np.ndarray: - """Generate a symmetric ramp (triangle/trapezoid) envelope 0→1→0. - - attack: seconds from 0→1 - plateau: seconds at level 1 between attack and release (may be 0) - release: seconds from 1→0 - """ - total = attack + plateau + release - if total <= 0 or num_points < 2: - return np.zeros(num_points) - - a_pts = max(1, int(round(num_points * attack / total))) - p_pts = max(0, int(round(num_points * plateau / total))) - r_pts = num_points - a_pts - p_pts - ascent = np.linspace(0, 1, a_pts, endpoint=False) - plateau_arr = np.ones(p_pts) - descent = np.linspace(1, 0, r_pts, endpoint=True) - return np.concatenate([ascent, plateau_arr, descent]) - def _normalize_axis(value: float, limits: Tuple[float, float]) -> float: """Normalize a raw axis value to a 0-100 scale based on its limits.""" min_val, max_val = limits @@ -144,9 +123,6 @@ class ContinuousSignal: funscripts more faithfully while staying within hardware limits. """ - ENVELOPE_RESOLUTION = 200 # Number of points in envelope lookup table - - def __init__(self, params: CoyoteAlgorithmParams, channel_params: 'CoyoteChannelParams', 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], @@ -164,11 +140,6 @@ def __init__(self, params: CoyoteAlgorithmParams, channel_params: 'CoyoteChannel self._start_time = None self.modulation_phase = 0.0 self._duration_residual_ms = 0.0 # fractional ms accumulator to reduce rounding jitter - - # Envelope cache - self._envelope_lookup_table = None - self._cached_envelope_params = None - self._envelope_period = 0.0 def _calculate_effective_frequency_limits(self) -> Tuple[float, float]: """Calculate effective frequency limits considering hardware constraints.""" @@ -494,9 +465,6 @@ def __init__(self, media: AbstractMediaSync, params: CoyoteAlgorithmParams, safe queue_horizon_s=0.75, ) - # UI Preview Cache - self._cached_envelope = np.full(200, 0.5) # Default to a flat line - self._cached_envelope_period = 0.0 # Smoothing state handled by ChannelController # Channel-specific pulse generation and queues moved to ChannelController @@ -660,17 +628,6 @@ def _log_packet_debug(self, current_time: float, alpha: float, beta: float, logger.debug("\n".join(log_lines)) - def _update_envelope_preview(self, t: float): - """Generate and cache a simple flat envelope synced to pulse_frequency. - - The revised runtime no longer modulates frequency by a sine wave, so the - preview reflects a steady state: flat line with period = 1/pulse_frequency. - """ - num_points = 200 - freq = float(self.params.pulse_frequency.interpolate(t)) - self._cached_envelope_period = 1.0 / freq if freq > 0 else 0.0 - self._cached_envelope = np.full(num_points, 0.5) - def generate_packet(self, current_time: float) -> Optional[CoyotePulses]: """Generate one packet of pulses for both channels.""" PACKET_MARGIN = 0.8 # Request next packet after 80% of current one has played @@ -694,9 +651,6 @@ def generate_packet(self, current_time: float) -> Optional[CoyotePulses]: self.next_update_time = current_time + (remaining_time_ms / 1000.0) * PACKET_MARGIN return None - # Update the UI preview cache from the current state - self._update_envelope_preview(current_time) - # Ensure queues are filled ahead of time if logger.isEnabledFor(logging.DEBUG): logger.debug("=== Channel A: Filling Queue ===") @@ -731,11 +685,3 @@ def generate_packet(self, current_time: float) -> Optional[CoyotePulses]: def get_next_update_time(self) -> float: return self.next_update_time - - def get_envelope_data(self) -> Tuple[np.ndarray, float]: - """Return the current flat envelope and its period for UI visualization.""" - t = time.time() - freq = float(self.params.pulse_frequency.interpolate(t)) - period = 1.0 / freq if freq > 0 else 0.0 - num_points = 200 - return np.full(num_points, 0.5), period diff --git a/device/coyote/device.py b/device/coyote/device.py index 905b94d..f45a3de 100644 --- a/device/coyote/device.py +++ b/device/coyote/device.py @@ -4,7 +4,6 @@ from typing import Optional, Callable import time import threading -import numpy as np from bleak import BleakClient, BleakScanner from device.output_device import OutputDevice @@ -76,7 +75,6 @@ class CoyoteDevice(OutputDevice, QObject): parameters_changed = Signal() power_levels_changed = Signal(CoyoteStrengths) pulse_sent = Signal(CoyotePulses) - envelope_updated = Signal(str, np.ndarray, float) # channel_id, envelope_data, envelope_period def __init__(self, device_name: str): OutputDevice.__init__(self) From cd0d248d1a74bcf5869c9b9d1727e0152d405da0 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Sat, 18 Oct 2025 17:39:57 +0700 Subject: [PATCH 38/47] Coyote: update docs --- README.md | 2 +- device/coyote/algorithm.py | 68 +++++++++++++++++++++++++++++--------- device/coyote/device.py | 4 +-- 3 files changed, 56 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 90f9a28..4568082 100644 --- a/README.md +++ b/README.md @@ -9,7 +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 (experimental) +* Coyote 3.0 (coming soon, experimental) ## Main features diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py index e01b4ca..3f42192 100644 --- a/device/coyote/algorithm.py +++ b/device/coyote/algorithm.py @@ -1,18 +1,54 @@ """ DG-LAB Coyote 3.0 E-Stim Algorithm Implementation -This algorithm controls a Coyote 3.0 dual-channel e-stim device using a symmetric ramp envelope -model that matches the pulse-based audio algorithm behavior while working within hardware constraints. +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. -Hardware Specifications: ------------------------ +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) -- Each pulse: intensity (0-100%), duration (5-240ms) -- Protocol: 4 pulses per packet +- 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. """ import logging @@ -117,10 +153,11 @@ def _get_normalized_parameters(params: CoyoteAlgorithmParams, t: float, class ContinuousSignal: """Models a single channel's pulse generation. - Revised to keep the pulse frequency anchored to the funscript/UI - `pulse_frequency` axis (with optional jitter), and to stop sweeping - across the full min/max range. This preserves the intention of - funscripts more faithfully while staying within hardware limits. + Generates pulses with duration anchored to the funscript pulse_frequency parameter, + mapped into the channel's configured frequency range. Applies optional jitter and + zero-mean micro-texture modulation via the pulse_width parameter. + + The carrier_frequency parameter controls the speed of the texture modulation phase. """ def __init__(self, params: CoyoteAlgorithmParams, channel_params: 'CoyoteChannelParams', @@ -418,10 +455,11 @@ def has_minimum_pulses(self, n: int) -> bool: return len(self.queue) >= n class CoyoteAlgorithm: - """Coyote 3.0 pulse generation algorithm with symmetric ramp envelope modulation. + """Coyote 3.0 pulse generation algorithm. Coordinates dual-channel pulse generation using ContinuousSignal instances. - Handles packet timing, positional intensity distribution, and envelope preview. + Handles packet timing, positional intensity distribution, and smoothed intensity transitions. + Each channel maintains an independent pulse queue for continuous output. """ def __init__(self, media: AbstractMediaSync, params: CoyoteAlgorithmParams, safety_limits: SafetyParams, carrier_freq_limits: Tuple[float, float], pulse_freq_limits: Tuple[float, float], diff --git a/device/coyote/device.py b/device/coyote/device.py index f45a3de..29124f4 100644 --- a/device/coyote/device.py +++ b/device/coyote/device.py @@ -50,9 +50,9 @@ class CoyoteParams: @dataclass class CoyotePulse: - frequency: int # 0-150 Hz + frequency: int # Calculated from duration: 1000/duration_ms, range ~4-200 Hz intensity: int # 0-100 - duration: int # 10-240 (converted from Hz frequency) + duration: int # 5-240ms (spec says 10-240, but 5ms works) @dataclass class CoyotePulses: From dd555a4f035df7beb1d99b1f9a30bc9b450c5ac3 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Sat, 18 Oct 2025 19:57:39 +0700 Subject: [PATCH 39/47] Coyote: refactor code + fix wizard --- .../device_wizard/coyote_waveform_select.ui | 54 +++++++++ device/coyote/algorithm.py | 71 +++++++----- device/coyote/constants.py | 43 +++++++ device/coyote/device.py | 109 ++++++------------ device/coyote/types.py | 54 +++++++++ qt_ui/device_wizard/coyote_waveform_select.py | 16 +++ .../coyote_waveform_select_ui.py | 57 +++++++++ qt_ui/device_wizard/enums.py | 2 +- qt_ui/device_wizard/wizard.py | 15 ++- 9 files changed, 312 insertions(+), 109 deletions(-) create mode 100644 designer/device_wizard/coyote_waveform_select.ui create mode 100644 device/coyote/constants.py create mode 100644 device/coyote/types.py create mode 100644 qt_ui/device_wizard/coyote_waveform_select.py create mode 100644 qt_ui/device_wizard/coyote_waveform_select_ui.py 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/device/coyote/algorithm.py b/device/coyote/algorithm.py index 3f42192..e7e9f4f 100644 --- a/device/coyote/algorithm.py +++ b/device/coyote/algorithm.py @@ -56,12 +56,28 @@ import numpy as np from collections import deque from typing import List, Tuple, Deque, Optional +from device.coyote.constants import ( + MIN_PULSE_DURATION_MS, + MAX_PULSE_DURATION_MS, + HARDWARE_MIN_FREQ_HZ, + HARDWARE_MAX_FREQ_HZ, + PULSES_PER_PACKET, + QUEUE_HORIZON_S, + PACKET_MARGIN, + TEXTURE_MIN_HZ, + TEXTURE_MAX_HZ, + TEXTURE_MAX_DEPTH_FRACTION, + JITTER_CLAMP_FRACTION, + RANDOMIZATION_LIMIT_FRACTION, + RESIDUAL_BOUND, + DEFAULT_MAX_CHANGE_PER_PULSE, +) from stim_math.axis import AbstractMediaSync, AbstractAxis from stim_math.threephase import ThreePhaseCenterCalibration from stim_math.audio_gen.params import CoyoteAlgorithmParams, VolumeParams, SafetyParams from stim_math.audio_gen.various import ThreePhasePosition -from device.coyote.device import CoyotePulse, CoyotePulses +from device.coyote.types import CoyotePulse, CoyotePulses try: # Optional import; keeps algorithm functional in headless contexts from qt_ui import settings as ui_settings @@ -70,9 +86,7 @@ logger = logging.getLogger('restim.coyote') -COYOTE_PULSES_PER_PACKET = 4 -COYOTE_MIN_PULSE_DURATION = 5 -COYOTE_MAX_PULSE_DURATION = 240 + def compute_volume(media: AbstractMediaSync, volume_params: VolumeParams, t: float) -> float: @@ -182,8 +196,8 @@ def _calculate_effective_frequency_limits(self) -> Tuple[float, float]: """Calculate effective frequency limits considering hardware constraints.""" channel_min = self.channel_params.minimum_frequency.get() channel_max = self.channel_params.maximum_frequency.get() - hardware_max = 1000.0 / COYOTE_MIN_PULSE_DURATION # ~200 Hz - hardware_min = 1000.0 / COYOTE_MAX_PULSE_DURATION # ~4.17 Hz + hardware_max = HARDWARE_MAX_FREQ_HZ # ~200 Hz + hardware_min = HARDWARE_MIN_FREQ_HZ # ~4.17 Hz effective_min = max(channel_min, hardware_min) effective_max = min(channel_max, hardware_max) @@ -201,8 +215,8 @@ def _apply_frequency_randomization(self, base_frequency: float, randomization_st if randomization_strength <= 0: return base_frequency - # Limit randomization to 10% of the setting - random_percentage = randomization_strength / 100.0 * 0.1 + # Limit randomization to a fraction of the setting + random_percentage = randomization_strength / 100.0 * RANDOMIZATION_LIMIT_FRACTION random_factor = 1.0 + (np.random.rand() - 0.5) * 2 * random_percentage randomized_freq = base_frequency * random_factor @@ -223,8 +237,8 @@ def get_pulse_at(self, current_time: float, base_intensity: float, pulse_index: # Effective channel limits → convert to duration window min_freq, max_freq = self._calculate_effective_frequency_limits() - min_dur = max(COYOTE_MIN_PULSE_DURATION, int(round(1000.0 / max_freq))) - max_dur = min(COYOTE_MAX_PULSE_DURATION, int(round(1000.0 / min_freq))) + min_dur = max(MIN_PULSE_DURATION_MS, int(round(1000.0 / max_freq))) + max_dur = min(MAX_PULSE_DURATION_MS, int(round(1000.0 / min_freq))) # Map global funscript pulse_frequency into the channel's preferred range # 1) Normalize funscript value using global pulse_freq_limits (kit limits) @@ -241,8 +255,8 @@ def get_pulse_at(self, current_time: float, base_intensity: float, pulse_index: # Optional jitter around base duration (from funscript) jitter = float(self.params.pulse_interval_random.interpolate(current_time)) - # Treat jitter as a 0..1 fraction; clamp to ±50% to avoid pathological spans - jitter = float(np.clip(jitter, 0.0, 0.5)) + # Treat jitter as a 0..1 fraction; clamp to avoid pathological spans + jitter = float(np.clip(jitter, 0.0, JITTER_CLAMP_FRACTION)) jitter_factor = 1.0 + (np.random.rand() * 2.0 - 1.0) * jitter # Micro-texture from pulse_width (depth) with phase advanced in _advance_channel_states @@ -257,7 +271,6 @@ def get_pulse_at(self, current_time: float, base_intensity: float, pulse_index: amp_up_ms = max(0.0, max_dur_f - base_duration) # can increase duration up to this much amp_dn_ms = max(0.0, base_duration - min_dur_f) # can decrease duration up to this much - TEXTURE_MAX_DEPTH_FRACTION = 0.5 amp_up_ms *= TEXTURE_MAX_DEPTH_FRACTION * width_norm amp_dn_ms *= TEXTURE_MAX_DEPTH_FRACTION * width_norm @@ -290,10 +303,10 @@ def get_pulse_at(self, current_time: float, base_intensity: float, pulse_index: pulse_duration = int(np.floor(accum + 0.5)) # nearest int self._duration_residual_ms = accum - pulse_duration # Keep residual bounded for numerical stability - if self._duration_residual_ms > 0.49: - self._duration_residual_ms = 0.49 - elif self._duration_residual_ms < -0.49: - self._duration_residual_ms = -0.49 + if self._duration_residual_ms > RESIDUAL_BOUND: + self._duration_residual_ms = RESIDUAL_BOUND + elif self._duration_residual_ms < -RESIDUAL_BOUND: + self._duration_residual_ms = -RESIDUAL_BOUND # Clamp to channel-specific duration window and hardware bounds clamped = False @@ -344,7 +357,7 @@ def __init__(self, signal: ContinuousSignal, get_positional_intensities, max_change_per_pulse: float, - queue_horizon_s: float = 0.75): + queue_horizon_s: float = QUEUE_HORIZON_S): self.name = name # 'A' or 'B' self.media = media self.params = params @@ -399,7 +412,7 @@ def fill_queue(self, now_s: float) -> None: seq_index = 0 new_pulses = [] - while end_time < horizon_end or len(self.queue) < COYOTE_PULSES_PER_PACKET: + while end_time < horizon_end or len(self.queue) < PULSES_PER_PACKET: pulse = self._generate_single_pulse(end_time, seq_index) self.queue.append(pulse) new_pulses.append(pulse) @@ -444,11 +457,11 @@ def fill_queue(self, now_s: float) -> None: def pop_packet(self) -> List[CoyotePulse]: packet: List[CoyotePulse] = [] - while len(packet) < COYOTE_PULSES_PER_PACKET: + while len(packet) < PULSES_PER_PACKET: if self.queue: packet.append(self.queue.popleft()) else: - packet.append(CoyotePulse(frequency=0, intensity=0, duration=COYOTE_MIN_PULSE_DURATION)) + packet.append(CoyotePulse(frequency=0, intensity=0, duration=MIN_PULSE_DURATION_MS)) return packet def has_minimum_pulses(self, n: int) -> bool: @@ -482,7 +495,7 @@ def __init__(self, media: AbstractMediaSync, params: CoyoteAlgorithmParams, safe self.next_update_time = 0.0 # Global per-pulse cap (percentage points). Read from settings if available. - self._max_change_per_pulse = 3.0 + self._max_change_per_pulse = DEFAULT_MAX_CHANGE_PER_PULSE try: if ui_settings is not None: self._max_change_per_pulse = float(ui_settings.coyote_max_intensity_change_per_pulse.get()) @@ -494,13 +507,13 @@ def __init__(self, media: AbstractMediaSync, params: CoyoteAlgorithmParams, safe 'A', self.media, self.params, self.signal_a, get_positional_intensities=self._get_positional_intensities, max_change_per_pulse=self._max_change_per_pulse, - queue_horizon_s=0.75, + queue_horizon_s=QUEUE_HORIZON_S, ) self.ctrl_b = ChannelController( 'B', self.media, self.params, self.signal_b, get_positional_intensities=self._get_positional_intensities, max_change_per_pulse=self._max_change_per_pulse, - queue_horizon_s=0.75, + queue_horizon_s=QUEUE_HORIZON_S, ) # Smoothing state handled by ChannelController @@ -594,8 +607,6 @@ def _advance_channel_states(self, current_time: float, delta_time_ms: float) -> # Advance shared micro-texture phase using carrier axis as speed control (mapped to ~0.5..5 Hz) carrier_norm, _, _, _ = _get_normalized_parameters( self.params, current_time, self.signal_a.carrier_freq_limits, self.signal_a.pulse_freq_limits) - TEXTURE_MIN_HZ = 0.5 - TEXTURE_MAX_HZ = 5.0 texture_speed_hz = TEXTURE_MIN_HZ + (TEXTURE_MAX_HZ - TEXTURE_MIN_HZ) * (carrier_norm / 100.0) delta_time_s = delta_time_ms / 1000.0 phase_change = (delta_time_s * texture_speed_hz) * 2 * np.pi @@ -609,14 +620,14 @@ def _is_packet_generation_needed(self) -> bool: We also allow proactive generation if queues are running low, to avoid starving the device with repeats for too long. """ - low_queue = (not self.ctrl_a.has_minimum_pulses(COYOTE_PULSES_PER_PACKET) or - not self.ctrl_b.has_minimum_pulses(COYOTE_PULSES_PER_PACKET)) + low_queue = (not self.ctrl_a.has_minimum_pulses(PULSES_PER_PACKET) or + not self.ctrl_b.has_minimum_pulses(PULSES_PER_PACKET)) ready = (self.channel_a.is_ready_for_next_packet() or self.channel_b.is_ready_for_next_packet()) return ready or low_queue def _schedule_next_update(self, current_time: float, packet_duration_a: float, - packet_duration_b: float, margin: float = 0.8) -> float: + packet_duration_b: float, margin: float = PACKET_MARGIN) -> float: """Schedule the next update time based on packet durations. Returns next update delta in ms.""" min_duration = min(packet_duration_a, packet_duration_b) self.next_update_time = current_time + min_duration * margin @@ -668,7 +679,7 @@ def _log_packet_debug(self, current_time: float, alpha: float, beta: float, def generate_packet(self, current_time: float) -> Optional[CoyotePulses]: """Generate one packet of pulses for both channels.""" - PACKET_MARGIN = 0.8 # Request next packet after 80% of current one has played + # Request next packet after threshold of current one has played # Initialize timing on first call if self.last_update_time_s == 0.0: diff --git a/device/coyote/constants.py b/device/coyote/constants.py new file mode 100644 index 0000000..aea83d3 --- /dev/null +++ b/device/coyote/constants.py @@ -0,0 +1,43 @@ +# 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 and queue behavior +PULSES_PER_PACKET = 4 +QUEUE_HORIZON_S = 0.75 +PACKET_MARGIN = 0.8 # request next packet when ~80% of current one has played + +# Pulse generation behavior +TEXTURE_MIN_HZ = 0.5 +TEXTURE_MAX_HZ = 5.0 +TEXTURE_MAX_DEPTH_FRACTION = 0.5 +JITTER_CLAMP_FRACTION = 0.5 +RANDOMIZATION_LIMIT_FRACTION = 0.1 # limit randomization to 10% of setting +RESIDUAL_BOUND = 0.49 # clamp fractional residual for rounding fairness +DEFAULT_MAX_CHANGE_PER_PULSE = 3.0 # percentage points per pulse if setting unavailable + +# 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 index 29124f4..25a74f3 100644 --- a/device/coyote/device.py +++ b/device/coyote/device.py @@ -1,72 +1,33 @@ import asyncio -from dataclasses import dataclass import logging -from typing import Optional, Callable +from typing import Optional import time import threading from bleak import BleakClient, BleakScanner from device.output_device import OutputDevice -#from stim_math.audio_gen.coyote import CoyoteThreePhaseAlgorithm #blegh, circular import + 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') -LOG_PREFIX = "[Coyote]" - -# Coyote BLE UUIDs -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" - -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 class CoyoteDevice(OutputDevice, QObject): parameters: CoyoteParams = None @@ -81,7 +42,7 @@ def __init__(self, device_name: str): QObject.__init__(self) self.device_name = device_name self.client: Optional[BleakClient] = None - self.algorithm: Optional[any] = None + self.algorithm: Optional[CoyoteAlgorithm] = None self.running = False self.connection_stage = ConnectionStage.DISCONNECTED self.strengths = CoyoteStrengths(channel_a=0, channel_b=0) @@ -133,8 +94,8 @@ async def _connection_loop(self): self.connection_stage = ConnectionStage.CONNECTING else: attempt_counter += 1 - logger.info(f"{LOG_PREFIX} Device not found (attempt {attempt_counter}); retrying in 5 seconds...") - await asyncio.sleep(5) + 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: if await self.client.connect(): @@ -243,16 +204,16 @@ async def _handle_status_notification(self, sender, data: bytearray): power_a = data[2] power_b = data[3] - if command_id == 0xB1: + if command_id == CMD_POWER_UPDATE: logger.debug(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 == 0x51: + elif command_id == CMD_ACK: logger.debug(f"{LOG_PREFIX} Command acknowledged (seq={sequence_number})") - elif command_id == 0x53: + elif command_id == CMD_ACTIVE_POWER: if len(data) < 4: logger.warning(f"{LOG_PREFIX} Malformed active power notification: {list(data)}") return @@ -359,11 +320,11 @@ async def send_command(self, # Determine strength interpretation (default absolute set if new strength provided) if strengths: - interp_a = 0b11 # Absolute set for Channel A - interp_b = 0b11 # Absolute set for Channel B + interp_a = INTERP_ABSOLUTE_SET # Absolute set for Channel A + interp_b = INTERP_ABSOLUTE_SET # Absolute set for Channel B else: - interp_a = 0b00 # No change - interp_b = 0b00 # No change + 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 @@ -371,7 +332,7 @@ async def send_command(self, # Build base command (B0 packet structure) command = bytearray([ - 0xB0, # Command ID + CMD_B0, # Command ID control_byte, # Combined seq + interpretation strengths.channel_a if strengths else 0, strengths.channel_b if strengths else 0, @@ -384,7 +345,7 @@ async def send_command(self, command.extend([b.duration for b in pulses.channel_b]) command.extend([b.intensity for b in pulses.channel_b]) else: - command.extend([0] * 16) # No pulses = zero padding + command.extend([0] * B0_NO_PULSES_PAD_BYTES) # No pulses = zero padding # Log what we're sending logger.info(f"{LOG_PREFIX} Sending command (seq={self.sequence_number}):") @@ -406,7 +367,7 @@ async def send_command(self, # Send the final command try: await self.client.write_gatt_char(WRITE_CHAR_UUID, command) - self.sequence_number = (self.sequence_number + 1) % 16 # Wrap seq at 4 bits (0-15) + 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}") @@ -419,8 +380,8 @@ async def disconnect(self): # Send zero pulses to turn off outputs zero_pulses = CoyotePulses( - channel_a=[CoyotePulse(frequency=0, intensity=0, duration=0)] * 4, - channel_b=[CoyotePulse(frequency=0, intensity=0, duration=0)] * 4 + 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() 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/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 d795cc7..9bbf21b 100644 --- a/qt_ui/device_wizard/enums.py +++ b/qt_ui/device_wizard/enums.py @@ -31,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/wizard.py b/qt_ui/device_wizard/wizard.py index c723e5c..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()) @@ -77,7 +84,7 @@ def nextId(self): 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_limits.value + return WizardPage.Page_coyote_waveform.value else: raise RuntimeError("unknown device type") @@ -154,7 +161,9 @@ def get_configuration(self) -> DeviceConfiguration: return DeviceConfiguration( DeviceType.COYOTE_THREE_PHASE, WaveformType.PULSE_BASED, - min_freq, max_freq + coyote_constants.HARDWARE_MIN_FREQ_HZ, + coyote_constants.HARDWARE_MAX_FREQ_HZ, + None ) else: assert(False) @@ -172,8 +181,6 @@ def set_configuration(self, config: DeviceConfiguration): self.page_device_type.neostim_radio.setChecked(True) if config.device_type == DeviceType.COYOTE_THREE_PHASE: self.page_device_type.coyote_radio.setChecked(True) - config.min_frequency = 1 - config.max_frequency = 150 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) From c808e77d360af745b6020597574523ec889eb2ac Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Sat, 18 Oct 2025 20:14:45 +0700 Subject: [PATCH 40/47] Coyote: change log levels --- device/coyote/device.py | 44 +++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 21 deletions(-) diff --git a/device/coyote/device.py b/device/coyote/device.py index 25a74f3..936f1a8 100644 --- a/device/coyote/device.py +++ b/device/coyote/device.py @@ -205,7 +205,7 @@ async def _handle_status_notification(self, sender, data: bytearray): power_b = data[3] if command_id == CMD_POWER_UPDATE: - logger.debug(f"{LOG_PREFIX} Power level update (seq={sequence_number}) - Channel A: {power_a}, Channel B: {power_b}") + 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) @@ -221,7 +221,7 @@ async def _handle_status_notification(self, sender, data: bytearray): power_a = data[2] power_b = data[3] - logger.debug(f"{LOG_PREFIX} Active power update - Channel A: {power_a}, Channel B: {power_b}") + 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 @@ -229,11 +229,11 @@ async def _handle_status_notification(self, sender, data: bytearray): # if len(data) > 4: # extra = data[4:] - # logger.debug(f"Extra fields in 0x53 notification (undocumented): {list(extra)}") + # 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.debug(f"{LOG_PREFIX} Raw notification: {list(data)}") + logger.warning(f"{LOG_PREFIX} Raw notification: {list(data)}") async def _send_parameters(self): """Send device parameters""" @@ -283,7 +283,7 @@ async def _scan_for_device(self): self.connection_stage = ConnectionStage.CONNECTING return True else: - logger.debug(f"{LOG_PREFIX} No BLE advertisement for {self.device_name} detected during scan window") + 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}") @@ -348,21 +348,23 @@ async def send_command(self, command.extend([0] * B0_NO_PULSES_PAD_BYTES) # No pulses = zero padding # Log what we're sending - logger.info(f"{LOG_PREFIX} Sending command (seq={self.sequence_number}):") - - if pulses and logger.isEnabledFor(logging.DEBUG): - 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}" - ) + 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: @@ -397,7 +399,7 @@ async def update_loop(self): while self.running: try: if not self.algorithm: - logger.debug(f"{LOG_PREFIX} Algorithm not yet set") + logger.warning(f"{LOG_PREFIX} Algorithm not yet set") await asyncio.sleep(0.1) continue From c0211616262f48d4ce6bb2181b110687eeb7ac85 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Wed, 5 Nov 2025 09:15:23 +0700 Subject: [PATCH 41/47] Coyote: update default freq range for A --- qt_ui/settings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qt_ui/settings.py b/qt_ui/settings.py index 25c4ffa..636ebb6 100644 --- a/qt_ui/settings.py +++ b/qt_ui/settings.py @@ -174,8 +174,8 @@ def set(self, value): 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", 90, int) -coyote_channel_a_freq_max = Setting("coyote/channel_a_freq_max", 120, 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) From b14ae7c26bc95166559587ddc1210d710fe526e1 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Wed, 5 Nov 2025 09:16:00 +0700 Subject: [PATCH 42/47] Coyote: fix connection loop for latest Bleak --- device/coyote/device.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/device/coyote/device.py b/device/coyote/device.py index 936f1a8..5820a74 100644 --- a/device/coyote/device.py +++ b/device/coyote/device.py @@ -98,16 +98,18 @@ async def _connection_loop(self): await asyncio.sleep(SCAN_RETRY_SECONDS) elif self.connection_stage == ConnectionStage.CONNECTING: - if await self.client.connect(): + try: + await self.client.connect() logger.info(f"{LOG_PREFIX} Connected, discovering services...") self.connection_stage = ConnectionStage.SERVICE_DISCOVERY - else: - logger.error(f"{LOG_PREFIX} Connection failed") + except Exception as e: + logger.error(f"{LOG_PREFIX} Connection failed: {e}") await self.disconnect() elif self.connection_stage == ConnectionStage.SERVICE_DISCOVERY: - if await self.client.get_services(): - logger.info(f"{LOG_PREFIX} Services discovered, subscribing to battery...") + 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") @@ -264,6 +266,11 @@ async def _send_parameters(self): 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) From 5de4c2911c6426b6a9c64d37ddccfd9cb3999425 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Wed, 5 Nov 2025 09:25:18 +0700 Subject: [PATCH 43/47] Coyote: add intensity debug log --- device/coyote/algorithm.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py index e7e9f4f..7c09cd1 100644 --- a/device/coyote/algorithm.py +++ b/device/coyote/algorithm.py @@ -644,29 +644,25 @@ def _log_packet_debug(self, current_time: float, alpha: float, beta: float, media_type = self._get_media_type() volume = compute_volume(self.media, self.params.volume, current_time) - # Get common intensity (they should all be the same within a channel) - intensity_a = pulses_a[0].intensity if pulses_a else 0 - intensity_b = pulses_b[0].intensity if pulses_b else 0 - log_lines = [ "=" * 72, f"Packet Generated @ {hours:02}:{minutes:02}:{seconds:02}.{millis:03} [{media_type}]", "=" * 72, f"Position: alpha={alpha:+.2f}, beta={beta:+.2f}, volume={volume:.0%}", "", - f"Channel A: duration={total_duration_a:.0f} ms, intensity={intensity_a}%", + f"Channel A: duration={total_duration_a:.0f} ms", ] for i, p in enumerate(pulses_a, 1): - log_lines.append(f" Pulse {i}: {p.duration} ms @ {p.frequency} Hz") + log_lines.append(f" Pulse {i}: {p.duration} ms @ {p.frequency} Hz ({p.intensity}%)") log_lines.extend([ "", - f"Channel B: duration={total_duration_b:.0f} ms, intensity={intensity_b}%" + f"Channel B: duration={total_duration_b:.0f} ms" ]) for i, p in enumerate(pulses_b, 1): - log_lines.append(f" Pulse {i}: {p.duration} ms @ {p.frequency} Hz") + log_lines.append(f" Pulse {i}: {p.duration} ms @ {p.frequency} Hz ({p.intensity}%)") log_lines.extend([ "", From 61c598ac0dd3bece1582ec0a5fd0489f7c903d91 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Wed, 5 Nov 2025 10:43:41 +0700 Subject: [PATCH 44/47] Coyote: make graph window configurable --- designer/preferencesdialog.ui | 30 ++++++++++++++++++++++++++++-- qt_ui/coyote_settings_widget.py | 28 ++++++++++++++++------------ qt_ui/preferences_dialog.py | 2 ++ qt_ui/preferences_dialog_ui.py | 22 +++++++++++++++++++--- qt_ui/settings.py | 1 + 5 files changed, 66 insertions(+), 17 deletions(-) diff --git a/designer/preferencesdialog.ui b/designer/preferencesdialog.ui index 3a2eb48..893e33c 100644 --- a/designer/preferencesdialog.ui +++ b/designer/preferencesdialog.ui @@ -742,13 +742,39 @@ - + - Debug logging + Graph Window (s) + + + 1 + + + 0.1 + + + 10.0 + + + 0.1 + + + 3.0 + + + + + + + Debug Logging + + + + diff --git a/qt_ui/coyote_settings_widget.py b/qt_ui/coyote_settings_widget.py index 2a84e28..c166652 100644 --- a/qt_ui/coyote_settings_widget.py +++ b/qt_ui/coyote_settings_widget.py @@ -7,7 +7,7 @@ from PySide6.QtWidgets import (QWidget, QVBoxLayout, QLabel, QSlider, QHBoxLayout, QGraphicsView, QGraphicsScene, QGraphicsLineItem, QSpinBox, QGraphicsRectItem, QToolTip, QGraphicsEllipseItem) -from PySide6.QtCore import Qt, QTimer +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 @@ -19,6 +19,7 @@ def __init__(self, parent=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()) @@ -183,7 +184,7 @@ def build_ui(self) -> QHBoxLayout: layout.addLayout(left) - self.pulse_graph = PulseGraphContainer(self.freq_min, self.freq_max) + self.pulse_graph = PulseGraphContainer(self.parent.graph_window, self.freq_min, self.freq_max) self.pulse_graph.plot.setMinimumHeight(100) graph_column = QVBoxLayout() @@ -305,7 +306,7 @@ def handle_pulses(self, pulses: list[CoyotePulse], strength: int): ) class PulseGraphContainer(QWidget): - def __init__(self, freq_min: QSpinBox, freq_max: QSpinBox, *args, **kwargs): + 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 @@ -315,13 +316,13 @@ def __init__(self, freq_min: QSpinBox, freq_max: QSpinBox, *args, **kwargs): self.entries = [] # Time window for stats display (in seconds) - self.stats_window = 3.0 # Match the graph's time window + self.stats_window = window_seconds # Create layout self.layout = QVBoxLayout(self) # Create plot widget - self.plot = PulseGraph(*args, **kwargs) + self.plot = PulseGraph(window_seconds, *args, **kwargs) self.layout.addWidget(self.plot) # Optional stats label managed by parent component @@ -363,7 +364,8 @@ def format_intensity_text(self, intensities) -> str: def clean_old_entries(self): """Remove entries outside the time window""" current_time = time.time() - self.entries = [e for e in self.entries if current_time - e.timestamp <= self.stats_window] + 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 @@ -408,7 +410,7 @@ def add_pulse(self, frequency, intensity, duration, current_strength, channel_li self.plot.add_pulse(pulse, effective_intensity, channel_limit) class PulseGraph(QWidget): - def __init__(self, parent=None): + def __init__(self, window_seconds: settings.Setting, parent=None): super().__init__(parent) self.setLayout(QVBoxLayout()) @@ -431,7 +433,7 @@ def __init__(self, parent=None): self.layout().addWidget(self.view) # Configuration for time window (in seconds) - self.time_window = 3 # Show pulses from the last 3 seconds + self.time_window = window_seconds # Store pulses for visualization self.pulses = [] @@ -477,11 +479,12 @@ def get_pulse_fingerprint(self, pulse: CoyotePulse) -> str: def clean_old_pulses(self): """Remove pulses outside the time window""" current_time = time.time() - self.pulses = [p for p in self.pulses if current_time - p.timestamp <= self.time_window] + 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 > self.time_window: + if current_time - timestamp > time_window: self.pulse_fingerprints.pop(fingerprint) def add_pulse(self, pulse: CoyotePulse, applied_intensity: float, channel_limit: int): @@ -555,9 +558,10 @@ def refresh(self): # Get the time span of the visible pulses now = time.time() - oldest_time = now - self.time_window + time_window = self.time_window.get() + oldest_time = now - time_window newest_time = now - time_span_sec = self.time_window + time_span_sec = time_window # Calculate total width available for all pulses usable_width = width - 10 # Leave small margin on right side diff --git a/qt_ui/preferences_dialog.py b/qt_ui/preferences_dialog.py index b889c14..ed0dd5f 100644 --- a/qt_ui/preferences_dialog.py +++ b/qt_ui/preferences_dialog.py @@ -162,6 +162,7 @@ def loadSettings(self): 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_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 @@ -336,6 +337,7 @@ def saveSettings(self): 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_graph_window.set(self.coyote_graph_window.value()) qt_ui.settings.coyote_debug_logging.set(self.coyote_debug_logging.isChecked()) # media sync settings diff --git a/qt_ui/preferences_dialog_ui.py b/qt_ui/preferences_dialog_ui.py index 222d5f2..57f18be 100644 --- a/qt_ui/preferences_dialog_ui.py +++ b/qt_ui/preferences_dialog_ui.py @@ -520,15 +520,30 @@ def setupUi(self, PreferencesDialog): self.formLayout_coyote.setWidget(6, QFormLayout.FieldRole, self.coyote_channel_b_intensity_balance) + self.label_coyote_graph_window = QLabel(self.tab_coyote) + self.label_coyote_graph_window.setObjectName(u"label_coyote_graph_window") + + self.formLayout_coyote.setWidget(7, QFormLayout.LabelRole, self.label_coyote_graph_window) + + self.coyote_graph_window = QDoubleSpinBox(self.tab_coyote) + self.coyote_graph_window.setObjectName(u"coyote_graph_window") + self.coyote_graph_window.setDecimals(1) + self.coyote_graph_window.setMinimum(0.1) + self.coyote_graph_window.setMaximum(10.0) + self.coyote_graph_window.setSingleStep(0.1) + self.coyote_graph_window.setValue(3.0) + + self.formLayout_coyote.setWidget(7, QFormLayout.FieldRole, self.coyote_graph_window) + self.label_coyote_debug_logging = QLabel(self.tab_coyote) self.label_coyote_debug_logging.setObjectName(u"label_coyote_debug_logging") - self.formLayout_coyote.setWidget(7, QFormLayout.LabelRole, self.label_coyote_debug_logging) + self.formLayout_coyote.setWidget(8, QFormLayout.LabelRole, self.label_coyote_debug_logging) self.coyote_debug_logging = QCheckBox(self.tab_coyote) self.coyote_debug_logging.setObjectName(u"coyote_debug_logging") - self.formLayout_coyote.setWidget(7, QFormLayout.FieldRole, self.coyote_debug_logging) + self.formLayout_coyote.setWidget(8, QFormLayout.FieldRole, self.coyote_debug_logging) self.verticalLayout_coyote.addLayout(self.formLayout_coyote) @@ -923,7 +938,8 @@ def retranslateUi(self, PreferencesDialog): 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.label_coyote_debug_logging.setText(QCoreApplication.translate("PreferencesDialog", u"Debug logging", 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)) diff --git a/qt_ui/settings.py b/qt_ui/settings.py index 636ebb6..a25d76f 100644 --- a/qt_ui/settings.py +++ b/qt_ui/settings.py @@ -181,6 +181,7 @@ def set(self, value): 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", 3.0, float) coyote_debug_logging = Setting("coyote/debug_logging", False, bool) +coyote_graph_window = Setting("coyote/graph_window", 3.0, float) # Pattern preferences - we'll store this as a JSON string and convert to dict import json From 79d10696dcd6378e31db2c254ef01e91092ba3fd Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Wed, 5 Nov 2025 12:07:20 +0700 Subject: [PATCH 45/47] Coyote: refactor preferences (without pyside6-uic compile) --- designer/preferencesdialog.ui | 103 ++++++++++++++++++++++------------ device/coyote/algorithm.py | 26 ++------- device/coyote/constants.py | 4 +- qt_ui/algorithm_factory.py | 1 + qt_ui/mainwindow.py | 3 +- qt_ui/preferences_dialog.py | 8 ++- qt_ui/settings.py | 1 - stim_math/audio_gen/params.py | 1 + 8 files changed, 84 insertions(+), 63 deletions(-) diff --git a/designer/preferencesdialog.ui b/designer/preferencesdialog.ui index 893e33c..6aa95b8 100644 --- a/designer/preferencesdialog.ui +++ b/designer/preferencesdialog.ui @@ -618,30 +618,19 @@ - - - - - Device Name - - - - - - - 47L121000 - - - - - + + + Device + + + Channel A Limit - + 0 @@ -651,15 +640,14 @@ - - + Channel B Limit - + 0 @@ -669,15 +657,14 @@ - - + Channel A Freq Balance - + 0 @@ -687,15 +674,14 @@ - - + Channel B Freq Balance - + 0 @@ -705,15 +691,14 @@ - - + Channel A Intensity Balance - + 0 @@ -723,15 +708,14 @@ - - + Channel B Intensity Balance - + 0 @@ -741,14 +725,58 @@ - + + + + + + + Algorithm + + + + + + Max Intensity Change per Pulse (%) + + + + + + + 1 + + + 0.0 + + + 100.0 + + + 0.1 + + + 3.0 + + + + + + + + + + Display + + + Graph Window (s) - + 1 @@ -767,14 +795,14 @@ - + Debug Logging - + @@ -782,6 +810,7 @@ + diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py index 7c09cd1..bd9b406 100644 --- a/device/coyote/algorithm.py +++ b/device/coyote/algorithm.py @@ -69,8 +69,7 @@ TEXTURE_MAX_DEPTH_FRACTION, JITTER_CLAMP_FRACTION, RANDOMIZATION_LIMIT_FRACTION, - RESIDUAL_BOUND, - DEFAULT_MAX_CHANGE_PER_PULSE, + RESIDUAL_BOUND ) from stim_math.axis import AbstractMediaSync, AbstractAxis @@ -78,11 +77,7 @@ from stim_math.audio_gen.params import CoyoteAlgorithmParams, VolumeParams, SafetyParams from stim_math.audio_gen.various import ThreePhasePosition from device.coyote.types import CoyotePulse, CoyotePulses -try: - # Optional import; keeps algorithm functional in headless contexts - from qt_ui import settings as ui_settings -except Exception: # pragma: no cover - setting import is best-effort - ui_settings = None +from qt_ui import settings logger = logging.getLogger('restim.coyote') @@ -356,14 +351,12 @@ def __init__(self, params: CoyoteAlgorithmParams, signal: ContinuousSignal, get_positional_intensities, - max_change_per_pulse: float, queue_horizon_s: float = QUEUE_HORIZON_S): self.name = name # 'A' or 'B' self.media = media self.params = params self.signal = signal self.get_positional_intensities = get_positional_intensities - self.max_change_per_pulse = max_change_per_pulse self.queue_horizon_s = queue_horizon_s self.queue: Deque[CoyotePulse] = deque() @@ -392,8 +385,9 @@ def _generate_single_pulse(self, t_pulse: float, seq_index: int) -> CoyotePulse: else: dt = max(0.0, t_pulse - last_t) allowed = (dt / tau_s) * 100.0 - if self.max_change_per_pulse > 0: - allowed = min(allowed, self.max_change_per_pulse) + max_change = self.params.max_intensity_change_per_pulse.get() + if max_change > 0: + allowed = min(allowed, max_change) delta = np.clip(target_intensity - last_y, -allowed, allowed) y = last_y + delta @@ -494,25 +488,15 @@ def __init__(self, media: AbstractMediaSync, params: CoyoteAlgorithmParams, safe self.last_update_time_s = 0.0 self.next_update_time = 0.0 - # Global per-pulse cap (percentage points). Read from settings if available. - self._max_change_per_pulse = DEFAULT_MAX_CHANGE_PER_PULSE - try: - if ui_settings is not None: - self._max_change_per_pulse = float(ui_settings.coyote_max_intensity_change_per_pulse.get()) - except Exception: - pass - # Per-channel controllers (queues, smoothing, assembly) self.ctrl_a = ChannelController( 'A', self.media, self.params, self.signal_a, get_positional_intensities=self._get_positional_intensities, - max_change_per_pulse=self._max_change_per_pulse, queue_horizon_s=QUEUE_HORIZON_S, ) self.ctrl_b = ChannelController( 'B', self.media, self.params, self.signal_b, get_positional_intensities=self._get_positional_intensities, - max_change_per_pulse=self._max_change_per_pulse, queue_horizon_s=QUEUE_HORIZON_S, ) diff --git a/device/coyote/constants.py b/device/coyote/constants.py index aea83d3..c0703f7 100644 --- a/device/coyote/constants.py +++ b/device/coyote/constants.py @@ -1,3 +1,6 @@ +# BLE device identification +DEVICE_NAME = "47L121000" + # Coyote hardware timing and derived limits # Pulse duration limits in milliseconds MIN_PULSE_DURATION_MS = 5 @@ -19,7 +22,6 @@ JITTER_CLAMP_FRACTION = 0.5 RANDOMIZATION_LIMIT_FRACTION = 0.1 # limit randomization to 10% of setting RESIDUAL_BOUND = 0.49 # clamp fractional residual for rounding fairness -DEFAULT_MAX_CHANGE_PER_PULSE = 3.0 # percentage points per pulse if setting unavailable # BLE / Protocol constants LOG_PREFIX = "[Coyote]" diff --git a/qt_ui/algorithm_factory.py b/qt_ui/algorithm_factory.py index 9141f89..cbf8d15 100644 --- a/qt_ui/algorithm_factory.py +++ b/qt_ui/algorithm_factory.py @@ -273,6 +273,7 @@ def create_coyote(self, device: DeviceConfiguration) -> AudioGenerationAlgorithm 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, diff --git a/qt_ui/mainwindow.py b/qt_ui/mainwindow.py index 59a217b..7db5aab 100644 --- a/qt_ui/mainwindow.py +++ b/qt_ui/mainwindow.py @@ -32,6 +32,7 @@ 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 @@ -408,7 +409,7 @@ def set_visible(widget, state): ) if config.device_type == DeviceType.COYOTE_THREE_PHASE: - self.output_device = CoyoteDevice(qt_ui.settings.coyote_device_name.get()) + 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(), diff --git a/qt_ui/preferences_dialog.py b/qt_ui/preferences_dialog.py index ed0dd5f..c8dee7e 100644 --- a/qt_ui/preferences_dialog.py +++ b/qt_ui/preferences_dialog.py @@ -155,13 +155,15 @@ def loadSettings(self): self.neostim_port.setCurrentIndex(self.neostim_port.findData(qt_ui.settings.neostim_serial_port.get())) # Coyote 3 - self.coyote_device_name.setText(qt_ui.settings.coyote_device_name.get()) 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()) @@ -330,13 +332,15 @@ def saveSettings(self): qt_ui.settings.neostim_serial_port.set(str(self.neostim_port.currentData())) # Coyote 3 - qt_ui.settings.coyote_device_name.set(self.coyote_device_name.text()) 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()) diff --git a/qt_ui/settings.py b/qt_ui/settings.py index a25d76f..bba9e47 100644 --- a/qt_ui/settings.py +++ b/qt_ui/settings.py @@ -166,7 +166,6 @@ def set(self, value): neostim_serial_port = Setting("neostim/serial_port", '', str) -coyote_device_name = Setting('coyote/device_name', '47L121000', 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) diff --git a/stim_math/audio_gen/params.py b/stim_math/audio_gen/params.py index 93237d8..8234170 100644 --- a/stim_math/audio_gen/params.py +++ b/stim_math/audio_gen/params.py @@ -191,6 +191,7 @@ class CoyoteAlgorithmParams: 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 From fc302adcd6bfdce136f3bd4bffe9d9761d225cb7 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Sun, 9 Nov 2025 15:59:59 +0700 Subject: [PATCH 46/47] Coyote: refactor and improve config (also recompiles preferences_dialog_ui.py) --- device/coyote/algorithm.py | 816 +++++++--------------------- device/coyote/channel_controller.py | 153 ++++++ device/coyote/channel_state.py | 46 ++ device/coyote/common.py | 42 ++ device/coyote/config.py | 47 ++ device/coyote/constants.py | 12 +- device/coyote/pulse_generator.py | 205 +++++++ qt_ui/preferences_dialog_ui.py | 190 ++++--- qt_ui/settings.py | 7 + 9 files changed, 798 insertions(+), 720 deletions(-) create mode 100644 device/coyote/channel_controller.py create mode 100644 device/coyote/channel_state.py create mode 100644 device/coyote/common.py create mode 100644 device/coyote/config.py create mode 100644 device/coyote/pulse_generator.py diff --git a/device/coyote/algorithm.py b/device/coyote/algorithm.py index bd9b406..e1bf088 100644 --- a/device/coyote/algorithm.py +++ b/device/coyote/algorithm.py @@ -51,666 +51,232 @@ from audio-based continuous algorithms due to fundamental hardware limitations. """ +from __future__ import annotations + import logging import time -import numpy as np -from collections import deque -from typing import List, Tuple, Deque, Optional -from device.coyote.constants import ( - MIN_PULSE_DURATION_MS, - MAX_PULSE_DURATION_MS, - HARDWARE_MIN_FREQ_HZ, - HARDWARE_MAX_FREQ_HZ, - PULSES_PER_PACKET, - QUEUE_HORIZON_S, - PACKET_MARGIN, - TEXTURE_MIN_HZ, - TEXTURE_MAX_HZ, - TEXTURE_MAX_DEPTH_FRACTION, - JITTER_CLAMP_FRACTION, - RANDOMIZATION_LIMIT_FRACTION, - RESIDUAL_BOUND -) - -from stim_math.axis import AbstractMediaSync, AbstractAxis -from stim_math.threephase import ThreePhaseCenterCalibration -from stim_math.audio_gen.params import CoyoteAlgorithmParams, VolumeParams, SafetyParams -from stim_math.audio_gen.various import ThreePhasePosition +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 qt_ui import settings - -logger = logging.getLogger('restim.coyote') - - - - -def compute_volume(media: AbstractMediaSync, volume_params: VolumeParams, t: float) -> float: - """Calculate the overall volume multiplier from all volume sources.""" - if not media.is_playing(): - return 0.0 - - master = np.clip(volume_params.master.last_value(), 0, 1) - api = np.clip(volume_params.api.interpolate(t), 0, 1) - inactivity = np.clip(volume_params.inactivity.last_value(), 0, 1) - external = np.clip(volume_params.external.last_value(), 0, 1) - - if inactivity == 0: - inactivity = 1.0 - - volume = master * api * inactivity * external - - return volume - - - -class ChannelState: - """Holds the state for a single channel's pulse packet and timing.""" - def __init__(self): - self.current_packet: Deque[CoyotePulse] = deque() - self.time_in_packet_ms = 0.0 - self.total_packet_duration_ms = 0.0 - self.packet_start_time_s = 0.0 - self.packet_finish_time_s = 0.0 - - def set_new_packet(self, t: float, packet: List[CoyotePulse], time_to_finish_s: float): - """Updates the channel with a new packet and its timing information.""" - self.current_packet = deque(packet) - self.packet_start_time_s = t - self.packet_finish_time_s = self.packet_start_time_s + time_to_finish_s - self.total_packet_duration_ms = sum(p.duration for p in packet) - self.time_in_packet_ms = 0.0 - - def advance_time(self, delta_time_ms: float): - self.time_in_packet_ms += delta_time_ms - - def is_ready_for_next_packet(self) -> bool: - """Returns True if the current packet has finished playing.""" - return self.get_remaining_time_ms() <= 0 - - def get_remaining_time_ms(self) -> float: - if self.total_packet_duration_ms == 0: - return 0.0 # Ready for first packet - return max(0.0, self.total_packet_duration_ms - self.time_in_packet_ms) - - -def _normalize_axis(value: float, limits: Tuple[float, float]) -> float: - """Normalize a raw axis value to a 0-100 scale based on its limits.""" - min_val, max_val = limits - if max_val <= min_val: - return 0.0 - return (value - min_val) / (max_val - min_val) * 100.0 - - -def _get_normalized_parameters(params: CoyoteAlgorithmParams, t: float, - carrier_freq_limits: Tuple[float, float], - pulse_freq_limits: Tuple[float, float]) -> Tuple[float, float, float, float]: - """Get parameter values: normalize frequency axes (Hz), return raw cycle values.""" - carrier_freq_raw = params.carrier_frequency.interpolate(t) - pulse_freq_raw = params.pulse_frequency.interpolate(t) - - # Normalize frequency axes from Hz ranges - carrier_freq = _normalize_axis(carrier_freq_raw, carrier_freq_limits) - pulse_freq = _normalize_axis(pulse_freq_raw, pulse_freq_limits) - - # Return raw cycle values for pulse_width and pulse_rise_time - pulse_width_cycles = params.pulse_width.interpolate(t) - pulse_rise_time_cycles = params.pulse_rise_time.interpolate(t) - - return carrier_freq, pulse_freq, pulse_width_cycles, pulse_rise_time_cycles - - -class ContinuousSignal: - """Models a single channel's pulse generation. - - Generates pulses with duration anchored to the funscript pulse_frequency parameter, - mapped into the channel's configured frequency range. Applies optional jitter and - zero-mean micro-texture modulation via the pulse_width parameter. - - The carrier_frequency parameter controls the speed of the texture modulation phase. - """ - - def __init__(self, params: CoyoteAlgorithmParams, channel_params: 'CoyoteChannelParams', - 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], - channel_name: str = ""): - self.params = params - self.channel_params = channel_params - self.carrier_freq_limits = carrier_freq_limits - self.pulse_freq_limits = pulse_freq_limits - self.pulse_width_limits = pulse_width_limits - self.pulse_rise_time_limits = pulse_rise_time_limits - self.channel_name = channel_name - - # Timing state - self._last_pulse_time = 0.0 - self._start_time = None - self.modulation_phase = 0.0 - self._duration_residual_ms = 0.0 # fractional ms accumulator to reduce rounding jitter - - def _calculate_effective_frequency_limits(self) -> Tuple[float, float]: - """Calculate effective frequency limits considering hardware constraints.""" - channel_min = self.channel_params.minimum_frequency.get() - channel_max = self.channel_params.maximum_frequency.get() - hardware_max = HARDWARE_MAX_FREQ_HZ # ~200 Hz - hardware_min = HARDWARE_MIN_FREQ_HZ # ~4.17 Hz - - effective_min = max(channel_min, hardware_min) - effective_max = min(channel_max, hardware_max) - - # Fallback to hardware limits if channel limits are invalid - if effective_min >= effective_max: - effective_min = hardware_min - effective_max = hardware_max - - return effective_min, effective_max - - def _apply_frequency_randomization(self, base_frequency: float, randomization_strength: float, - min_freq: float, max_freq: float) -> float: - """Apply limited randomization to frequency.""" - if randomization_strength <= 0: - return base_frequency - - # Limit randomization to a fraction of the setting - random_percentage = randomization_strength / 100.0 * RANDOMIZATION_LIMIT_FRACTION - random_factor = 1.0 + (np.random.rand() - 0.5) * 2 * random_percentage - randomized_freq = base_frequency * random_factor - - return np.clip(randomized_freq, min_freq, max_freq) - - def get_pulse_at(self, current_time: float, base_intensity: float, pulse_index: int = 0) -> CoyotePulse: - """Generate a pulse anchored to the requested pulse_frequency with optional jitter. - - - pulse_frequency: controls the base repetition rate (Hz) - - pulse_interval_random: ±fractional jitter of the base duration - - pulse_width: controls zero-mean micro-texture depth (duration modulation) - - intensity: provided by caller (volume × position, smoothed elsewhere) - """ - # Initialize timing state - if self._start_time is None: - self._start_time = current_time - self._last_pulse_time = current_time - - # Effective channel limits → convert to duration window - min_freq, max_freq = self._calculate_effective_frequency_limits() - min_dur = max(MIN_PULSE_DURATION_MS, int(round(1000.0 / max_freq))) - max_dur = min(MAX_PULSE_DURATION_MS, int(round(1000.0 / min_freq))) - - # Map global funscript pulse_frequency into the channel's preferred range - # 1) Normalize funscript value using global pulse_freq_limits (kit limits) - raw_pf = float(self.params.pulse_frequency.interpolate(current_time)) - pf_min, pf_max = self.pulse_freq_limits - if pf_max <= pf_min: - pf_norm = 0.0 - else: - pf_norm = float(np.clip((raw_pf - pf_min) / (pf_max - pf_min), 0.0, 1.0)) - # 2) Map normalized value into channel-specific [min_freq, max_freq] - mapped_freq = min_freq + pf_norm * (max_freq - min_freq) - mapped_freq = float(np.clip(mapped_freq, min_freq, max_freq)) - base_duration = 1000.0 / mapped_freq if mapped_freq > 0 else max_dur - - # Optional jitter around base duration (from funscript) - jitter = float(self.params.pulse_interval_random.interpolate(current_time)) - # Treat jitter as a 0..1 fraction; clamp to avoid pathological spans - jitter = float(np.clip(jitter, 0.0, JITTER_CLAMP_FRACTION)) - jitter_factor = 1.0 + (np.random.rand() * 2.0 - 1.0) * jitter - - # Micro-texture from pulse_width (depth) with phase advanced in _advance_channel_states - # Normalize pulse_width cycles to 0..1 using limits - width_cycles = float(self.params.pulse_width.interpolate(current_time)) - min_w, max_w = self.pulse_width_limits - width_norm = 0.0 if max_w <= min_w else np.clip((width_cycles - min_w) / (max_w - min_w), 0.0, 1.0) - - # Floating headroom on each side of base (use float limits, clamp to >=0) - min_dur_f = 1000.0 / max_freq - max_dur_f = 1000.0 / min_freq - amp_up_ms = max(0.0, max_dur_f - base_duration) # can increase duration up to this much - amp_dn_ms = max(0.0, base_duration - min_dur_f) # can decrease duration up to this much - - amp_up_ms *= TEXTURE_MAX_DEPTH_FRACTION * width_norm - amp_dn_ms *= TEXTURE_MAX_DEPTH_FRACTION * width_norm - - # Zero-mean texture respecting asymmetric headroom - s = np.sin(self.modulation_phase) - if amp_up_ms > 1e-6 and amp_dn_ms > 1e-6: - # Symmetric case: use sine with symmetric amplitude - texture_amplitude_ms = min(amp_up_ms, amp_dn_ms) - texture_ms = texture_amplitude_ms * s - tex_mode = 'sym' - elif amp_up_ms > 1e-6: - # One-sided (can only go up). Use rectified sine and subtract DC (E|sin|=2/π) - texture_amplitude_ms = amp_up_ms - texture_ms = amp_up_ms * (abs(s) - 2.0/np.pi) - tex_mode = 'up' - elif amp_dn_ms > 1e-6: - # One-sided (can only go down). Negative rectified sine with DC removed - texture_amplitude_ms = amp_dn_ms - texture_ms = -amp_dn_ms * (abs(s) - 2.0/np.pi) - tex_mode = 'down' - else: - texture_amplitude_ms = 0.0 - texture_ms = 0.0 - tex_mode = 'none' - - desired_ms = base_duration * jitter_factor + texture_ms - - # Fractional-duration accumulation to reduce jagged 10↔11ms toggling - accum = self._duration_residual_ms + desired_ms - pulse_duration = int(np.floor(accum + 0.5)) # nearest int - self._duration_residual_ms = accum - pulse_duration - # Keep residual bounded for numerical stability - if self._duration_residual_ms > RESIDUAL_BOUND: - self._duration_residual_ms = RESIDUAL_BOUND - elif self._duration_residual_ms < -RESIDUAL_BOUND: - self._duration_residual_ms = -RESIDUAL_BOUND - - # Clamp to channel-specific duration window and hardware bounds - clamped = False - if pulse_duration < min_dur: - pulse_duration = min_dur - clamped = True - elif pulse_duration > max_dur: - pulse_duration = max_dur - clamped = True - # If clamped to bounds, do not let residual drift; rounding is only for integer fairness - if clamped: - self._duration_residual_ms = 0.0 - - final_frequency = int(max(1, round(1000.0 / pulse_duration))) - - # Intensity is supplied by the caller (already smoothed). Do not modulate here. - final_intensity = int(np.clip(base_intensity, 0, 100)) - - # Debug: log pulse generation details - if logger.isEnabledFor(logging.DEBUG): - logger.debug( - f" [{self.channel_name}] pulse #{pulse_index}: " - f"freq_raw={raw_pf:.1f} Hz, freq_norm={pf_norm:.2f}, freq_mapped={mapped_freq:.1f} Hz, " - f"freq_limits=({min_freq:.1f}-{max_freq:.1f}) Hz | " - f"base_dur={base_duration:.1f} ms, dur_limits=({min_dur}-{max_dur}) ms, width_norm={width_norm:.2f}, " - f"jitter={jitter:.0%} | " - f"texture_mode={tex_mode}, texture_up={amp_up_ms:.2f} ms, texture_dn={amp_dn_ms:.2f} ms, texture_used={texture_amplitude_ms:.2f} ms | " - f"desired={desired_ms:.2f} ms, residual={self._duration_residual_ms:+.2f} ms | " - f"result: dur={pulse_duration} ms, freq={final_frequency} Hz, intensity={final_intensity}%" - ) - - return CoyotePulse( - duration=pulse_duration, - intensity=final_intensity, - frequency=final_frequency, - ) +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 ChannelController: - """Encapsulates per-channel queuing, smoothing, and packet assembly.""" - def __init__(self, - name: str, - media: AbstractMediaSync, - params: CoyoteAlgorithmParams, - signal: ContinuousSignal, - get_positional_intensities, - queue_horizon_s: float = QUEUE_HORIZON_S): - self.name = name # 'A' or 'B' +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.signal = signal - self.get_positional_intensities = get_positional_intensities - self.queue_horizon_s = queue_horizon_s - - self.queue: Deque[CoyotePulse] = deque() - self.queue_end_time: float | None = None - - # Smoothing state - self._last_intensity: float | None = None - self._last_intensity_time: float | None = None - - # Fill summary for logging - self._last_fill_summary = None - - def _generate_single_pulse(self, t_pulse: float, seq_index: int) -> CoyotePulse: - volume = compute_volume(self.media, self.params.volume, t_pulse) - ia, ib = self.get_positional_intensities(t_pulse, volume) - target_intensity = float(ia if self.name == 'A' else ib) - - carrier_hz = float(self.params.carrier_frequency.interpolate(t_pulse)) - rise_cycles = float(self.params.pulse_rise_time.interpolate(t_pulse)) - tau_s = 0.0 if carrier_hz <= 0 else (rise_cycles / carrier_hz) - - last_y = self._last_intensity - last_t = self._last_intensity_time - if last_y is None or tau_s <= 0 or last_t is None: - y = target_intensity - else: - dt = max(0.0, t_pulse - last_t) - allowed = (dt / tau_s) * 100.0 - max_change = self.params.max_intensity_change_per_pulse.get() - if max_change > 0: - allowed = min(allowed, max_change) - delta = np.clip(target_intensity - last_y, -allowed, allowed) - y = last_y + delta - - self._last_intensity = y - self._last_intensity_time = t_pulse - - intensity = int(np.clip(round(y), 0, 100)) - pulse = self.signal.get_pulse_at(t_pulse, intensity, seq_index) - return pulse - - def fill_queue(self, now_s: float) -> None: - # Compute end_time from current queue coverage relative to now - coverage_s = sum(p.duration for p in self.queue) / 1000.0 - end_time = now_s + coverage_s - horizon_end = now_s + self.queue_horizon_s - - seq_index = 0 - new_pulses = [] - while end_time < horizon_end or len(self.queue) < PULSES_PER_PACKET: - pulse = self._generate_single_pulse(end_time, seq_index) - self.queue.append(pulse) - new_pulses.append(pulse) - end_time += pulse.duration / 1000.0 - seq_index += 1 - - self.queue_end_time = end_time - - # Log queue fill summary - if logger.isEnabledFor(logging.DEBUG): - if new_pulses: - durations = [p.duration for p in new_pulses] - frequencies = [p.frequency for p in new_pulses] - total_ms = sum(durations) - logger.debug( - f" [{self.name}] Queue filled: " - f"added={len(new_pulses)}, " - f"dur_range={min(durations)}-{max(durations)} ms, " - f"freq_range={min(frequencies)}-{max(frequencies)} Hz, " - f"total_added={total_ms} ms | " - f"queue_size={len(self.queue)}, " - f"coverage={coverage_s * 1000:.0f} ms, " - f"horizon={self.queue_horizon_s * 1000:.0f} ms\n" - ) - self._last_fill_summary = ( - len(new_pulses), - min(durations), - max(durations), - min(frequencies), - max(frequencies) - ) - else: - logger.debug( - f" [{self.name}] Queue status: " - f"queue_size={len(self.queue)}, " - f"coverage={coverage_s * 1000:.0f} ms, " - f"horizon={self.queue_horizon_s * 1000:.0f} ms (no refill needed)\n" - ) - self._last_fill_summary = None - else: - self._last_fill_summary = None + 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() - def pop_packet(self) -> List[CoyotePulse]: - packet: List[CoyotePulse] = [] - while len(packet) < PULSES_PER_PACKET: - if self.queue: - packet.append(self.queue.popleft()) - else: - packet.append(CoyotePulse(frequency=0, intensity=0, duration=MIN_PULSE_DURATION_MS)) - return packet + self.position = ThreePhasePosition(params.position, params.transform) - def has_minimum_pulses(self, n: int) -> bool: - return len(self.queue) >= n + 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) -class CoyoteAlgorithm: - """Coyote 3.0 pulse generation algorithm. - - Coordinates dual-channel pulse generation using ContinuousSignal instances. - Handles packet timing, positional intensity distribution, and smoothed intensity transitions. - Each channel maintains an independent pulse queue for continuous output. - """ - 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]): - self.media = media - self.params = params - self.calibration = ThreePhaseCenterCalibration(params.calibrate) - self.position = ThreePhasePosition(params.position, params.transform) + self._last_update_time: Optional[float] = None + self.next_update_time: float = 0.0 + self._start_time: Optional[float] = None - self.signal_a = ContinuousSignal(params, params.channel_a, carrier_freq_limits, pulse_freq_limits, - pulse_width_limits, pulse_rise_time_limits, channel_name="A") - self.signal_b = ContinuousSignal(params, params.channel_b, carrier_freq_limits, pulse_freq_limits, - pulse_width_limits, pulse_rise_time_limits, channel_name="B") + def generate_packet(self, current_time: float) -> Optional[CoyotePulses]: + if self._last_update_time is None: + self._last_update_time = current_time - self.channel_a = ChannelState() - self.channel_b = ChannelState() + delta_ms = max(0.0, (current_time - self._last_update_time) * 1000.0) + self._last_update_time = current_time - self.start_time = None - self.last_update_time_s = 0.0 - self.next_update_time = 0.0 + self._advance_state(current_time, delta_ms) - # Per-channel controllers (queues, smoothing, assembly) - self.ctrl_a = ChannelController( - 'A', self.media, self.params, self.signal_a, - get_positional_intensities=self._get_positional_intensities, - queue_horizon_s=QUEUE_HORIZON_S, - ) - self.ctrl_b = ChannelController( - 'B', self.media, self.params, self.signal_b, - get_positional_intensities=self._get_positional_intensities, - queue_horizon_s=QUEUE_HORIZON_S, - ) + if not self._needs_packet(): + self._schedule_from_remaining(current_time) + return None - # Smoothing state handled by ChannelController + for channel in self._channels: + channel.controller.fill_queue(current_time) - # Channel-specific pulse generation and queues moved to ChannelController + 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) - def _get_positional_intensities(self, t: float, volume: float) -> Tuple[int, int]: - """Barycentric phase diagram mapping: (beta, alpha) with left=+1, right=-1, neutral=+1 (top).""" - alpha, beta = self.position.get_position(t) + 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) - # Barycentric weights for triangle corners - w_L = max(0.0, (beta + 1) / 2) - w_R = max(0.0, (1 - beta) / 2) - w_N = max(0.0, alpha) - sum_w = w_L + w_R + w_N - if sum_w > 0: - w_L /= sum_w - w_R /= sum_w - w_N /= sum_w - else: - w_L = w_R = w_N = 0.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) - # Calibration scaling - center_val = self.params.calibrate.center.last_value() - center_calib = ThreePhaseCenterCalibration(center_val) - scale = center_calib.get_scale(alpha, beta) + return CoyotePulses(pulses_a, pulses_b) - # Channel mapping: A = left+neutral, B = right+neutral - intensity_a = int((w_L + w_N) * volume * scale * 100.0) - intensity_b = int((w_R + w_N) * volume * scale * 100.0) + def get_next_update_time(self) -> float: + return self.next_update_time - return intensity_a, intensity_b + 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 - # Per-channel pulse generation was refactored into ChannelController + def _advance_state(self, current_time: float, delta_ms: float) -> None: + for channel in self._channels: + channel.state.advance(delta_ms) - def _get_media_type(self) -> str: - """Determine the media type for logging purposes.""" - if hasattr(self.media, 'media_type'): - return str(getattr(self.media, 'media_type')) - - class_name = self.media.__class__.__name__.lower() - if class_name.startswith('internal'): - return 'internal' - elif 'vlc' in class_name: - return 'vlc' - elif 'mpv' in class_name: - return 'mpv' + 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: - return class_name - - def _get_display_time(self, current_time: float) -> Tuple[int, int, int, int]: - """Get formatted time for debug logging.""" - media_type = self._get_media_type() - - # Try to use media timestamp if available - if (media_type != 'internal' and - hasattr(self.media, 'is_playing') and self.media.is_playing() and - hasattr(self.media, 'map_timestamp')): - try: - rel_time_s = self.media.map_timestamp(time.time()) - if rel_time_s is not None and rel_time_s >= 0: - return self._seconds_to_time_components(rel_time_s) - except Exception: - pass - - # Use local time for internal media - if media_type == 'internal': - now = time.localtime() - millis = int((time.time() - int(time.time())) * 1000) - return now.tm_hour, now.tm_min, now.tm_sec, millis - - # Use relative time from start - if self.start_time is None: - self.start_time = current_time - rel_time_s = current_time - self.start_time - return self._seconds_to_time_components(rel_time_s) - - def _seconds_to_time_components(self, seconds: float) -> Tuple[int, int, int, int]: - """Convert seconds to (hours, minutes, seconds, milliseconds).""" - hours = int(seconds // 3600) - minutes = int((seconds % 3600) // 60) - secs = int(seconds % 60) - millis = int((seconds - int(seconds)) * 1000) - return hours, minutes, secs, millis - - def _advance_channel_states(self, current_time: float, delta_time_ms: float) -> None: - """Advance channel timing and modulation phase for the next packet.""" - self.channel_a.advance_time(delta_time_ms) - self.channel_b.advance_time(delta_time_ms) - - # Advance shared micro-texture phase using carrier axis as speed control (mapped to ~0.5..5 Hz) - carrier_norm, _, _, _ = _get_normalized_parameters( - self.params, current_time, self.signal_a.carrier_freq_limits, self.signal_a.pulse_freq_limits) - texture_speed_hz = TEXTURE_MIN_HZ + (TEXTURE_MAX_HZ - TEXTURE_MIN_HZ) * (carrier_norm / 100.0) - delta_time_s = delta_time_ms / 1000.0 - phase_change = (delta_time_s * texture_speed_hz) * 2 * np.pi - # Keep both channels in sync for texture phase - self.signal_a.modulation_phase = (self.signal_a.modulation_phase + phase_change) % (2 * np.pi) - self.signal_b.modulation_phase = (self.signal_b.modulation_phase + phase_change) % (2 * np.pi) - - def _is_packet_generation_needed(self) -> bool: - """Check if either channel needs a new packet. - - We also allow proactive generation if queues are running low, to avoid - starving the device with repeats for too long. - """ - low_queue = (not self.ctrl_a.has_minimum_pulses(PULSES_PER_PACKET) or - not self.ctrl_b.has_minimum_pulses(PULSES_PER_PACKET)) - ready = (self.channel_a.is_ready_for_next_packet() or - self.channel_b.is_ready_for_next_packet()) - return ready or low_queue - - def _schedule_next_update(self, current_time: float, packet_duration_a: float, - packet_duration_b: float, margin: float = PACKET_MARGIN) -> float: - """Schedule the next update time based on packet durations. Returns next update delta in ms.""" - min_duration = min(packet_duration_a, packet_duration_b) - self.next_update_time = current_time + min_duration * margin - return min_duration * margin * 1000 - - def _log_packet_debug(self, current_time: float, alpha: float, beta: float, - pulses_a: List[CoyotePulse], pulses_b: List[CoyotePulse], - total_duration_a: float, total_duration_b: float, next_update_ms: float, margin: float) -> None: - """Log debug information for generated packet.""" + 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 - - hours, minutes, seconds, millis = self._get_display_time(current_time) - media_type = self._get_media_type() - volume = compute_volume(self.media, self.params.volume, current_time) - - log_lines = [ + + 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 @ {hours:02}:{minutes:02}:{seconds:02}.{millis:03} [{media_type}]", + 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={total_duration_a:.0f} ms", + f"Channel A: duration={duration_a_ms:.0f} ms", ] - - for i, p in enumerate(pulses_a, 1): - log_lines.append(f" Pulse {i}: {p.duration} ms @ {p.frequency} Hz ({p.intensity}%)") - - log_lines.extend([ - "", - f"Channel B: duration={total_duration_b:.0f} ms" - ]) - - for i, p in enumerate(pulses_b, 1): - log_lines.append(f" Pulse {i}: {p.duration} ms @ {p.frequency} Hz ({p.intensity}%)") - - log_lines.extend([ - "", - f"Next update: {next_update_ms:.0f} ms (packet_dur_a={total_duration_a:.0f} ms, packet_dur_b={total_duration_b:.0f} ms, margin={margin:.0%})", - "=" * 72, - "" - ]) - - logger.debug("\n".join(log_lines)) - - def generate_packet(self, current_time: float) -> Optional[CoyotePulses]: - """Generate one packet of pulses for both channels.""" - # Request next packet after threshold of current one has played - - # Initialize timing on first call - if self.last_update_time_s == 0.0: - self.last_update_time_s = current_time - - # Advance channel states - delta_time_ms = (current_time - self.last_update_time_s) * 1000.0 - self.last_update_time_s = current_time - self._advance_channel_states(current_time, delta_time_ms) - - # Check if packet generation is needed - if not self._is_packet_generation_needed(): - # Schedule next update based on remaining time - remaining_time_ms = min( - self.channel_a.get_remaining_time_ms(), - self.channel_b.get_remaining_time_ms() - ) - self.next_update_time = current_time + (remaining_time_ms / 1000.0) * PACKET_MARGIN - return None - # Ensure queues are filled ahead of time - if logger.isEnabledFor(logging.DEBUG): - logger.debug("=== Channel A: Filling Queue ===") - self.ctrl_a.fill_queue(current_time) - - if logger.isEnabledFor(logging.DEBUG): - logger.debug("=== Channel B: Filling Queue ===") - self.ctrl_b.fill_queue(current_time) - - # Assemble packets by popping from queues (atomic update for A and B) - alpha, beta = self.position.get_position(current_time) - pulses_a = self.ctrl_a.pop_packet() - pulses_b = self.ctrl_b.pop_packet() - duration_a = sum(p.duration for p in pulses_a) - duration_b = sum(p.duration for p in pulses_b) - - # Update channel states so readiness reflects packet progress - try: - self.channel_a.set_new_packet(current_time, pulses_a, duration_a / 1000.0) - self.channel_b.set_new_packet(current_time, pulses_b, duration_b / 1000.0) - except Exception: - pass - - # Schedule next update and get the delta for logging - next_update_ms = self._schedule_next_update(current_time, duration_a / 1000.0, duration_b / 1000.0, PACKET_MARGIN) - - # Log debug information - self._log_packet_debug(current_time, alpha, beta, pulses_a, pulses_b, duration_a, duration_b, next_update_ms, PACKET_MARGIN) + 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, + "", + ] + ) - return CoyotePulses(pulses_a, pulses_b) + 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 - def get_next_update_time(self) -> float: - return self.next_update_time + 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 index c0703f7..51c7862 100644 --- a/device/coyote/constants.py +++ b/device/coyote/constants.py @@ -10,18 +10,8 @@ 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 and queue behavior +# Packet behaviour PULSES_PER_PACKET = 4 -QUEUE_HORIZON_S = 0.75 -PACKET_MARGIN = 0.8 # request next packet when ~80% of current one has played - -# Pulse generation behavior -TEXTURE_MIN_HZ = 0.5 -TEXTURE_MAX_HZ = 5.0 -TEXTURE_MAX_DEPTH_FRACTION = 0.5 -JITTER_CLAMP_FRACTION = 0.5 -RANDOMIZATION_LIMIT_FRACTION = 0.1 # limit randomization to 10% of setting -RESIDUAL_BOUND = 0.49 # clamp fractional residual for rounding fairness # BLE / Protocol constants LOG_PREFIX = "[Coyote]" 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/qt_ui/preferences_dialog_ui.py b/qt_ui/preferences_dialog_ui.py index 57f18be..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) @@ -436,117 +436,138 @@ def setupUi(self, PreferencesDialog): self.tab_coyote.setObjectName(u"tab_coyote") self.verticalLayout_coyote = QVBoxLayout(self.tab_coyote) self.verticalLayout_coyote.setObjectName(u"verticalLayout_coyote") - self.formLayout_coyote = QFormLayout() - self.formLayout_coyote.setObjectName(u"formLayout_coyote") - self.label_coyote_device_name = QLabel(self.tab_coyote) - self.label_coyote_device_name.setObjectName(u"label_coyote_device_name") - - self.formLayout_coyote.setWidget(0, QFormLayout.LabelRole, self.label_coyote_device_name) - - self.coyote_device_name = QLineEdit(self.tab_coyote) - self.coyote_device_name.setObjectName(u"coyote_device_name") - - self.formLayout_coyote.setWidget(0, QFormLayout.FieldRole, self.coyote_device_name) - - self.label_coyote_channel_a_limit = QLabel(self.tab_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.setWidget(1, QFormLayout.LabelRole, self.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.tab_coyote) + 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.setWidget(1, QFormLayout.FieldRole, self.coyote_channel_a_limit) + self.formLayout_coyote_params.setWidget(0, QFormLayout.ItemRole.FieldRole, self.coyote_channel_a_limit) - self.label_coyote_channel_b_limit = QLabel(self.tab_coyote) + 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.setWidget(2, QFormLayout.LabelRole, self.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.tab_coyote) + 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.setWidget(2, QFormLayout.FieldRole, self.coyote_channel_b_limit) + self.formLayout_coyote_params.setWidget(1, QFormLayout.ItemRole.FieldRole, self.coyote_channel_b_limit) - self.label_coyote_channel_a_freq_balance = QLabel(self.tab_coyote) + 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.setWidget(3, QFormLayout.LabelRole, self.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.tab_coyote) + 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.setWidget(3, QFormLayout.FieldRole, self.coyote_channel_a_freq_balance) + self.formLayout_coyote_params.setWidget(2, QFormLayout.ItemRole.FieldRole, self.coyote_channel_a_freq_balance) - self.label_coyote_channel_b_freq_balance = QLabel(self.tab_coyote) + 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.setWidget(4, QFormLayout.LabelRole, self.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.tab_coyote) + 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.setWidget(4, QFormLayout.FieldRole, self.coyote_channel_b_freq_balance) + self.formLayout_coyote_params.setWidget(3, QFormLayout.ItemRole.FieldRole, self.coyote_channel_b_freq_balance) - self.label_coyote_channel_a_intensity_balance = QLabel(self.tab_coyote) + 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.setWidget(5, QFormLayout.LabelRole, self.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.tab_coyote) + 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.setWidget(5, QFormLayout.FieldRole, self.coyote_channel_a_intensity_balance) + self.formLayout_coyote_params.setWidget(4, QFormLayout.ItemRole.FieldRole, self.coyote_channel_a_intensity_balance) - self.label_coyote_channel_b_intensity_balance = QLabel(self.tab_coyote) + 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.setWidget(6, QFormLayout.LabelRole, self.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.tab_coyote) + 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.setWidget(6, QFormLayout.FieldRole, self.coyote_channel_b_intensity_balance) + 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.label_coyote_graph_window = QLabel(self.tab_coyote) + 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.setWidget(7, QFormLayout.LabelRole, self.label_coyote_graph_window) + self.formLayout_coyote_display.setWidget(0, QFormLayout.ItemRole.LabelRole, self.label_coyote_graph_window) - self.coyote_graph_window = QDoubleSpinBox(self.tab_coyote) + 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.1) - self.coyote_graph_window.setMaximum(10.0) - self.coyote_graph_window.setSingleStep(0.1) - self.coyote_graph_window.setValue(3.0) + 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.setWidget(7, QFormLayout.FieldRole, self.coyote_graph_window) + self.formLayout_coyote_display.setWidget(0, QFormLayout.ItemRole.FieldRole, self.coyote_graph_window) - self.label_coyote_debug_logging = QLabel(self.tab_coyote) + self.label_coyote_debug_logging = QLabel(self.groupBox_coyote_display) self.label_coyote_debug_logging.setObjectName(u"label_coyote_debug_logging") - self.formLayout_coyote.setWidget(8, QFormLayout.LabelRole, self.label_coyote_debug_logging) + self.formLayout_coyote_display.setWidget(1, QFormLayout.ItemRole.LabelRole, self.label_coyote_debug_logging) - self.coyote_debug_logging = QCheckBox(self.tab_coyote) + self.coyote_debug_logging = QCheckBox(self.groupBox_coyote_display) self.coyote_debug_logging.setObjectName(u"coyote_debug_logging") - self.formLayout_coyote.setWidget(8, QFormLayout.FieldRole, self.coyote_debug_logging) + self.formLayout_coyote_display.setWidget(1, QFormLayout.ItemRole.FieldRole, self.coyote_debug_logging) - self.verticalLayout_coyote.addLayout(self.formLayout_coyote) + self.verticalLayout_coyote.addWidget(self.groupBox_coyote_display) self.tabWidget.addTab(self.tab_coyote, "") self.tab_media_settings = QWidget() @@ -807,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) @@ -911,33 +930,35 @@ 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.label_coyote_device_name.setText(QCoreApplication.translate("PreferencesDialog", u"Device Name", None)) - self.coyote_device_name.setText(QCoreApplication.translate("PreferencesDialog", u"47L121000", 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("") @@ -972,3 +993,4 @@ def retranslateUi(self, PreferencesDialog): ___qtablewidgetitem1.setText(QCoreApplication.translate("PreferencesDialog", u"Enabled", None)); self.tabWidget.setTabText(self.tabWidget.indexOf(self.tab_patterns), QCoreApplication.translate("PreferencesDialog", u"Patterns", None)) # retranslateUi + diff --git a/qt_ui/settings.py b/qt_ui/settings.py index bba9e47..6881065 100644 --- a/qt_ui/settings.py +++ b/qt_ui/settings.py @@ -181,6 +181,13 @@ def set(self, value): coyote_max_intensity_change_per_pulse = Setting("coyote/max_intensity_change_per_pulse", 3.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 From fa8e9f363647f2054a4b1ed9a3c27a1e432297f3 Mon Sep 17 00:00:00 2001 From: voltmouse69 Date: Sun, 9 Nov 2025 16:00:43 +0700 Subject: [PATCH 47/47] Coyote: reduce default max_intensity_change_per_pulse to 1% --- qt_ui/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qt_ui/settings.py b/qt_ui/settings.py index 6881065..21ff739 100644 --- a/qt_ui/settings.py +++ b/qt_ui/settings.py @@ -178,7 +178,7 @@ def set(self, value): 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", 3.0, float) +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)