I’ve always had an issue with funscript to estim conversion tools, because the files generated with them never felt “right”. Meaning - more often than not, very slow strokes felt too intense for what it should feel, and fast strokes - not intense enough.
With a bit of trial and error, and with the help of chatgpt, I created a piece of software, that creates an additional funscripts to control volume and frequency in Restim.
This is what it does:
1. It creates .alpha and .beta files (this portion of the code is taken directly from Restim).
2. It creates .volume file based on the current movement speed between two timecodes. After some trial and error, I found out it's best to compare the speed against arbitrary values instead of minimum and maximum speed with each script. Meaning - a "slow" funscript will have significantly lower volume than a "fast" one. This is deliberate.
3. Lastly, it creates .frequency funscript. This part is a little experimental. The idea is, that if the current movement speed exceeds the arbitrary top speed (so the volume is at 100 and can't increase further), the frequency starts to drop. In theory, lower frequencies should feel more intense, further enhancing the sensation scale.
4. The software goes through ALL the funscripts in its current directory (excluding .alpha, .beta, .volume, and .frequency) to create the new files. Batch conversion is something I've always been missing in the "Convert 1d to 2d" function of Restim.
----
MOST IMPORTANTLY:
Feel free to download, share and modify the script, but DON’T ASK ME FOR ANY CHANGES. I don’t know python, I’m not even a programmer and the whole thing has been created by chatgpt. I've only tested it, it seems to work fine.
DOWNLOAD LINK:
If you wanted to change anything and recompile the thing, the python source code is also included in the file:
https://mega.nz/file/DmgiSL5Z#XniBkY9bH ... 82QovrheVE
----
Copy-paste source code if the mega link goes down one day.
Code: Select all
import os
import json
import numpy as np
# Constants for speed ranges (from restim_volume)
VOLUME_MIN_SPEED = 0.04
VOLUME_MAX_SPEED = 1.25
VOLUME_MIN_POS = 60
VOLUME_MAX_POS = 100
FREQUENCY_MIN_SPEED = 1.25
FREQUENCY_MAX_SPEED = 25
FREQUENCY_MIN_POS = 0
FREQUENCY_MAX_POS = 100
NON_MOVEMENT_MAX_DURATION = 1000 # 1 second (ms)
NON_MOVEMENT_MAX_POS = 30
NON_MOVEMENT_MIN_POS = 0
def scale_speed_to_pos(speed, min_speed, max_speed, min_pos, max_pos):
"""
Scale a speed value to a position within the given range.
"""
if speed <= min_speed:
return min_pos
if speed >= max_speed:
return max_pos
# Linear interpolation
return int(min_pos + ((speed - min_speed) / (max_speed - min_speed)) * (max_pos - min_pos))
def handle_non_movement(actions):
"""
Handle non-movement periods and assign appropriate pos values (0–30) based on duration.
"""
non_movement_data = []
for i in range(len(actions) - 1):
if actions[i]['pos'] == actions[i + 1]['pos']: # Non-movement detected
start = actions[i]['at']
end = actions[i + 1]['at']
duration = end - start
if duration >= NON_MOVEMENT_MAX_DURATION:
assigned_pos = NON_MOVEMENT_MIN_POS
else:
assigned_pos = int(NON_MOVEMENT_MAX_POS * (1 - duration / NON_MOVEMENT_MAX_DURATION))
non_movement_data.append({'at': start, 'pos': assigned_pos})
non_movement_data.append({'at': end, 'pos': assigned_pos})
return non_movement_data
def create_scaled_file(actions, min_speed, max_speed, min_pos, max_pos, invert=False, output_path=None, handle_non_movement_flag=False):
"""
Create a scaled output file based on the speed range and output scaling.
Optionally handles non-movement.
"""
scaled_data = []
if handle_non_movement_flag:
# Add non-movement data
scaled_data.extend(handle_non_movement(actions))
for i in range(len(actions) - 1):
time_diff = actions[i + 1]['at'] - actions[i]['at']
pos_diff = abs(actions[i + 1]['pos'] - actions[i]['pos'])
if actions[i]['pos'] != actions[i + 1]['pos']: # Movement detected
speed = pos_diff / time_diff if time_diff > 0 else 0
scaled_pos = scale_speed_to_pos(speed, min_speed, max_speed, min_pos, max_pos)
if invert:
scaled_pos = max_pos - scaled_pos
avg_at = (actions[i]['at'] + actions[i + 1]['at']) // 2
scaled_data.append({'at': avg_at, 'pos': scaled_pos})
scaled_data.sort(key=lambda x: x['at'])
if output_path:
with open(output_path, 'w') as f:
json.dump({'actions': scaled_data}, f, indent=4)
def parse_funscript(path):
"""
Parse a .funscript file and extract time and position values.
"""
x = []
y = []
with open(path, 'r') as f:
js = json.load(f)
for action in js['actions']:
at = float(action['at']) / 1000
pos = float(action['pos']) * 0.01
x.append(at)
y.append(pos)
return x, y
def write_funscript(path, funscript):
"""
Write a .funscript file from time and position values.
"""
x, y = funscript
actions = [{"at": int(at * 1000), "pos": int(pos * 100)} for at, pos in zip(x, y)]
js = {"actions": actions}
with open(path, 'w') as f:
json.dump(js, f, indent=4)
def convert_funscript_radial(funscript):
"""
Convert a 1D .funscript into radial (2D) data.
"""
at, pos = funscript
t_out = []
x_out = []
y_out = []
for i in range(len(pos) - 1):
start_t, end_t = at[i:i + 2]
start_p, end_p = pos[i:i + 2]
points_per_second = 25
n = int(np.clip(float((end_t - start_t) * points_per_second), 1, None))
t = np.linspace(0.0, end_t - start_t, n, endpoint=False)
theta = np.linspace(0, np.pi, n, endpoint=False)
center = (end_p + start_p) / 2
r = (start_p - end_p) / 2
x = center + r * np.cos(theta)
y = r * np.sin(theta) + 0.5
t_out += list(t + start_t)
x_out += list(x)
y_out += list(y)
return t_out, x_out, y_out
def process_funscript(file_path):
"""
Process a single funscript file to create volume, frequency, alpha, and beta outputs.
"""
with open(file_path, 'r', encoding='utf-8') as f:
script_data = json.load(f)
actions = script_data.get('actions', [])
if not actions:
print(f"No 'actions' found in {file_path}. Skipping.")
return
# Generate .volume.funscript
volume_output_path = file_path.replace('.funscript', '.volume.funscript')
create_scaled_file(
actions,
VOLUME_MIN_SPEED,
VOLUME_MAX_SPEED,
VOLUME_MIN_POS,
VOLUME_MAX_POS,
invert=False,
output_path=volume_output_path,
handle_non_movement_flag=True
)
# Generate .frequency.funscript
frequency_output_path = file_path.replace('.funscript', '.frequency.funscript')
create_scaled_file(
actions,
FREQUENCY_MIN_SPEED,
FREQUENCY_MAX_SPEED,
FREQUENCY_MIN_POS,
FREQUENCY_MAX_POS,
invert=True,
output_path=frequency_output_path
)
# Generate .alpha and .beta files
funscript = parse_funscript(file_path)
alpha_output_path = file_path.replace('.funscript', '.alpha.funscript')
beta_output_path = file_path.replace('.funscript', '.beta.funscript')
a, b, c = convert_funscript_radial(funscript)
write_funscript(alpha_output_path, (a, b))
write_funscript(beta_output_path, (a, c))
def batch_process_folder():
"""
Process all .funscript files in the current directory, excluding generated outputs.
"""
current_folder = os.getcwd()
for file_name in os.listdir(current_folder):
if file_name.endswith('.funscript') and not any(
suffix in file_name for suffix in ['.volume.funscript', '.frequency.funscript', '.alpha.funscript', '.beta.funscript']
):
file_path = os.path.join(current_folder, file_name)
print(f"Processing: {file_path}")
process_funscript(file_path)
if __name__ == "__main__":
batch_process_folder()




