From 2159c341930620678d2ed388e36e4b6a36ff9d38 Mon Sep 17 00:00:00 2001 From: lqr Date: Sat, 14 Mar 2026 08:47:43 -0400 Subject: [PATCH 1/2] Add CLI adapter, examples, tests, and docs Adds a stable cli.py adapter layer that wraps the restim processor behind a clean public API (load_file, process, list_outputs, preview_*). Includes bash/Python examples, a full pytest test suite, and CLI reference documentation. The adapter isolates upstream internals so callers never import processor or config classes directly. - cli.py: public API + argparse CLI (info, process, list-outputs, algorithms, config show/save, preview electrode-path/frequency-blend/pulse-shape) - examples/: process_default.sh, process_default.py, sample.funscript, README - tests/: test_cli.py (8 test classes, pytest + unittest), conftest.py - docs/CLI_REFERENCE.md: full command reference with --help text and examples Co-Authored-By: Claude Sonnet 4.6 --- cli.py | 877 ++++++++++++++++++++++++++++++++++++ docs/CLI_REFERENCE.md | 336 ++++++++++++++ examples/README.md | 242 ++++++++++ examples/process_default.py | 117 +++++ examples/process_default.sh | 44 ++ examples/sample.funscript | 25 + tests/conftest.py | 10 + tests/test_cli.py | 281 ++++++++++++ 8 files changed, 1932 insertions(+) create mode 100644 cli.py create mode 100644 docs/CLI_REFERENCE.md create mode 100644 examples/README.md create mode 100644 examples/process_default.py create mode 100644 examples/process_default.sh create mode 100644 examples/sample.funscript create mode 100644 tests/conftest.py create mode 100644 tests/test_cli.py diff --git a/cli.py b/cli.py new file mode 100644 index 0000000..5a8c42c --- /dev/null +++ b/cli.py @@ -0,0 +1,877 @@ +""" +cli.py — Adapter layer for funscript-tools. + +This is the ONLY file that imports from the upstream processing engine. +All other code (UI, tests, API) imports from here only. + +Processing engine by edger477: https://github.com/edger477/funscript-tools +All algorithm credit belongs to edger477 and contributors. + +Usage (command line): + python cli.py process path/to/file.funscript + python cli.py process path/to/file.funscript --output-dir /some/dir + python cli.py info path/to/file.funscript + python cli.py list-outputs path/to/dir stem_name + +Usage (Python): + from cli import load_file, process, get_default_config + from cli import preview_electrode_path, preview_frequency_blend, preview_pulse_shape +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Callable, Optional + +import numpy as np + +# ── Upstream imports (isolated here) ───────────────────────────────────────── +# Everything upstream lives behind this boundary. + +_HERE = Path(__file__).parent +sys.path.insert(0, str(_HERE)) + + +def _upstream_load(path: Path): + """Load a funscript via the upstream Funscript class.""" + from funscript import Funscript # upstream + return Funscript.from_file(path) + + +def _upstream_process(input_path: Path, config: dict, on_progress=None) -> bool: + """Run the upstream RestimProcessor pipeline.""" + from processor import RestimProcessor # upstream + proc = RestimProcessor(config) + return proc.process(str(input_path), on_progress) + + +def _upstream_default_config() -> dict: + """Return upstream default config.""" + from config import DEFAULT_CONFIG # upstream + import copy + return copy.deepcopy(DEFAULT_CONFIG) + + +def _upstream_preview_path(algorithm: str, min_distance: float, + speed_threshold: float, n_points: int): + """Generate a 2D electrode path using a synthetic sine input.""" + from funscript import Funscript # upstream + from processing.speed_processing import convert_to_speed # upstream + from processing.funscript_1d_to_2d import generate_alpha_beta_from_main # upstream + + # Synthetic sinusoidal funscript (2 seconds, representative motion) + t = np.linspace(0, 2.0, n_points) + y = (np.sin(2 * np.pi * 1.5 * t) + 1) / 2 # 0-1 range, 1.5 Hz + synth = Funscript(t.tolist(), y.tolist()) + + speed = convert_to_speed(synth, window_size=5, interpolation_interval=0.05) + + alpha, beta = generate_alpha_beta_from_main( + synth, speed, + points_per_second=25, + algorithm=algorithm, + min_distance_from_center=min_distance, + speed_threshold_percent=speed_threshold, + ) + return alpha.y, beta.y # Both are 0-1 floats + + +# ── Public API ──────────────────────────────────────────────────────────────── + +ALGORITHMS = { + "circular": "Circular (0°–180°)", + "top-right-left": "Top-Right-Bottom-Left (0°–270°)", + "top-left-right": "Top-Left-Bottom-Right (0°–90°)", + "restim-original": "Restim Original (0°–360°)", +} + +ALGORITHM_DESCRIPTIONS = { + "circular": "Smooth semi-circle. Balanced, works well for most content.", + "top-right-left": "Wider arc. More variation, stronger contrast between strokes.", + "top-left-right": "Narrower arc. Subtle, good for slower content.", + "restim-original": "Full circle with random direction changes. Most unpredictable.", +} + + +def load_file(path: str) -> dict: + """ + Load a .funscript file and return metadata + waveform data. + + Returns: + { + name: str, + path: str, + actions: int, + duration_s: float, + duration_fmt: str, # "MM:SS" + pos_min: float, # 0–100 + pos_max: float, # 0–100 + x: list[float], # time in seconds + y: list[float], # position 0–100 + } + Raises: + ValueError if the file cannot be loaded. + """ + p = Path(path) + if not p.exists(): + raise ValueError(f"File not found: {path}") + if p.suffix.lower() != ".funscript": + raise ValueError(f"Expected a .funscript file, got: {p.suffix}") + + try: + fs = _upstream_load(p) + except Exception as e: + raise ValueError(f"Failed to read {p.name}: {e}") from e + + x = [float(v) for v in fs.x] + y = [float(v) * 100 for v in fs.y] # Convert 0-1 → 0-100 + duration = x[-1] if x else 0.0 + m, s = int(duration // 60), int(duration % 60) + + return { + "name": p.name, + "path": str(p), + "actions": len(x), + "duration_s": duration, + "duration_fmt": f"{m:02d}:{s:02d}", + "pos_min": round(min(y), 1) if y else 0.0, + "pos_max": round(max(y), 1) if y else 0.0, + "x": x, + "y": y, + } + + +def get_default_config() -> dict: + """Return the default processing configuration.""" + return _upstream_default_config() + + +def process(path: str, config: dict, on_progress: Optional[Callable] = None) -> dict: + """ + Run the full processing pipeline on a .funscript file. + + on_progress: optional callback(percent: int, message: str) + + Returns: + { + success: bool, + error: str | None, + outputs: list[{ suffix: str, path: str, size_bytes: int }] + } + """ + p = Path(path) + + try: + success = _upstream_process(p, config, on_progress) + except Exception as e: + return {"success": False, "error": str(e), "outputs": []} + + if not success: + return {"success": False, "error": "Processing failed — check logs.", "outputs": []} + + # Determine output directory + custom = config.get("advanced", {}).get("custom_output_directory", "").strip() + out_dir = Path(custom) if custom else p.parent + + outputs = list_outputs(str(out_dir), p.stem) + return {"success": True, "error": None, "outputs": outputs} + + +def list_outputs(directory: str, stem: str) -> list[dict]: + """ + Find all generated output files for a given stem in a directory. + + Returns: + list of { suffix: str, path: str, size_bytes: int } + """ + d = Path(directory) + if not d.exists(): + return [] + + results = [] + for p in sorted(d.glob(f"{stem}.*.funscript")): + suffix = p.name[len(stem) + 1: -len(".funscript")] + results.append({ + "suffix": suffix, + "path": str(p), + "size_bytes": p.stat().st_size, + }) + return results + + +# ── Preview functions (no file I/O, safe to call on every slider move) ──────── + +def preview_electrode_path( + algorithm: str = "circular", + min_distance_from_center: float = 0.1, + speed_threshold_percent: float = 50.0, + points: int = 200, +) -> dict: + """ + Return the 2D electrode path shape for visualization. + + Uses a synthetic sinusoidal input so shape depends only on the algorithm + and parameters — no real funscript needed. + + Returns: + { + alpha: list[float], # x-axis values 0–1 + beta: list[float], # y-axis values 0–1 + label: str, # algorithm display name + description: str, # plain-language description + } + """ + try: + alpha_y, beta_y = _upstream_preview_path( + algorithm, + min_distance_from_center, + speed_threshold_percent, + points, + ) + return { + "alpha": [float(v) for v in alpha_y], + "beta": [float(v) for v in beta_y], + "label": ALGORITHMS.get(algorithm, algorithm), + "description": ALGORITHM_DESCRIPTIONS.get(algorithm, ""), + } + except Exception as e: + # Return a fallback circle so the UI never crashes + t = np.linspace(0, np.pi, points) + return { + "alpha": (0.5 + 0.4 * np.cos(t)).tolist(), + "beta": (0.5 + 0.4 * np.sin(t)).tolist(), + "label": ALGORITHMS.get(algorithm, algorithm), + "description": f"Preview unavailable: {e}", + } + + +def preview_frequency_blend( + frequency_ramp_combine_ratio: float = 2.0, + pulse_frequency_combine_ratio: float = 3.0, +) -> dict: + """ + Return plain-language description of the frequency blend settings. + + The combine ratio R means: (R-1)/R of the first source + 1/R of the second. + + Returns: + { + frequency_ramp_pct: float, # % from ramp (slow build) + frequency_speed_pct: float, # % from speed (action intensity) + pulse_speed_pct: float, # % from speed in pulse freq + pulse_alpha_pct: float, # % from alpha in pulse freq + frequency_label: str, # "70% slow build + 30% scene energy" + pulse_label: str, + overall_label: str, # summary sentence + } + """ + def split(ratio): + r = max(1.0, float(ratio)) + left = round((r - 1) / r * 100, 1) + right = round(100 - left, 1) + return left, right + + ramp_pct, speed_pct = split(frequency_ramp_combine_ratio) + pulse_speed_pct, pulse_alpha_pct = split(pulse_frequency_combine_ratio) + + freq_label = f"{ramp_pct:.0f}% slow build + {speed_pct:.0f}% scene energy" + pulse_label = f"{pulse_speed_pct:.0f}% scene energy + {pulse_alpha_pct:.0f}% spatial position" + + if ramp_pct >= 60: + character = "gradual, builds slowly" + elif speed_pct >= 60: + character = "reactive, follows action closely" + else: + character = "balanced — responsive with a slow build" + + return { + "frequency_ramp_pct": ramp_pct, + "frequency_speed_pct": speed_pct, + "pulse_speed_pct": pulse_speed_pct, + "pulse_alpha_pct": pulse_alpha_pct, + "frequency_label": freq_label, + "pulse_label": pulse_label, + "overall_label": f"Frequency feel: {character}", + } + + +def preview_pulse_shape( + width_min: float = 0.1, + width_max: float = 0.45, + rise_min: float = 0.0, + rise_max: float = 0.80, +) -> dict: + """ + Return a representative pulse silhouette for visualization. + + Generates a trapezoidal pulse at the midpoint of the configured ranges, + showing the relationship between width and rise time. + + Returns: + { + x: list[float], # normalized time 0–1 + y: list[float], # normalized amplitude 0–1 + width: float, # midpoint width used + rise: float, # midpoint rise time used + label: str, # plain-language description + sharpness: str, # "sharp" / "medium" / "soft" + } + """ + width = (width_min + width_max) / 2 + rise = (rise_min + rise_max) / 2 + + # Build trapezoidal pulse: rise → hold → fall + # Normalized to 0-1 time window + rise_frac = min(rise, width / 2) # Can't rise longer than half the pulse + hold_frac = width - 2 * rise_frac + fall_frac = rise_frac + gap_frac = 1.0 - width + + x = [0.0, + rise_frac, + rise_frac + hold_frac, + rise_frac + hold_frac + fall_frac, + rise_frac + hold_frac + fall_frac + gap_frac] + y = [0.0, 1.0, 1.0, 0.0, 0.0] + + if rise < 0.1: + sharpness = "sharp — immediate onset" + elif rise < 0.4: + sharpness = "medium — smooth ramp" + else: + sharpness = "soft — gentle build" + + width_desc = "narrow" if width < 0.2 else ("wide" if width > 0.35 else "medium") + label = f"Pulse: {width_desc} width, {sharpness}" + + return { + "x": x, + "y": y, + "width": round(width, 3), + "rise": round(rise, 3), + "label": label, + "sharpness": sharpness.split(" — ")[0], + } + + +def preview_output( + source: dict, + config: dict, + output_type: str = "alpha", +) -> dict: + """ + Run a lightweight partial pipeline to preview one output type. + + Faster than full process() — skips file I/O, returns array data only. + Best effort: falls back gracefully if the output type isn't computable. + + Returns: + { + original_x: list[float], + original_y: list[float], # 0–100 + output_x: list[float], + output_y: list[float], # 0–100 + label: str, + available: bool, + } + """ + base = { + "original_x": source.get("x", []), + "original_y": source.get("y", []), + "output_x": [], + "output_y": [], + "label": output_type, + "available": False, + } + + try: + from funscript import Funscript # upstream + from processing.speed_processing import convert_to_speed # upstream + + x = np.array(source["x"]) + y = np.array(source["y"]) / 100.0 # back to 0-1 + main_fs = Funscript(x.tolist(), y.tolist()) + + ab_cfg = config.get("alpha_beta_generation", {}) + speed_fs = convert_to_speed( + main_fs, + config["general"]["speed_window_size"], + config["speed"]["interpolation_interval"], + ) + + if output_type in ("alpha", "beta"): + from processing.funscript_1d_to_2d import generate_alpha_beta_from_main # upstream + alpha, beta = generate_alpha_beta_from_main( + main_fs, speed_fs, + points_per_second=ab_cfg.get("points_per_second", 25), + algorithm=ab_cfg.get("algorithm", "circular"), + min_distance_from_center=ab_cfg.get("min_distance_from_center", 0.1), + speed_threshold_percent=ab_cfg.get("speed_threshold_percent", 50), + ) + out = alpha if output_type == "alpha" else beta + + elif output_type == "speed": + out = speed_fs + + elif output_type == "frequency": + from processing.combining import combine_funscripts # upstream + from processing.special_generators import make_volume_ramp # upstream + ramp = make_volume_ramp(main_fs, config.get("volume", {}).get("ramp_percent_per_hour", 15)) + out = combine_funscripts(ramp, speed_fs, config["frequency"]["frequency_ramp_combine_ratio"]) + + elif output_type == "volume": + from processing.combining import combine_funscripts # upstream + from processing.special_generators import make_volume_ramp # upstream + ramp = make_volume_ramp(main_fs, config.get("volume", {}).get("ramp_percent_per_hour", 15)) + out = combine_funscripts( + ramp, speed_fs, + config["volume"]["volume_ramp_combine_ratio"], + config["general"]["rest_level"], + config["general"]["ramp_up_duration_after_rest"], + ) + + else: + base["label"] = f"{output_type} (preview not available — run Process)" + return base + + base["output_x"] = [float(v) for v in out.x] + base["output_y"] = [float(v) * 100 for v in out.y] + base["available"] = True + base["label"] = output_type + + except Exception as e: + base["label"] = f"{output_type} (preview error: {e})" + + return base + + +# ── Command-line interface ───────────────────────────────────────────────────── + +def _cmd_algorithms(args): + for key, name in ALGORITHMS.items(): + desc = ALGORITHM_DESCRIPTIONS[key] + print(f" {key:<20} {name}") + print(f" {'':20} {desc}") + print() + + +def _cmd_config_show(args): + cfg = get_default_config() + if args.section: + if args.section not in cfg: + print(f"Error: unknown section '{args.section}'. " + f"Available: {', '.join(cfg.keys())}", file=sys.stderr) + sys.exit(1) + print(json.dumps(cfg[args.section], indent=2)) + else: + print(json.dumps(cfg, indent=2)) + + +def _cmd_config_save(args): + cfg = get_default_config() + out = Path(args.output) + if out.exists() and not args.force: + print(f"Error: {out} already exists. Use --force to overwrite.", file=sys.stderr) + sys.exit(1) + with open(out, "w") as f: + json.dump(cfg, f, indent=2) + print(f"Default config saved to: {out}") + print(f"Edit it, then use: python cli.py process --config {out}") + + +def _cmd_preview_electrode(args): + result = preview_electrode_path( + algorithm=args.algorithm, + min_distance_from_center=args.min_distance, + speed_threshold_percent=args.speed_threshold, + points=args.points, + ) + if args.json: + print(json.dumps(result, indent=2)) + else: + print(f"Algorithm: {result['label']}") + print(f"Description: {result['description']}") + print(f"Points: {len(result['alpha'])} alpha, {len(result['beta'])} beta") + print(f"Alpha range: {min(result['alpha']):.3f} – {max(result['alpha']):.3f}") + print(f"Beta range: {min(result['beta']):.3f} – {max(result['beta']):.3f}") + + +def _cmd_preview_frequency(args): + result = preview_frequency_blend( + frequency_ramp_combine_ratio=args.ramp_ratio, + pulse_frequency_combine_ratio=args.pulse_ratio, + ) + if args.json: + print(json.dumps(result, indent=2)) + else: + print(f" {result['overall_label']}") + print(f" Frequency: {result['frequency_label']}") + print(f" Pulse: {result['pulse_label']}") + + +def _cmd_preview_pulse(args): + result = preview_pulse_shape( + width_min=args.width_min, + width_max=args.width_max, + rise_min=args.rise_min, + rise_max=args.rise_max, + ) + if args.json: + print(json.dumps(result, indent=2)) + else: + print(f" {result['label']}") + print(f" Width (mid): {result['width']}") + print(f" Rise (mid): {result['rise']}") + print(f" Sharpness: {result['sharpness']}") + + +def _cmd_info(args): + try: + info = load_file(args.file) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + print(f"File: {info['name']}") + print(f"Actions: {info['actions']}") + print(f"Duration: {info['duration_fmt']}") + print(f"Range: {info['pos_min']:.0f} – {info['pos_max']:.0f}") + + +def _cmd_process(args): + config = get_default_config() + + if args.config: + with open(args.config) as f: + overrides = json.load(f) + _deep_merge(config, overrides) + + if args.output_dir: + config.setdefault("advanced", {})["custom_output_directory"] = args.output_dir + + def _progress(pct, msg): + bar = "█" * (pct // 5) + "░" * (20 - pct // 5) + print(f"\r[{bar}] {pct:3d}% {msg:<50}", end="", flush=True) + + print(f"Processing: {args.file}") + result = process(args.file, config, _progress) + print() # newline after progress bar + + if not result["success"]: + print(f"Error: {result['error']}", file=sys.stderr) + sys.exit(1) + + print(f"\nGenerated {len(result['outputs'])} file(s):") + for out in result["outputs"]: + size_kb = out["size_bytes"] / 1024 + print(f" {out['suffix']:<30} {size_kb:.1f} KB → {out['path']}") + + +def _cmd_list_outputs(args): + outputs = list_outputs(args.directory, args.stem) + if not outputs: + print(f"No outputs found for '{args.stem}' in {args.directory}") + return + for out in outputs: + size_kb = out["size_bytes"] / 1024 + print(f" {out['suffix']:<30} {size_kb:.1f} KB") + + +def _deep_merge(base: dict, override: dict): + for k, v in override.items(): + if isinstance(v, dict) and isinstance(base.get(k), dict): + _deep_merge(base[k], v) + else: + base[k] = v + + +def main(): + parser = argparse.ArgumentParser( + prog="cli.py", + description=( + "funscript-tools CLI — convert .funscript files into restim-ready output sets.\n\n" + "Processing engine by edger477: https://github.com/edger477/funscript-tools\n\n" + "Quick start:\n" + " python cli.py process my_scene.funscript\n\n" + "Tune settings:\n" + " python cli.py config save my_config.json\n" + " # edit my_config.json, then:\n" + " python cli.py process my_scene.funscript --config my_config.json\n\n" + "Explore without processing:\n" + " python cli.py algorithms\n" + " python cli.py preview electrode-path --algorithm circular\n" + " python cli.py preview frequency-blend --ramp-ratio 4\n" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=( + "For full documentation see DESIGN.md or examples/README.md\n" + "Run any subcommand with --help for details: python cli.py process --help" + ), + ) + sub = parser.add_subparsers(dest="command", required=True, metavar="command") + + # ── info ───────────────────────────────────────────────────────────────── + p_info = sub.add_parser( + "info", + help="Show metadata about a .funscript file", + description="Load a .funscript file and display its metadata — action count, duration, and position range.", + epilog="Example:\n python cli.py info my_scene.funscript", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p_info.add_argument("file", help="Path to the .funscript file") + + # ── process ────────────────────────────────────────────────────────────── + p_proc = sub.add_parser( + "process", + help="Run the full processing pipeline on a .funscript file", + description=( + "Process a .funscript file through the full restim pipeline, generating all\n" + "output files (alpha, beta, frequency, volume, pulse_width, etc.).\n\n" + "Outputs are written next to the input file by default.\n" + "Use --config to load a saved config (see: python cli.py config save).\n" + "Use --output-dir to redirect outputs to a different folder." + ), + epilog=( + "Examples:\n" + " python cli.py process my_scene.funscript\n" + " python cli.py process my_scene.funscript --output-dir ~/restim/\n" + " python cli.py process my_scene.funscript --config my_config.json\n" + " python cli.py process my_scene.funscript --config my_config.json --output-dir ~/restim/" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p_proc.add_argument("file", help="Path to the .funscript file to process") + p_proc.add_argument( + "--config", + metavar="FILE", + help="JSON config file to use instead of defaults (see: python cli.py config save)" + ) + p_proc.add_argument( + "--output-dir", + metavar="DIR", + help="Directory for output files (default: same folder as input)" + ) + + # ── list-outputs ───────────────────────────────────────────────────────── + p_list = sub.add_parser( + "list-outputs", + help="List generated output files for a given input stem", + description=( + "Find and list all generated .funscript output files for a given input\n" + "filename stem (the filename without .funscript extension)." + ), + epilog=( + "Examples:\n" + " python cli.py list-outputs . my_scene\n" + " python cli.py list-outputs ~/restim/ my_scene" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p_list.add_argument("directory", help="Directory to search for output files") + p_list.add_argument("stem", help="Input filename without extension (e.g. my_scene)") + + # ── algorithms ─────────────────────────────────────────────────────────── + sub.add_parser( + "algorithms", + help="List available 2D electrode path algorithms with descriptions", + description=( + "List all available algorithms for converting 1D funscript motion\n" + "into a 2D electrode path, with plain-language descriptions.\n\n" + "Use the algorithm key with: python cli.py preview electrode-path --algorithm \n" + "Or set it in your config: python cli.py config save my_config.json" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + + # ── config ─────────────────────────────────────────────────────────────── + p_cfg = sub.add_parser( + "config", + help="Show or save the default processing configuration", + description=( + "Inspect or export the default configuration.\n\n" + "Workflow:\n" + " 1. Save defaults to a file: python cli.py config save my_config.json\n" + " 2. Edit my_config.json in any text editor\n" + " 3. Process with your config: python cli.py process file.funscript --config my_config.json" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + cfg_sub = p_cfg.add_subparsers(dest="config_command", required=True, metavar="subcommand") + + p_cfg_show = cfg_sub.add_parser( + "show", + help="Print the default configuration as JSON", + description="Print the full default config, or a single section, as formatted JSON.", + epilog=( + "Examples:\n" + " python cli.py config show\n" + " python cli.py config show frequency\n" + " python cli.py config show pulse" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p_cfg_show.add_argument( + "section", nargs="?", + help="Section to show: general, frequency, pulse, volume, alpha_beta_generation, " + "prostate_generation, advanced, options, positional_axes, speed. Omit for full config." + ) + + p_cfg_save = cfg_sub.add_parser( + "save", + help="Save the default configuration to a JSON file for editing", + description=( + "Write the full default configuration to a JSON file.\n" + "Edit the file to tune parameters, then use it with --config." + ), + epilog=( + "Examples:\n" + " python cli.py config save my_config.json\n" + " python cli.py config save configs/gentle.json\n" + " python cli.py config save my_config.json --force # overwrite existing" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p_cfg_save.add_argument("output", help="Output file path (e.g. my_config.json)") + p_cfg_save.add_argument("--force", action="store_true", help="Overwrite if file already exists") + + # ── preview ────────────────────────────────────────────────────────────── + p_prev = sub.add_parser( + "preview", + help="Preview parameter effects without running the full pipeline", + description=( + "Fast parameter previews — no file I/O, no output files written.\n" + "Use these to understand what a setting does before committing to a full process run.\n" + "Add --json to pipe results to other tools." + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + prev_sub = p_prev.add_subparsers(dest="preview_command", required=True, metavar="subcommand") + + p_elec = prev_sub.add_parser( + "electrode-path", + help="Show the 2D electrode path shape for an algorithm", + description=( + "Generate the 2D electrode path shape that a given algorithm produces.\n" + "Uses a synthetic sine-wave input so the shape reflects the algorithm only.\n\n" + "This is what the UI plots when you change the Algorithm dropdown.\n" + "Run 'python cli.py algorithms' to see available algorithm keys." + ), + epilog=( + "Examples:\n" + " python cli.py preview electrode-path\n" + " python cli.py preview electrode-path --algorithm circular\n" + " python cli.py preview electrode-path --algorithm top-right-left --min-distance 0.3\n" + " python cli.py preview electrode-path --json | python -m json.tool" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p_elec.add_argument( + "--algorithm", default="circular", + choices=list(ALGORITHMS.keys()), + help="Algorithm to preview (default: circular)" + ) + p_elec.add_argument( + "--min-distance", type=float, default=0.1, + metavar="0.0-0.9", + help="Min distance from center (default: 0.1)" + ) + p_elec.add_argument( + "--speed-threshold", type=float, default=50.0, + metavar="0-100", + help="Speed threshold percent (default: 50)" + ) + p_elec.add_argument( + "--points", type=int, default=200, + help="Number of preview points (default: 200)" + ) + p_elec.add_argument( + "--json", action="store_true", + help="Output raw JSON (for piping to other tools)" + ) + + p_freq = prev_sub.add_parser( + "frequency-blend", + help="Show plain-language description of frequency blend settings", + description=( + "Translate the frequency combine ratios into plain English.\n" + "Shows what percentage of the output comes from the slow ramp vs scene energy,\n" + "and gives an overall character description.\n\n" + "The ramp ratio controls the primary frequency envelope.\n" + "The pulse ratio controls how pulse frequency tracks action intensity." + ), + epilog=( + "Examples:\n" + " python cli.py preview frequency-blend\n" + " python cli.py preview frequency-blend --ramp-ratio 4\n" + " python cli.py preview frequency-blend --ramp-ratio 1 --pulse-ratio 1\n" + " python cli.py preview frequency-blend --json" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p_freq.add_argument( + "--ramp-ratio", type=float, default=2.0, + metavar="1-10", + help="Frequency ramp combine ratio (default: 2.0)" + ) + p_freq.add_argument( + "--pulse-ratio", type=float, default=3.0, + metavar="1-10", + help="Pulse frequency combine ratio (default: 3.0)" + ) + p_freq.add_argument("--json", action="store_true", help="Output raw JSON") + + p_pulse = prev_sub.add_parser( + "pulse-shape", + help="Show pulse silhouette for given width and rise time settings", + description=( + "Describe the shape of a representative pulse given width and rise time settings.\n\n" + "Width controls how long each pulse lasts.\n" + "Rise time controls how quickly it ramps up — low values are sharp, high values are soft.\n\n" + "Both are expressed as 0.0–1.0 fractions. The preview uses the midpoint of each range." + ), + epilog=( + "Examples:\n" + " python cli.py preview pulse-shape\n" + " python cli.py preview pulse-shape --width-min 0.1 --width-max 0.5\n" + " python cli.py preview pulse-shape --rise-min 0.0 --rise-max 0.1 # sharp\n" + " python cli.py preview pulse-shape --rise-min 0.5 --rise-max 0.9 # soft\n" + " python cli.py preview pulse-shape --json" + ), + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p_pulse.add_argument("--width-min", type=float, default=0.1, metavar="0.0-1.0") + p_pulse.add_argument("--width-max", type=float, default=0.45, metavar="0.0-1.0") + p_pulse.add_argument("--rise-min", type=float, default=0.0, metavar="0.0-1.0") + p_pulse.add_argument("--rise-max", type=float, default=0.80, metavar="0.0-1.0") + p_pulse.add_argument("--json", action="store_true", help="Output raw JSON") + + # ── dispatch ───────────────────────────────────────────────────────────── + args = parser.parse_args() + + if args.command == "info": + _cmd_info(args) + elif args.command == "process": + _cmd_process(args) + elif args.command == "list-outputs": + _cmd_list_outputs(args) + elif args.command == "algorithms": + _cmd_algorithms(args) + elif args.command == "config": + if args.config_command == "show": + _cmd_config_show(args) + elif args.config_command == "save": + _cmd_config_save(args) + elif args.command == "preview": + if args.preview_command == "electrode-path": + _cmd_preview_electrode(args) + elif args.preview_command == "frequency-blend": + _cmd_preview_frequency(args) + elif args.preview_command == "pulse-shape": + _cmd_preview_pulse(args) + + +if __name__ == "__main__": + main() diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md new file mode 100644 index 0000000..4cd4382 --- /dev/null +++ b/docs/CLI_REFERENCE.md @@ -0,0 +1,336 @@ +# CLI Reference + +All commands go through `cli.py` — the adapter layer over edger477's processing engine. + +> Engine: https://github.com/edger477/funscript-tools — all algorithm credit to edger477. + +``` +python cli.py [options] +python cli.py --help # detailed help for any command +``` + +--- + +## Commands at a glance + +| Command | What it does | +|---------|-------------| +| `info` | Show metadata about a .funscript file | +| `process` | Run the full pipeline — generates all restim output files | +| `list-outputs` | List generated output files for a given input | +| `algorithms` | List 2D conversion algorithms with descriptions | +| `config show` | Print the default configuration as JSON | +| `config save` | Save default config to a file for editing | +| `preview electrode-path` | Show 2D electrode path shape for an algorithm | +| `preview frequency-blend` | Plain-language description of frequency blend settings | +| `preview pulse-shape` | Pulse silhouette for given width and rise time settings | + +--- + +## `info` + +Show metadata about a .funscript file without processing it. + +```bash +python cli.py info +``` + +```bash +# Example +python cli.py info examples/sample.funscript + +# Output +File: sample.funscript +Actions: 18 +Duration: 00:10 +Range: 0 – 100 +``` + +--- + +## `process` + +Run the full processing pipeline. Generates all restim output files. + +```bash +python cli.py process [--config FILE] [--output-dir DIR] +``` + +**Arguments:** + +| Argument | Description | +|----------|-------------| +| `file` | Path to the .funscript file to process | +| `--config FILE` | JSON config file (from `config save`). Uses defaults if omitted. | +| `--output-dir DIR` | Where to write output files. Default: same folder as input. | + +```bash +# Default settings, outputs next to input +python cli.py process my_scene.funscript + +# Custom output directory +python cli.py process my_scene.funscript --output-dir ~/restim/outputs/ + +# Saved config (see config save below) +python cli.py process my_scene.funscript --config my_config.json + +# Both +python cli.py process my_scene.funscript --config my_config.json --output-dir ~/restim/ + +# Batch — process every file in a folder +for f in ~/scenes/*.funscript; do + python cli.py process "$f" --output-dir ~/restim/ +done +``` + +**Output files generated:** + +| File | What it controls in restim | +|------|---------------------------| +| `*.alpha.funscript` | 2D electrode X position | +| `*.beta.funscript` | 2D electrode Y position | +| `*.alpha-prostate.funscript` | Prostate electrode X | +| `*.beta-prostate.funscript` | Prostate electrode Y | +| `*.frequency.funscript` | Pulse rate envelope | +| `*.pulse_frequency.funscript` | Pulse rate by action intensity | +| `*.volume.funscript` | Amplitude envelope | +| `*.volume-prostate.funscript` | Prostate amplitude | +| `*.pulse_width.funscript` | Pulse width | +| `*.pulse_rise_time.funscript` | Pulse sharpness | + +--- + +## `list-outputs` + +List generated output files for a given input stem. + +```bash +python cli.py list-outputs +``` + +```bash +# Example +python cli.py list-outputs . my_scene + +# Output + alpha 2.4 KB + beta 2.4 KB + frequency 1.2 KB + ... +``` + +--- + +## `algorithms` + +List all available 2D electrode path algorithms with plain-language descriptions. + +```bash +python cli.py algorithms + +# Output + circular Circular (0°–180°) + Smooth semi-circle. Balanced, works well for most content. + + top-right-left Top-Right-Bottom-Left (0°–270°) + Wider arc. More variation, stronger contrast between strokes. + + top-left-right Top-Left-Bottom-Right (0°–90°) + Narrower arc. Subtle, good for slower content. + + restim-original Restim Original (0°–360°) + Full circle with random direction changes. Most unpredictable. +``` + +Set your preferred algorithm in a saved config: + +```bash +python cli.py config save my_config.json +# Edit my_config.json → alpha_beta_generation.algorithm → "circular" +python cli.py process scene.funscript --config my_config.json +``` + +--- + +## `config show` + +Print the default configuration (or a single section) as formatted JSON. + +```bash +python cli.py config show [section] +``` + +```bash +# Full config +python cli.py config show + +# Just the frequency settings +python cli.py config show frequency + +# Just the pulse settings +python cli.py config show pulse +``` + +Available sections: `general`, `frequency`, `pulse`, `volume`, +`alpha_beta_generation`, `prostate_generation`, `advanced`, +`options`, `positional_axes`, `speed` + +--- + +## `config save` + +Save the default configuration to a JSON file so you can edit and reuse it. + +```bash +python cli.py config save [--force] +``` + +```bash +# Save defaults +python cli.py config save my_config.json + +# Overwrite existing file +python cli.py config save my_config.json --force + +# Typical workflow +python cli.py config save configs/gentle.json +# → edit gentle.json: lower frequency ratios, softer pulse rise +python cli.py process scene.funscript --config configs/gentle.json +``` + +**Key settings to tune:** + +| JSON path | What it changes | +|-----------|----------------| +| `alpha_beta_generation.algorithm` | 2D path algorithm (`circular`, `top-right-left`, etc.) | +| `alpha_beta_generation.min_distance_from_center` | How wide the electrode motion range is (0.1–0.9) | +| `frequency.frequency_ramp_combine_ratio` | Slow build vs scene energy blend (1–10) | +| `frequency.pulse_freq_min` / `pulse_freq_max` | Pulse rate output range (0.0–1.0) | +| `pulse.pulse_width_min` / `pulse_width_max` | Pulse width range | +| `pulse.pulse_rise_min` / `pulse_rise_max` | Pulse sharpness — low = sharp, high = soft | +| `volume.volume_ramp_combine_ratio` | Volume blend ratio | +| `options.overwrite_existing_files` | Whether to regenerate existing outputs | + +--- + +## `preview electrode-path` + +Show the 2D electrode path shape an algorithm produces — no processing required. + +```bash +python cli.py preview electrode-path [--algorithm ALGO] [--min-distance N] + [--speed-threshold N] [--points N] [--json] +``` + +```bash +# Default algorithm (circular) +python cli.py preview electrode-path + +# Output +Algorithm: Circular (0°–180°) +Description: Smooth semi-circle. Balanced, works well for most content. +Points: 200 alpha, 200 beta +Alpha range: 0.100 – 0.900 +Beta range: 0.500 – 0.900 + +# Compare algorithms +python cli.py preview electrode-path --algorithm top-right-left +python cli.py preview electrode-path --algorithm restim-original + +# Wider motion range +python cli.py preview electrode-path --algorithm circular --min-distance 0.3 + +# Machine-readable output (pipe to plotting tools) +python cli.py preview electrode-path --json +``` + +--- + +## `preview frequency-blend` + +Translate frequency combine ratios into plain English. + +```bash +python cli.py preview frequency-blend [--ramp-ratio N] [--pulse-ratio N] [--json] +``` + +```bash +# Default ratios +python cli.py preview frequency-blend + +# Output + Frequency feel: balanced — responsive with a slow build + Frequency: 50.0% slow build + 50.0% scene energy + Pulse: 66.7% scene energy + 33.3% spatial position + +# Ramp-heavy (slow, gradual build) +python cli.py preview frequency-blend --ramp-ratio 8 + +# Output + Frequency feel: gradual, builds slowly + Frequency: 87.5% slow build + 12.5% scene energy + +# Speed-heavy (reactive to action) +python cli.py preview frequency-blend --ramp-ratio 1 + +# Output + Frequency feel: reactive, follows action closely + Frequency: 0.0% slow build + 100.0% scene energy +``` + +--- + +## `preview pulse-shape` + +Describe the pulse silhouette for given width and rise time settings. + +```bash +python cli.py preview pulse-shape [--width-min N] [--width-max N] + [--rise-min N] [--rise-max N] [--json] +``` + +```bash +# Default settings +python cli.py preview pulse-shape + +# Output + Pulse: medium width, medium — smooth ramp + Width (mid): 0.275 + Rise (mid): 0.4 + Sharpness: medium + +# Sharp, narrow pulses +python cli.py preview pulse-shape --width-min 0.05 --width-max 0.2 --rise-min 0.0 --rise-max 0.05 + +# Output + Pulse: narrow width, sharp — immediate onset + +# Soft, wide pulses +python cli.py preview pulse-shape --width-min 0.3 --width-max 0.6 --rise-min 0.5 --rise-max 0.9 + +# Output + Pulse: wide width, soft — gentle build +``` + +--- + +## Using `--json` for scripting + +All `preview` commands and `config show` output valid JSON with `--json`, +making them pipeable to other tools: + +```bash +# Pipe to Python for further processing +python cli.py preview electrode-path --json | python -c " +import json, sys +data = json.load(sys.stdin) +print(f'Path covers {len(data[\"alpha\"])} points') +" + +# Pipe to jq +python cli.py config show frequency --json | jq '.pulse_freq_min' + +# Save preview data for plotting +python cli.py preview electrode-path --algorithm circular --json > path_circular.json +python cli.py preview electrode-path --algorithm top-right-left --json > path_trl.json +``` diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..a78c242 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,242 @@ +# Examples + +These examples show the two ways to drive funscript-tools: + +- **Shell script** — drop into a build pipeline, file watcher, or batch job +- **Python script** — import `cli.py` directly for scripted or automated workflows + +Both use the same default settings and produce the same outputs. +They are also the foundation of the test suite — `tests/test_cli.py` runs +the same scenarios with assertions instead of printed output. + +> Processing engine by edger477: https://github.com/edger477/funscript-tools +> All algorithm credit belongs to edger477 and contributors. + +--- + +## Why you might want these + +| Scenario | Use | +|----------|-----| +| You have a config you like and just want to process new files quickly | bash script in a loop or file watcher | +| You're building a pipeline that generates restim-ready files as part of a larger workflow | Python import — call `cli.process()` programmatically | +| You want to understand what cli.py exposes before building a UI | Python script — it demos every public function | +| You want to verify your install is working | Run either script against `sample.funscript` | + +--- + +## Prerequisites + +```bash +# From the repo root +pip install -r requirements.txt +``` + +Python 3.10+ required. No other setup needed. + +--- + +## The sample file + +`sample.funscript` is a minimal 10-second funscript with realistic up/down +motion. It's used by both examples and by the test suite. You can replace it +with any `.funscript` file from your own library. + +**What's in it:** 18 actions over 10 seconds, position range 0–100. + +--- + +## process_default.sh + +Processes a funscript file using all default settings. +Outputs are written next to the input file. + +### What it does + +1. Shows file info (name, action count, duration, position range) +2. Runs the full processing pipeline with default config +3. Lists all generated output files with sizes + +### Run it + +```bash +# Against the included sample +bash examples/process_default.sh + +# Against your own file +bash examples/process_default.sh path/to/your/file.funscript + +# With a custom output directory +bash examples/process_default.sh path/to/file.funscript --output-dir ~/restim/outputs/ +``` + +### Expected output + +``` +======================================== + funscript-tools — default processing + Engine by edger477 +======================================== + +[ Info ] + File: sample.funscript + Actions: 18 + Duration: 00:10 + Range: 0 – 100 + +[ Processing ] + [████████████████████] 100% Processing complete! + +[ Outputs ] + alpha 2.4 KB + alpha-prostate 1.8 KB + beta 2.4 KB + beta-prostate 1.8 KB + frequency 1.2 KB + pulse_frequency 1.3 KB + pulse_rise_time 1.1 KB + pulse_width 1.2 KB + volume 1.1 KB + volume-prostate 1.0 KB + +Done. +``` + +File sizes will vary with input length and config. + +--- + +## process_default.py + +The Python equivalent — and a tour of every public `cli.py` function. + +In addition to processing the file, it also calls the **preview functions**: +fast computations that return visualization data without writing any files. +These are the same calls the UI makes to update plots on every slider move. + +### What it does + +1. `cli.load_file()` — loads the funscript, returns metadata + waveform arrays +2. `cli.get_default_config()` — shows the key creative settings +3. `cli.preview_electrode_path()` — 2D path shape data (no file I/O) +4. `cli.preview_frequency_blend()` — plain-language blend description +5. `cli.preview_pulse_shape()` — pulse silhouette data +6. `cli.process()` — runs the full pipeline, returns output file list +7. Shows all generated outputs with sizes + +### Run it + +```bash +# Against the included sample +python examples/process_default.py + +# Against your own file +python examples/process_default.py path/to/your/file.funscript + +# With a custom output directory +python examples/process_default.py path/to/file.funscript --output-dir ~/restim/outputs/ +``` + +### Expected output + +``` +================================================== + funscript-tools — default processing + Engine by edger477 +================================================== + +[ File Info ] + Name: sample.funscript + Actions: 18 + Duration: 00:10 + Range: 0 – 100 + +[ Default Config (key creative settings) ] + Algorithm: top-right-left + Min dist. center: 0.1 + Freq ramp blend: 2 + Pulse freq range: 0.4 – 0.95 + Pulse width range: 0.1 – 0.45 + +[ Previews ] + Electrode path: Top-Right-Bottom-Left (0°–270°) + Wider arc. More variation, stronger contrast between strokes. + 200 points generated + Frequency blend: Frequency feel: gradual, builds slowly + 50.0% slow build + 50.0% scene energy + Pulse shape: Pulse: medium width, medium — smooth ramp + +[ Processing ] + [████████████████████] 100% Processing complete! + +[ Outputs — 10 files ] + alpha 2.4 KB + alpha-prostate 1.8 KB + beta 2.4 KB + beta-prostate 1.8 KB + frequency 1.2 KB + pulse_frequency 1.3 KB + pulse_rise_time 1.1 KB + pulse_width 1.2 KB + volume 1.1 KB + volume-prostate 1.0 KB + +Done. +``` + +--- + +## Output files — what they are + +All outputs are `.funscript` files that restim reads to control estim parameters: + +| File | What it controls | +|------|-----------------| +| `alpha`, `beta` | 2D electrode position — *where* on the pad the stimulus sits | +| `alpha-prostate`, `beta-prostate` | Same for prostate electrode geometry | +| `frequency` | Pulse rate envelope — how fast pulses fire over time | +| `pulse_frequency` | Pulse rate modulated by action intensity | +| `volume` | Amplitude envelope — how strong the signal is | +| `volume-prostate` | Amplitude for the prostate channel | +| `pulse_width` | How wide each individual pulse is | +| `pulse_rise_time` | How fast each pulse ramps up — sharpness vs softness | + +--- + +## Using as a batch pipeline + +```bash +# Process every .funscript in a directory +for f in ~/scenes/*.funscript; do + bash examples/process_default.sh "$f" --output-dir ~/restim/ +done +``` + +```python +# Python batch processing +import cli + +config = cli.get_default_config() +# Tune config once here if you want non-default settings + +for path in Path("~/scenes").expanduser().glob("*.funscript"): + result = cli.process(str(path), config) + if result["success"]: + print(f"{path.name}: {len(result['outputs'])} files generated") + else: + print(f"{path.name}: FAILED — {result['error']}") +``` + +--- + +## Troubleshooting + +**`ModuleNotFoundError: No module named 'numpy'`** +Run `pip install -r requirements.txt` from the repo root. + +**`FileNotFoundError` or empty outputs** +Check that your input file ends in `.funscript` and is valid JSON. +Run `python cli.py info your_file.funscript` to verify it loads. + +**Outputs written to the wrong place** +By default, outputs go next to the input file. Use `--output-dir` to redirect. diff --git a/examples/process_default.py b/examples/process_default.py new file mode 100644 index 0000000..06b97aa --- /dev/null +++ b/examples/process_default.py @@ -0,0 +1,117 @@ +""" +examples/process_default.py + +Simplest possible usage: process a funscript with all defaults. +Also demonstrates the preview functions — the same calls the UI uses +to show visualizations without running the full pipeline. + +Usage: + python examples/process_default.py + python examples/process_default.py path/to/your/file.funscript + python examples/process_default.py path/to/file.funscript --output-dir /some/dir +""" + +import argparse +import sys +from pathlib import Path + +# cli.py is the only import needed — it wraps all upstream internals. +sys.path.insert(0, str(Path(__file__).parent.parent)) +import cli + + +def main(): + parser = argparse.ArgumentParser(description="Process a funscript with default settings") + parser.add_argument( + "file", + nargs="?", + default=str(Path(__file__).parent / "sample.funscript"), + help="Path to .funscript file (default: examples/sample.funscript)", + ) + parser.add_argument("--output-dir", help="Output directory (default: same as input)") + args = parser.parse_args() + + print("=" * 50) + print(" funscript-tools — default processing") + print(" Engine by edger477") + print("=" * 50) + + # ── Step 1: load and inspect the file ──────────────────────────────────── + print("\n[ File Info ]") + try: + info = cli.load_file(args.file) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + print(f" Name: {info['name']}") + print(f" Actions: {info['actions']}") + print(f" Duration: {info['duration_fmt']}") + print(f" Range: {info['pos_min']:.0f} – {info['pos_max']:.0f}") + + # ── Step 2: inspect default config ─────────────────────────────────────── + print("\n[ Default Config (key creative settings) ]") + config = cli.get_default_config() + ab = config["alpha_beta_generation"] + fq = config["frequency"] + pu = config["pulse"] + print(f" Algorithm: {ab['algorithm']}") + print(f" Min dist. center: {ab['min_distance_from_center']}") + print(f" Freq ramp blend: {fq['frequency_ramp_combine_ratio']}") + print(f" Pulse freq range: {fq['pulse_freq_min']} – {fq['pulse_freq_max']}") + print(f" Pulse width range: {pu['pulse_width_min']} – {pu['pulse_width_max']}") + + # ── Step 3: preview without processing (UI uses these for live viz) ─────── + print("\n[ Previews ]") + + path_data = cli.preview_electrode_path( + algorithm=ab["algorithm"], + min_distance_from_center=ab["min_distance_from_center"], + ) + print(f" Electrode path: {path_data['label']}") + print(f" {path_data['description']}") + print(f" {len(path_data['alpha'])} points generated") + + blend = cli.preview_frequency_blend( + frequency_ramp_combine_ratio=fq["frequency_ramp_combine_ratio"], + pulse_frequency_combine_ratio=fq["pulse_frequency_combine_ratio"], + ) + print(f" Frequency blend: {blend['overall_label']}") + print(f" {blend['frequency_label']}") + + pulse = cli.preview_pulse_shape( + width_min=pu["pulse_width_min"], + width_max=pu["pulse_width_max"], + rise_min=pu["pulse_rise_min"], + rise_max=pu["pulse_rise_max"], + ) + print(f" Pulse shape: {pulse['label']}") + + # ── Step 4: run the full pipeline ───────────────────────────────────────── + print("\n[ Processing ]") + if args.output_dir: + config.setdefault("advanced", {})["custom_output_directory"] = args.output_dir + + def on_progress(pct, msg): + bar = "█" * (pct // 5) + "░" * (20 - pct // 5) + print(f"\r [{bar}] {pct:3d}% {msg:<45}", end="", flush=True) + + result = cli.process(args.file, config, on_progress=on_progress) + print() # newline after progress + + if not result["success"]: + print(f"\nError: {result['error']}", file=sys.stderr) + sys.exit(1) + + # ── Step 5: show outputs ────────────────────────────────────────────────── + print(f"\n[ Outputs — {len(result['outputs'])} files ]") + for out in result["outputs"]: + size_kb = out["size_bytes"] / 1024 + print(f" {out['suffix']:<30} {size_kb:6.1f} KB") + + print("\nDone.") + return result + + +if __name__ == "__main__": + main() diff --git a/examples/process_default.sh b/examples/process_default.sh new file mode 100644 index 0000000..4a9c6e1 --- /dev/null +++ b/examples/process_default.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# examples/process_default.sh +# +# Simplest possible usage: process a funscript with all defaults. +# Outputs land next to the input file. +# +# Usage: +# ./examples/process_default.sh +# ./examples/process_default.sh path/to/your/file.funscript +# ./examples/process_default.sh path/to/file.funscript --output-dir /some/dir + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" + +INPUT="${1:-$SCRIPT_DIR/sample.funscript}" +SHIFT_DONE=false +if [ $# -ge 1 ]; then shift; SHIFT_DONE=true; fi + +echo "========================================" +echo " funscript-tools — default processing" +echo " Engine by edger477" +echo "========================================" +echo + +# ── Step 1: show file info ──────────────────────────────────────────────────── +echo "[ Info ]" +python "$REPO_ROOT/cli.py" info "$INPUT" +echo + +# ── Step 2: process with defaults ──────────────────────────────────────────── +echo "[ Processing ]" +python "$REPO_ROOT/cli.py" process "$INPUT" "$@" +echo + +# ── Step 3: list what was generated ────────────────────────────────────────── +STEM="$(basename "$INPUT" .funscript)" +DIR="$(dirname "$INPUT")" + +echo "[ Outputs ]" +python "$REPO_ROOT/cli.py" list-outputs "$DIR" "$STEM" +echo +echo "Done." diff --git a/examples/sample.funscript b/examples/sample.funscript new file mode 100644 index 0000000..f80c2dd --- /dev/null +++ b/examples/sample.funscript @@ -0,0 +1,25 @@ +{ + "version": 1, + "inverted": false, + "range": 90, + "actions": [ + {"at": 0, "pos": 0}, + {"at": 500, "pos": 100}, + {"at": 1000, "pos": 5}, + {"at": 1400, "pos": 95}, + {"at": 1750, "pos": 10}, + {"at": 2000, "pos": 90}, + {"at": 2400, "pos": 0}, + {"at": 3000, "pos": 100}, + {"at": 3600, "pos": 5}, + {"at": 4000, "pos": 80}, + {"at": 4500, "pos": 15}, + {"at": 5000, "pos": 95}, + {"at": 5800, "pos": 0}, + {"at": 6500, "pos": 100}, + {"at": 7200, "pos": 5}, + {"at": 8000, "pos": 90}, + {"at": 9000, "pos": 10}, + {"at": 10000, "pos": 100} + ] +} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4acad9d --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,10 @@ +""" +conftest.py — pytest configuration for funscript-tools tests. + +All tests go through cli.py only — never import upstream internals directly. +""" +import sys +from pathlib import Path + +# Ensure repo root is on the path so `import cli` works +sys.path.insert(0, str(Path(__file__).parent.parent)) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..1fa89bb --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,281 @@ +""" +tests/test_cli.py + +Tests for the cli.py adapter layer. + +These are the canonical tests — they call only cli.py, never upstream +internals directly. If these pass, the adapter contract is intact regardless +of what changed upstream. + +Run: + python -m pytest tests/test_cli.py -v + python tests/test_cli.py # without pytest +""" + +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) +import cli + +SAMPLE = Path(__file__).parent.parent / "examples" / "sample.funscript" + +# Minimal inline funscript for tests that don't need the full sample +MINIMAL_FUNSCRIPT = { + "version": 1, + "actions": [ + {"at": 0, "pos": 0}, + {"at": 500, "pos": 100}, + {"at": 1000, "pos": 5}, + {"at": 1500, "pos": 95}, + {"at": 2000, "pos": 10}, + {"at": 3000, "pos": 100}, + {"at": 4000, "pos": 0}, + {"at": 5000, "pos": 80}, + ], +} + + +def _write_temp_funscript(data=None) -> Path: + """Write a funscript to a temp file and return its path.""" + f = tempfile.NamedTemporaryFile( + mode="w", suffix=".funscript", delete=False + ) + json.dump(data or MINIMAL_FUNSCRIPT, f) + f.close() + return Path(f.name) + + +class TestLoadFile(unittest.TestCase): + + def test_load_sample(self): + """load_file returns expected keys and sane values.""" + info = cli.load_file(str(SAMPLE)) + self.assertIn("name", info) + self.assertIn("actions", info) + self.assertIn("duration_s", info) + self.assertIn("duration_fmt", info) + self.assertIn("x", info) + self.assertIn("y", info) + self.assertGreater(info["actions"], 0) + self.assertGreater(info["duration_s"], 0) + self.assertGreater(len(info["x"]), 0) + self.assertEqual(len(info["x"]), len(info["y"])) + + def test_load_values_in_range(self): + """y values should be 0–100.""" + info = cli.load_file(str(SAMPLE)) + self.assertGreaterEqual(min(info["y"]), 0) + self.assertLessEqual(max(info["y"]), 100) + + def test_load_missing_file(self): + """load_file raises ValueError for missing files.""" + with self.assertRaises(ValueError): + cli.load_file("/nonexistent/path/file.funscript") + + def test_load_wrong_extension(self): + """load_file raises ValueError for non-.funscript files.""" + with self.assertRaises(ValueError): + cli.load_file("/some/file.txt") + + def test_load_temp_file(self): + """load_file works on a freshly written temp file.""" + p = _write_temp_funscript() + try: + info = cli.load_file(str(p)) + self.assertEqual(info["actions"], len(MINIMAL_FUNSCRIPT["actions"])) + finally: + p.unlink() + + +class TestGetDefaultConfig(unittest.TestCase): + + def test_returns_dict(self): + cfg = cli.get_default_config() + self.assertIsInstance(cfg, dict) + + def test_has_required_sections(self): + cfg = cli.get_default_config() + for section in ("general", "frequency", "volume", "pulse", + "alpha_beta_generation", "options"): + self.assertIn(section, cfg, f"Missing config section: {section}") + + def test_independent_copies(self): + """Each call returns an independent copy — mutations don't bleed.""" + cfg1 = cli.get_default_config() + cfg2 = cli.get_default_config() + cfg1["frequency"]["pulse_freq_min"] = 0.99 + self.assertNotEqual( + cfg2["frequency"]["pulse_freq_min"], 0.99, + "get_default_config() should return independent copies", + ) + + +class TestProcess(unittest.TestCase): + + def test_process_default(self): + """process() with defaults succeeds and returns output files.""" + p = _write_temp_funscript() + try: + config = cli.get_default_config() + # Write outputs next to the temp file + result = cli.process(str(p), config) + self.assertTrue(result["success"], msg=result.get("error")) + self.assertIsInstance(result["outputs"], list) + self.assertGreater(len(result["outputs"]), 0) + finally: + # Clean up temp file and any generated outputs + stem = p.stem + for f in p.parent.glob(f"{stem}*"): + f.unlink(missing_ok=True) + if p.parent.joinpath("funscript-temp").exists(): + import shutil + shutil.rmtree(p.parent / "funscript-temp", ignore_errors=True) + + def test_process_returns_known_suffixes(self): + """process() generates at least the core output types.""" + p = _write_temp_funscript() + try: + config = cli.get_default_config() + result = cli.process(str(p), config) + suffixes = {o["suffix"] for o in result["outputs"]} + for expected in ("alpha", "beta", "frequency", "volume"): + self.assertIn(expected, suffixes, f"Missing output: {expected}") + finally: + stem = p.stem + for f in p.parent.glob(f"{stem}*"): + f.unlink(missing_ok=True) + import shutil + shutil.rmtree(p.parent / "funscript-temp", ignore_errors=True) + + def test_process_missing_file(self): + """process() on a missing file returns success=False.""" + config = cli.get_default_config() + result = cli.process("/nonexistent/file.funscript", config) + self.assertFalse(result["success"]) + self.assertIsNotNone(result["error"]) + + +class TestListOutputs(unittest.TestCase): + + def test_empty_dir(self): + """list_outputs returns empty list when nothing matches.""" + with tempfile.TemporaryDirectory() as d: + outputs = cli.list_outputs(d, "nonexistent_stem") + self.assertEqual(outputs, []) + + def test_finds_files(self): + """list_outputs finds files matching the stem pattern.""" + with tempfile.TemporaryDirectory() as d: + dp = Path(d) + (dp / "mystem.alpha.funscript").write_text("{}") + (dp / "mystem.beta.funscript").write_text("{}") + (dp / "other.alpha.funscript").write_text("{}") # should not match + + outputs = cli.list_outputs(d, "mystem") + suffixes = {o["suffix"] for o in outputs} + self.assertIn("alpha", suffixes) + self.assertIn("beta", suffixes) + self.assertNotIn("other", suffixes) + self.assertEqual(len(outputs), 2) + + +class TestPreviewElectrodePath(unittest.TestCase): + + def test_returns_expected_keys(self): + result = cli.preview_electrode_path() + self.assertIn("alpha", result) + self.assertIn("beta", result) + self.assertIn("label", result) + self.assertIn("description", result) + + def test_all_algorithms(self): + """All known algorithms return valid data.""" + for algo in cli.ALGORITHMS: + result = cli.preview_electrode_path(algorithm=algo) + self.assertGreater(len(result["alpha"]), 0, f"No data for {algo}") + self.assertEqual(len(result["alpha"]), len(result["beta"])) + + def test_values_in_range(self): + """Path values should stay within 0–1.""" + result = cli.preview_electrode_path() + for v in result["alpha"]: + self.assertGreaterEqual(v, -0.1) # small tolerance for edge algorithms + self.assertLessEqual(v, 1.1) + + +class TestPreviewFrequencyBlend(unittest.TestCase): + + def test_returns_expected_keys(self): + result = cli.preview_frequency_blend() + for key in ("frequency_ramp_pct", "frequency_speed_pct", + "pulse_speed_pct", "pulse_alpha_pct", + "frequency_label", "pulse_label", "overall_label"): + self.assertIn(key, result) + + def test_percentages_sum_to_100(self): + result = cli.preview_frequency_blend(2.0, 3.0) + self.assertAlmostEqual( + result["frequency_ramp_pct"] + result["frequency_speed_pct"], + 100.0, places=0 + ) + + def test_ratio_1_is_equal_split(self): + """Ratio of 1 means 100% of the second source.""" + result = cli.preview_frequency_blend(1.0, 1.0) + self.assertAlmostEqual(result["frequency_speed_pct"], 100.0, places=0) + + +class TestPreviewPulseShape(unittest.TestCase): + + def test_returns_expected_keys(self): + result = cli.preview_pulse_shape() + for key in ("x", "y", "width", "rise", "label", "sharpness"): + self.assertIn(key, result) + + def test_x_y_same_length(self): + result = cli.preview_pulse_shape() + self.assertEqual(len(result["x"]), len(result["y"])) + + def test_sharpness_values(self): + sharp = cli.preview_pulse_shape(rise_min=0.0, rise_max=0.05) + soft = cli.preview_pulse_shape(rise_min=0.5, rise_max=0.9) + self.assertEqual(sharp["sharpness"], "sharp") + self.assertEqual(soft["sharpness"], "soft") + + +class TestPreviewOutput(unittest.TestCase): + + def test_returns_original_data(self): + """preview_output always returns original waveform data.""" + info = cli.load_file(str(SAMPLE)) + config = cli.get_default_config() + result = cli.preview_output(info, config, "alpha") + self.assertEqual(result["original_x"], info["x"]) + self.assertEqual(result["original_y"], info["y"]) + + def test_available_outputs(self): + """alpha, beta, speed, frequency, volume should all be previewable.""" + info = cli.load_file(str(SAMPLE)) + config = cli.get_default_config() + for output_type in ("alpha", "beta", "speed", "frequency", "volume"): + result = cli.preview_output(info, config, output_type) + self.assertTrue( + result["available"], + f"preview_output failed for '{output_type}': {result['label']}" + ) + self.assertGreater(len(result["output_x"]), 0) + + def test_unknown_output_graceful(self): + """Unknown output type returns available=False, doesn't crash.""" + info = cli.load_file(str(SAMPLE)) + config = cli.get_default_config() + result = cli.preview_output(info, config, "nonexistent_type") + self.assertFalse(result["available"]) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From c9a52b3a991d5445403182e402ba5d15557683b7 Mon Sep 17 00:00:00 2001 From: lqr Date: Sat, 14 Mar 2026 08:53:10 -0400 Subject: [PATCH 2/2] Add user guide: plain-language explanation of all creative decisions Explains what each of the 10 output files controls in terms of sensation, translates the three main creative decisions (algorithm, frequency blend, pulse shape) into physical terms without requiring signal processing knowledge, and shows toolchain automation patterns including batch processing and --json piping. Co-Authored-By: Claude Sonnet 4.6 --- docs/USER_GUIDE.md | 278 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 docs/USER_GUIDE.md diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md new file mode 100644 index 0000000..150e563 --- /dev/null +++ b/docs/USER_GUIDE.md @@ -0,0 +1,278 @@ +# User Guide + +> **Credit:** The processing engine is [edger477's funscript-tools](https://github.com/edger477/funscript-tools). +> This guide covers the CLI adapter layer built on top of it. + +--- + +## What this tool does + +You have a `.funscript` — a timeline of position movements, originally created for +a stroker device. This tool converts that file into a set of estim (e-stim) control +signals for use in [restim](https://github.com/edger477/restim). + +One `.funscript` in → ten output files out. Each output controls a different +dimension of what you feel. + +--- + +## The ten output files + +| File | What it controls | What you feel | +|------|-----------------|---------------| +| `*.alpha.funscript` | Electrode X position | Left–right movement of sensation | +| `*.beta.funscript` | Electrode Y position | Up–down movement of sensation | +| `*.alpha-prostate.funscript` | Prostate electrode X | Spatial position for prostate channel | +| `*.beta-prostate.funscript` | Prostate electrode Y | Spatial position for prostate channel | +| `*.frequency.funscript` | Pulse rate envelope | Overall intensity rhythm | +| `*.pulse_frequency.funscript` | Pulse rate by action | How directly action speed maps to sensation rate | +| `*.volume.funscript` | Amplitude envelope | Overall strength | +| `*.volume-prostate.funscript` | Prostate amplitude | Prostate channel strength | +| `*.pulse_width.funscript` | Pulse duration | Fullness vs sharpness of each pulse | +| `*.pulse_rise_time.funscript` | Pulse attack | Hard edge vs soft onset of each pulse | + +Think of the alpha/beta pair as *where* you feel it, frequency as *how fast*, pulse shape +as *what kind*, and volume as *how strong*. + +--- + +## The three creative decisions + +Most of the config is infrastructure you set once. Three things actually change +the character of the output: + +### 1. Algorithm — where the sensation moves + +The algorithm controls the path the electrode position traces as the source +funscript plays back. Different algorithms produce different spatial movement patterns. + +| Algorithm | Path shape | Character | +|-----------|------------|-----------| +| `circular` | Semi-circle (0°–180°) | Smooth, balanced, good for most content | +| `top-right-left` | Wide arc (0°–270°) | More variation, stronger contrast between strokes | +| `top-left-right` | Narrow arc (0°–90°) | Subtle, suited to slower, gentler content | +| `restim-original` | Full circle with random reversals | Unpredictable, most varied | + +**To explore algorithms before committing:** +```bash +python cli.py algorithms +python cli.py preview electrode-path --algorithm circular +python cli.py preview electrode-path --algorithm top-right-left +python cli.py preview electrode-path --algorithm restim-original +``` + +**What `--min-distance` does:** +The `min_distance_from_center` config setting (0.1–0.9) controls how far from center +the electrode can range. Higher = wider sweep, more pronounced movement. + +``` +low (0.1) ████░░░░░░ electrode stays near center — subtle +mid (0.5) ██░░░░░░░░ moderate range +high (0.9) █░░░░░░░░░ full range — strong, sweeping movement +``` + +--- + +### 2. Frequency blend — how sensation tracks the action + +The frequency output is a blend of two signals: + +- **Scene energy (ramp):** A slow-building intensity curve that rises and falls with + the overall pace of a scene. Think of it as the "mood arc." +- **Action speed:** Direct tracking of how fast the source funscript is moving. + Fast strokes → faster pulse rate, immediately. + +The `frequency_ramp_combine_ratio` (1–10) sets the blend: + +``` +ratio 1: ████████████ 100% action speed — highly reactive, follows every stroke +ratio 3: ██████░░░░░░ 75% action speed + 25% ramp +ratio 5: ████████░░░░ 50% / 50% ← default, balanced +ratio 8: ░░░░░░░░░░██ 12% action speed + 88% ramp — slow, gradual build +ratio 10: ░░░░░░░░░░░░ 100% ramp — ignores action speed entirely +``` + +**To hear what these mean before processing:** +```bash +python cli.py preview frequency-blend --ramp-ratio 1 +python cli.py preview frequency-blend --ramp-ratio 5 +python cli.py preview frequency-blend --ramp-ratio 8 +``` + +**Rule of thumb:** +- Fast, intense content → lower ratio (reactive) +- Slow, scene-building content → higher ratio (gradual build) +- Mixed content → default (5) + +--- + +### 3. Pulse shape — the character of each pulse + +Each electrical pulse has two physical dimensions: + +**Width** (how long each pulse lasts): +``` +narrow ▐█▌ short, sharp individual pulses +medium ▐███▌ +wide ▐███████▌ long, full pulses — more "filled in" sensation +``` + +**Rise time** (how the pulse attacks): +``` +sharp ▐█▌ ▐█▌ immediate onset, hard edge + ▐/▌ ▐\▌ +soft ▐ ▌▐ ▌ gradual build-in, rounded feel +``` + +The config sets a min and max for each — the output file sweeps between them based +on the source funscript's intensity. + +```bash +python cli.py preview pulse-shape --width-min 0.05 --width-max 0.2 --rise-min 0.0 --rise-max 0.05 +# → narrow width, sharp — immediate onset + +python cli.py preview pulse-shape --width-min 0.3 --width-max 0.6 --rise-min 0.5 --rise-max 0.9 +# → wide width, soft — gentle build +``` + +--- + +## Typical workflow + +### One-off: explore and process + +```bash +# 1. Inspect your file +python cli.py info my_scene.funscript + +# 2. Preview your creative settings (no file written) +python cli.py preview electrode-path --algorithm circular +python cli.py preview frequency-blend --ramp-ratio 5 +python cli.py preview pulse-shape + +# 3. Process with defaults +python cli.py process my_scene.funscript + +# 4. Check what was generated +python cli.py list-outputs . my_scene +``` + +### Iterative: save a config and tune it + +```bash +# Save defaults to a file +python cli.py config save configs/my_style.json + +# Edit the file — adjust algorithm, frequency blend, pulse shape +# (see Key settings below) + +# Process with your config +python cli.py process my_scene.funscript --config configs/my_style.json + +# Regenerate to refine +python cli.py process my_scene.funscript --config configs/my_style.json +``` + +### Toolchain: batch process a library + +```bash +# Process every scene with the same config +for f in ~/scenes/*.funscript; do + python cli.py process "$f" \ + --config configs/my_style.json \ + --output-dir ~/restim/outputs/ +done +``` + +Or from Python (e.g. in a build script or CI pipeline): + +```python +from cli import load_file, get_default_config, process, list_outputs +import json, pathlib + +config = json.loads(pathlib.Path("configs/my_style.json").read_text()) +scenes = pathlib.Path("~/scenes").expanduser().glob("*.funscript") + +for scene in scenes: + info = load_file(str(scene)) + print(f"Processing {info['name']} ({info['duration_fmt']})…") + result = process(str(scene), config) + if result["success"]: + for out in result["outputs"]: + print(f" ✓ {out['suffix']}") + else: + print(f" ✗ {result['error']}") +``` + +--- + +## Key config settings + +Run `python cli.py config save my_config.json` to get a full config template. +The settings you'll actually want to change: + +| Setting | Where | What to change it for | +|---------|-------|-----------------------| +| `algorithm` | `alpha_beta_generation` | Spatial movement character | +| `min_distance_from_center` | `alpha_beta_generation` | How wide the spatial sweep is | +| `frequency_ramp_combine_ratio` | `frequency` | Reactive vs gradual build | +| `pulse_freq_min` / `pulse_freq_max` | `frequency` | Overall pulse rate range | +| `pulse_width_min` / `pulse_width_max` | `pulse` | Pulse fullness range | +| `pulse_rise_min` / `pulse_rise_max` | `pulse` | Sharp vs soft attack range | +| `rest_level` | `general` | Baseline intensity during slow sections | +| `overwrite_existing_files` | `options` | Whether to regenerate existing outputs | + +--- + +## Automating with `--json` + +All `preview` commands return JSON when called with `--json`. This is designed +for toolchain use — pipe data to a plotter, feed it to a UI, or drive a CI +artifact check. + +```bash +# Capture electrode path data for plotting +python cli.py preview electrode-path --algorithm circular --json > circular.json +python cli.py preview electrode-path --algorithm restim-original --json > original.json + +# Compare frequency blend options +python cli.py preview frequency-blend --ramp-ratio 1 --json +python cli.py preview frequency-blend --ramp-ratio 8 --json + +# Pull a single config value +python cli.py config show frequency | python -c " +import json, sys +cfg = json.load(sys.stdin) +print(f'Pulse rate range: {cfg[\"pulse_freq_min\"]} – {cfg[\"pulse_freq_max\"]}') +" +``` + +--- + +## Named config profiles (recommended for toolchains) + +Save multiple profiles for different content types and call them by name: + +``` +configs/ + gentle.json # soft pulse, gradual build, narrow arc + reactive.json # sharp pulse, high action-tracking, wide arc + scene-builder.json # high ramp ratio, builds over time + default.json # baseline — good starting point +``` + +```bash +# In a Makefile or build script: +python cli.py process $SCENE --config configs/reactive.json --output-dir dist/ +``` + +--- + +## Next steps + +- **Visualizations:** The UI (coming in FunScriptForge) shows live before/after + waveform comparisons for every setting above. Every slider move updates the + preview in real time. +- **More docs:** See [CLI_REFERENCE.md](CLI_REFERENCE.md) for flag-level detail + on every command. +- **MkDocs:** This doc folder will be surfaced as a searchable site via GitHub Pages.