Skip to content

Commit d09363b

Browse files
committed
CLI
1 parent 05b3d4d commit d09363b

File tree

7 files changed

+334
-1
lines changed

7 files changed

+334
-1
lines changed

.claude/skills/screenshot/SKILL.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
name: screenshot
3+
description: Take a screenshot of an iPlug2 standalone app's GUI for iteration with AI
4+
---
5+
6+
# Screenshot iPlug2 App GUI
7+
8+
Use this skill to capture a screenshot of a running iPlug2 standalone app. This is useful for iterating on GUI design with AI assistance.
9+
10+
## Requirements
11+
12+
- The app must be built in **Debug** configuration
13+
- The app must be running
14+
- On first use, macOS may prompt for Screen Recording permission
15+
16+
## Taking a Screenshot
17+
18+
### Method 1: Keyboard Shortcut
19+
With the app in focus, press:
20+
- **macOS:** `Cmd+Shift+S`
21+
- **Windows:** `Ctrl+Shift+S`
22+
23+
### Method 2: Menu
24+
1. Open the **Debug** menu
25+
2. Click **Save Screenshot**
26+
27+
## Screenshot Location
28+
29+
Screenshots are saved to the system temp directory with a timestamp:
30+
```
31+
{TMPDIR}/{PluginName}_screenshot_YYYYMMDD_HHMMSS.png
32+
```
33+
34+
On macOS, `TMPDIR` is typically `/var/folders/.../T/`
35+
36+
After saving, a dialog appears with the full path and an option to open the file.
37+
38+
## Viewing Screenshots in Claude Code
39+
40+
To show a screenshot to Claude Code for GUI iteration:
41+
42+
```bash
43+
# Find the most recent screenshot
44+
ls -t $TMPDIR/*_screenshot_*.png | head -1
45+
46+
# Or copy to a known location
47+
cp $(ls -t $TMPDIR/*_screenshot_*.png | head -1) ./screenshot.png
48+
```
49+
50+
Then ask Claude Code to read the screenshot file.
51+
52+
## Troubleshooting
53+
54+
### No Debug menu visible
55+
- Ensure the app is built with **Debug** configuration
56+
- For CMake builds: `cmake -B build -DCMAKE_BUILD_TYPE=Debug`
57+
- For Xcode builds: Select "Debug" configuration
58+
59+
### Screenshot is blank
60+
- This can happen with certain GPU-accelerated views
61+
- The IGraphics-based screenshot (used when IGraphics is available) should work with Metal/NanoVG/Skia backends
62+
63+
### Permission denied on macOS
64+
- Go to System Preferences > Privacy & Security > Screen Recording
65+
- Enable permission for the app or Terminal
66+
67+
## Technical Details
68+
69+
The screenshot feature uses:
70+
- **IGraphics builds:** Layer-based capture via `IGraphics::SaveScreenshot()` - works with all backends
71+
- **Non-IGraphics builds (Visage, SwiftUI, etc.):** `CGWindowListCreateImage` API via dlsym
72+
73+
Screenshots are captured at full Retina/HiDPI resolution.

TemplateProject/CMakeLists.txt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,20 @@ if(NOT IOS AND NOT CMAKE_SYSTEM_NAME STREQUAL "Emscripten")
104104
)
105105
iplug_configure_target(${PROJECT_NAME}-clap CLAP ${PROJECT_NAME})
106106
iplug_add_plugin_resources(${PROJECT_NAME}-clap)
107+
108+
# ============================================================================
109+
# CLI Target (Command Line Interface) - NO IGraphics, for offline processing
110+
# ============================================================================
111+
add_executable(${PROJECT_NAME}-cli
112+
TemplateProject.cpp
113+
TemplateProject.h
114+
resources/resource.h
115+
)
116+
iplug_add_target(${PROJECT_NAME}-cli PUBLIC
117+
INCLUDE ${PROJECT_DIR} ${PROJECT_DIR}/resources
118+
LINK iPlug2::CLI
119+
)
120+
iplug_configure_cli(${PROJECT_NAME}-cli ${PROJECT_NAME})
107121
endif()
108122

109123
# ============================================================================
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Python harness for plotting impulse responses from iPlug2 CLI plugins.
4+
5+
Usage:
6+
python plot_impulse.py <cli_binary> [options]
7+
8+
Examples:
9+
# Basic impulse plot with default settings
10+
python plot_impulse.py ./build/out/MyPlugin
11+
12+
# Set gain to 50% and plot 1024 samples
13+
python plot_impulse.py ./build/out/MyPlugin --set 0 50 --length 1024
14+
15+
# Compare multiple gain settings
16+
python plot_impulse.py ./build/out/MyPlugin --sweep 0 0 100 5
17+
18+
# Save to PNG
19+
python plot_impulse.py ./build/out/MyPlugin --set 0 75 -o impulse.png
20+
"""
21+
22+
import argparse
23+
import subprocess
24+
import tempfile
25+
import os
26+
import sys
27+
from pathlib import Path
28+
29+
# Optional: matplotlib for plotting
30+
try:
31+
import matplotlib.pyplot as plt
32+
HAS_MATPLOTLIB = True
33+
except ImportError:
34+
HAS_MATPLOTLIB = False
35+
36+
37+
38+
39+
def run_impulse(cli_path, length=4096, params=None, sample_rate=44100):
40+
"""
41+
Run the CLI to generate an impulse response.
42+
43+
Args:
44+
cli_path: Path to the CLI binary
45+
length: Impulse response length in samples
46+
params: List of (index, value) tuples to set parameters
47+
sample_rate: Sample rate in Hz
48+
49+
Returns:
50+
List of float samples
51+
"""
52+
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
53+
output_path = f.name
54+
55+
try:
56+
cmd = [cli_path, "--sr", str(sample_rate)]
57+
58+
# Add parameter settings
59+
if params:
60+
for idx, val in params:
61+
cmd.extend(["--set", str(idx), str(val)])
62+
63+
cmd.extend(["--impulse", str(length), "--output-txt", output_path])
64+
65+
result = subprocess.run(cmd, capture_output=True, text=True)
66+
67+
if result.returncode != 0:
68+
print(f"CLI error: {result.stderr}", file=sys.stderr)
69+
return None
70+
71+
# Read samples from output file
72+
samples = []
73+
with open(output_path, 'r') as f:
74+
for line in f:
75+
line = line.strip()
76+
if line:
77+
samples.append(float(line))
78+
79+
return samples
80+
81+
finally:
82+
if os.path.exists(output_path):
83+
os.unlink(output_path)
84+
85+
86+
def plot_impulse(samples, title="Impulse Response", sample_rate=44100, output_path=None):
87+
"""Plot an impulse response."""
88+
if not HAS_MATPLOTLIB:
89+
print("matplotlib not installed. Install with: pip install matplotlib")
90+
print("\nSample data (first 32 samples):")
91+
for i, s in enumerate(samples[:32]):
92+
print(f" [{i:4d}] {s:+.6f}")
93+
return None
94+
95+
n = len(samples)
96+
time_ms = [i * 1000.0 / sample_rate for i in range(n)]
97+
98+
fig, ax = plt.subplots(figsize=(10, 4))
99+
ax.plot(time_ms, samples, 'b-', linewidth=0.8)
100+
ax.axhline(y=0, color='gray', linestyle='-', linewidth=0.5)
101+
ax.set_xlabel('Time (ms)')
102+
ax.set_ylabel('Amplitude')
103+
ax.set_title(title)
104+
ax.grid(True, alpha=0.3)
105+
106+
plt.tight_layout()
107+
108+
if output_path:
109+
plt.savefig(output_path, dpi=150, bbox_inches='tight')
110+
plt.close(fig)
111+
return output_path
112+
else:
113+
plt.show()
114+
return None
115+
116+
117+
def plot_comparison(results, sample_rate=44100, output_path=None):
118+
"""Plot multiple impulse responses for comparison."""
119+
if not HAS_MATPLOTLIB:
120+
print("matplotlib not installed for comparison plot")
121+
return None
122+
123+
fig, ax = plt.subplots(figsize=(10, 5))
124+
125+
for label, samples in results.items():
126+
n = len(samples)
127+
time_ms = [i * 1000.0 / sample_rate for i in range(n)]
128+
ax.plot(time_ms, samples, label=label, linewidth=0.8)
129+
130+
ax.axhline(y=0, color='gray', linestyle='-', linewidth=0.5)
131+
ax.set_xlabel('Time (ms)')
132+
ax.set_ylabel('Amplitude')
133+
ax.set_title('Impulse Response Comparison')
134+
ax.legend()
135+
ax.grid(True, alpha=0.3)
136+
137+
plt.tight_layout()
138+
139+
if output_path:
140+
plt.savefig(output_path, dpi=150, bbox_inches='tight')
141+
plt.close(fig)
142+
return output_path
143+
else:
144+
plt.show()
145+
return None
146+
147+
148+
def main():
149+
parser = argparse.ArgumentParser(
150+
description='Plot impulse responses from iPlug2 CLI plugins',
151+
formatter_class=argparse.RawDescriptionHelpFormatter,
152+
epilog=__doc__
153+
)
154+
parser.add_argument('cli', type=str, help='Path to CLI binary')
155+
parser.add_argument('--length', '-l', type=int, default=4096,
156+
help='Impulse response length in samples (default: 4096)')
157+
parser.add_argument('--sr', type=int, default=44100,
158+
help='Sample rate in Hz (default: 44100)')
159+
parser.add_argument('--set', nargs=2, action='append', metavar=('IDX', 'VAL'),
160+
help='Set parameter IDX to VAL (can be repeated)')
161+
parser.add_argument('--sweep', nargs=4, type=float, metavar=('IDX', 'START', 'END', 'STEPS'),
162+
help='Sweep parameter IDX from START to END in STEPS')
163+
parser.add_argument('--output', '-o', type=str,
164+
help='Save plot to PNG file instead of displaying')
165+
parser.add_argument('--no-plot', action='store_true',
166+
help='Print samples instead of plotting')
167+
168+
args = parser.parse_args()
169+
170+
cli_path = args.cli
171+
print(f"Using CLI: {cli_path}")
172+
173+
# Parse parameters
174+
params = []
175+
if args.set:
176+
for idx, val in args.set:
177+
params.append((int(idx), float(val)))
178+
179+
if args.sweep:
180+
# Parameter sweep mode
181+
param_idx = int(args.sweep[0])
182+
start, end, steps = args.sweep[1], args.sweep[2], int(args.sweep[3])
183+
184+
results = {}
185+
for i in range(steps + 1):
186+
val = start + (end - start) * i / steps
187+
sweep_params = params + [(param_idx, val)]
188+
samples = run_impulse(cli_path, args.length, sweep_params, args.sr)
189+
if samples:
190+
results[f"Param {param_idx}={val:.1f}"] = samples
191+
192+
if results:
193+
if args.no_plot or not HAS_MATPLOTLIB:
194+
for label, samples in results.items():
195+
print(f"\n{label}:")
196+
print(f" Peak: {max(abs(s) for s in samples):.6f}")
197+
print(f" Sum: {sum(samples):.6f}")
198+
else:
199+
out = plot_comparison(results, args.sr, args.output)
200+
if out:
201+
print(f"Saved plot to: {out}")
202+
else:
203+
# Single impulse mode
204+
samples = run_impulse(cli_path, args.length, params, args.sr)
205+
206+
if samples:
207+
if args.no_plot or not HAS_MATPLOTLIB:
208+
print(f"\nImpulse Response ({len(samples)} samples):")
209+
print(f" Peak amplitude: {max(abs(s) for s in samples):.6f}")
210+
print(f" DC sum: {sum(samples):.6f}")
211+
print("\nFirst 32 samples:")
212+
for i, s in enumerate(samples[:32]):
213+
bar = '#' * int(abs(s) * 50) if abs(s) > 0.001 else ''
214+
print(f" [{i:4d}] {s:+.6f} {bar}")
215+
else:
216+
title = "Impulse Response"
217+
if params:
218+
param_str = ', '.join(f"P{i}={v}" for i, v in params)
219+
title += f" ({param_str})"
220+
out = plot_impulse(samples, title, args.sr, args.output)
221+
if out:
222+
print(f"Saved plot to: {out}")
223+
224+
225+
if __name__ == '__main__':
226+
main()

VisageTemplate/CMakeLists.txt

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,20 @@ if(NOT IOS AND NOT CMAKE_SYSTEM_NAME STREQUAL "Emscripten")
7171
LINK iPlug2::CLAP _${PROJECT_NAME}-base
7272
)
7373
iplug_configure_target(${PROJECT_NAME}-clap CLAP ${PROJECT_NAME})
74+
75+
# ============================================================================
76+
# CLI Target (Command Line Interface) - NO UI, for offline processing
77+
# ============================================================================
78+
add_executable(${PROJECT_NAME}-cli
79+
VisageTemplate.cpp
80+
VisageTemplate.h
81+
resources/resource.h
82+
)
83+
iplug_add_target(${PROJECT_NAME}-cli PUBLIC
84+
INCLUDE ${PROJECT_DIR} ${PROJECT_DIR}/resources
85+
LINK iPlug2::CLI
86+
)
87+
iplug_configure_cli(${PROJECT_NAME}-cli ${PROJECT_NAME})
7488
endif()
7589

7690
# ============================================================================

VisageTemplate/VisageTemplate.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22
#include "IPlug_include_in_plug_src.h"
33
#include <cmath>
44

5+
#ifndef CLI_API
56
static constexpr float kPi = 3.14159265358979323846f;
67
static constexpr float kTwoPi = 2.f * kPi;
8+
#endif
79

810
using namespace iplug;
911

@@ -13,6 +15,7 @@ VisageTemplate::VisageTemplate(const InstanceInfo& info)
1315
GetParam(kParamGain)->InitDouble("Gain", 50., 0., 100.0, 0.01, "%");
1416
}
1517

18+
#ifndef CLI_API
1619
void VisageTemplate::OnDraw(visage::Canvas& canvas)
1720
{
1821
auto* editor = GetEditor();
@@ -215,6 +218,7 @@ void VisageTemplate::OnParamChangeUI(int paramIdx, EParamSource source)
215218
if (source != EParamSource::kUI)
216219
Redraw();
217220
}
221+
#endif // CLI_API
218222

219223
void VisageTemplate::ProcessBlock(sample** inputs, sample** outputs, int nFrames)
220224
{

VisageTemplate/VisageTemplate.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class VisageTemplate final : public iplug::Plugin
1717

1818
void ProcessBlock(iplug::sample** inputs, iplug::sample** outputs, int nFrames) override;
1919

20+
#ifndef CLI_API
2021
protected:
2122
// VisageEditorDelegate overrides
2223
void OnDraw(visage::Canvas& canvas) override;
@@ -28,4 +29,5 @@ class VisageTemplate final : public iplug::Plugin
2829
private:
2930
bool mDragging = false;
3031
float mLastY = 0.f;
32+
#endif
3133
};

iPlug2

0 commit comments

Comments
 (0)