hyperframes-vst-host 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
File without changes
@@ -0,0 +1,78 @@
1
+ import argparse
2
+ import sys
3
+
4
+ from .chain import PluginMissingError
5
+
6
+
7
+ def main() -> int:
8
+ parser = argparse.ArgumentParser(prog="hyperframes-vst")
9
+ sub = parser.add_subparsers(dest="command", required=True)
10
+
11
+ p_bounce = sub.add_parser("bounce", help="Offline render a WAV through a chain")
12
+ p_bounce.add_argument("--input", required=True)
13
+ p_bounce.add_argument("--chain", required=True)
14
+ p_bounce.add_argument("--output", required=True)
15
+
16
+ p_probe = sub.add_parser("probe", help="Probe one bundle (may crash; run in subprocess)")
17
+ p_probe.add_argument("path")
18
+
19
+ p_scan = sub.add_parser("scan", help="Scan plugin dirs, print registry JSON")
20
+ p_scan.add_argument("--dirs", nargs="*", default=None)
21
+ p_scan.add_argument("--json", action="store_true")
22
+
23
+ p_carve = sub.add_parser("carve", help="Analyze a voiceover WAV, print carve bands JSON")
24
+ p_carve.add_argument("--voice", required=True)
25
+ p_carve.add_argument("--max-cut-db", type=float, default=4.0)
26
+ p_carve.add_argument("--json", action="store_true")
27
+
28
+ p_serve = sub.add_parser("serve", help="Run the WebSocket sidecar")
29
+ p_serve.add_argument("--port", type=int, default=0)
30
+
31
+ args = parser.parse_args()
32
+
33
+ if args.command == "bounce":
34
+ from .bounce import bounce_file
35
+
36
+ try:
37
+ bounce_file(args.input, args.chain, args.output)
38
+ except PluginMissingError as exc:
39
+ print(f"PLUGIN_MISSING {exc.plugin_name}", file=sys.stderr)
40
+ return 3
41
+ return 0
42
+
43
+ if args.command == "probe":
44
+ import json as _json
45
+
46
+ from .scan import probe_bundle
47
+
48
+ print(_json.dumps(probe_bundle(args.path)))
49
+ return 0
50
+
51
+ if args.command == "scan":
52
+ import json as _json
53
+
54
+ from .scan import default_plugin_dirs, scan_paths
55
+
56
+ print(_json.dumps(scan_paths(args.dirs or default_plugin_dirs())))
57
+ return 0
58
+
59
+ if args.command == "carve":
60
+ import json as _json
61
+
62
+ from .carve import carve_file
63
+
64
+ bands = carve_file(args.voice, max_cut_db=args.max_cut_db)
65
+ print(_json.dumps({"bands": bands}))
66
+ return 0
67
+
68
+ if args.command == "serve":
69
+ from .server import serve
70
+
71
+ serve(args.port)
72
+ return 0
73
+
74
+ return 2
75
+
76
+
77
+ if __name__ == "__main__":
78
+ sys.exit(main())
@@ -0,0 +1,19 @@
1
+ """Offline WAV -> WAV render through a chain. Block-by-block, no realtime clock."""
2
+ from __future__ import annotations
3
+
4
+ from pedalboard import Pedalboard
5
+ from pedalboard.io import AudioFile
6
+
7
+ from .chain import build_chain, enabled_plugins, load_chain_spec
8
+
9
+
10
+ def bounce_file(input_wav: str, chain_json_path: str, output_wav: str, block_size: int = 8192) -> None:
11
+ with open(chain_json_path, "r", encoding="utf-8") as f:
12
+ spec = load_chain_spec(f.read())
13
+ # Disabled plugins are bypassed at render exactly as in preview.
14
+ board = Pedalboard(enabled_plugins(spec, build_chain(spec)))
15
+ with AudioFile(input_wav) as src:
16
+ with AudioFile(output_wav, "w", src.samplerate, src.num_channels) as dst:
17
+ while src.tell() < src.frames:
18
+ chunk = src.read(min(block_size, src.frames - src.tell()))
19
+ dst.write(board(chunk, src.samplerate, reset=False))
@@ -0,0 +1,90 @@
1
+ """Spectral carve: find a voiceover's dominant presence bands so a music
2
+ track can be EQ-dipped there (the "vocal pocket"). Pure numpy analysis; the
3
+ returned bands are plain pedalboard PeakFilter configs."""
4
+ from __future__ import annotations
5
+
6
+ import numpy as np
7
+
8
+ SPEECH_LO_HZ = 150.0
9
+ SPEECH_HI_HZ = 6000.0
10
+ CARVE_Q = 1.5
11
+ MIN_BANDS = 2
12
+ MAX_BANDS = 4
13
+ # Roughly third-octave centers spanning the speech-relevant range.
14
+ CANDIDATE_CENTERS_HZ = [160.0, 250.0, 400.0, 630.0, 1000.0, 1600.0, 2500.0, 4000.0, 6000.0]
15
+
16
+ _FRAME = 4096
17
+ _HOP = 2048
18
+
19
+
20
+ def _to_mono(samples: np.ndarray) -> np.ndarray:
21
+ arr = np.asarray(samples, dtype=np.float64)
22
+ if arr.ndim == 2:
23
+ # pedalboard yields (channels, frames); average channels to mono.
24
+ arr = arr.mean(axis=0)
25
+ return arr.reshape(-1)
26
+
27
+
28
+ def _power_spectrum(mono: np.ndarray, sample_rate: float) -> tuple[np.ndarray, np.ndarray]:
29
+ """Welch-style averaged power spectrum. Returns (freqs, power)."""
30
+ n = mono.shape[0]
31
+ if n < _FRAME:
32
+ mono = np.pad(mono, (0, _FRAME - n))
33
+ n = _FRAME
34
+ window = np.hanning(_FRAME)
35
+ acc = np.zeros(_FRAME // 2 + 1)
36
+ frames = 0
37
+ for start in range(0, n - _FRAME + 1, _HOP):
38
+ seg = mono[start : start + _FRAME] * window
39
+ spec = np.abs(np.fft.rfft(seg)) ** 2
40
+ acc += spec
41
+ frames += 1
42
+ if frames == 0:
43
+ acc = np.abs(np.fft.rfft(mono[:_FRAME] * window)) ** 2
44
+ frames = 1
45
+ freqs = np.fft.rfftfreq(_FRAME, d=1.0 / sample_rate)
46
+ return freqs, acc / frames
47
+
48
+
49
+ def _band_power(freqs: np.ndarray, power: np.ndarray, center: float) -> float:
50
+ lo = center / (2.0 ** (1.0 / 6.0))
51
+ hi = center * (2.0 ** (1.0 / 6.0))
52
+ mask = (freqs >= lo) & (freqs < hi)
53
+ if not np.any(mask):
54
+ return 0.0
55
+ return float(power[mask].mean())
56
+
57
+
58
+ def carve(samples: np.ndarray, sample_rate: float, max_cut_db: float = 4.0) -> list[dict]:
59
+ mono = _to_mono(samples)
60
+ freqs, power = _power_spectrum(mono, sample_rate)
61
+
62
+ centers = [c for c in CANDIDATE_CENTERS_HZ if SPEECH_LO_HZ <= c <= SPEECH_HI_HZ]
63
+ band_powers = [(c, _band_power(freqs, power, c)) for c in centers]
64
+ mean_power = np.mean([p for _, p in band_powers]) or 1.0
65
+
66
+ # Rank by how far each band exceeds the average; keep those above average
67
+ # (up to MAX_BANDS), but always return at least MIN_BANDS (the strongest).
68
+ ranked = sorted(band_powers, key=lambda cp: cp[1], reverse=True)
69
+ above = [cp for cp in ranked if cp[1] > mean_power]
70
+ selected = above[:MAX_BANDS] if len(above) >= MIN_BANDS else ranked[:MIN_BANDS]
71
+
72
+ top = max(p for _, p in selected) or 1.0
73
+ bands: list[dict] = []
74
+ for center, p in selected:
75
+ # Deepest cut on the strongest band; shallower elsewhere, floored at
76
+ # half depth so weak-but-selected bands still get meaningful room.
77
+ depth = max_cut_db * (p / top)
78
+ depth = min(max_cut_db, max(max_cut_db / 2.0, depth))
79
+ bands.append({"freq": float(center), "gainDb": float(-depth), "q": CARVE_Q})
80
+ bands.sort(key=lambda b: b["freq"])
81
+ return bands
82
+
83
+
84
+ def carve_file(voice_wav: str, max_cut_db: float = 4.0) -> list[dict]:
85
+ from pedalboard.io import AudioFile
86
+
87
+ with AudioFile(voice_wav) as f:
88
+ samples = f.read(f.frames) # (channels, frames)
89
+ sr = f.samplerate
90
+ return carve(samples, sr, max_cut_db=max_cut_db)
@@ -0,0 +1,120 @@
1
+ """Chain spec: the persisted .vstchain.json contents and live plugin construction."""
2
+ from __future__ import annotations
3
+
4
+ import base64
5
+ import json
6
+ import os
7
+ from dataclasses import dataclass
8
+
9
+ import pedalboard
10
+
11
+ VALID_FORMATS = {"vst3", "au", "builtin"}
12
+
13
+
14
+ class ChainError(ValueError):
15
+ pass
16
+
17
+
18
+ class PluginMissingError(ChainError):
19
+ def __init__(self, name: str):
20
+ super().__init__(f"Plugin not available on this machine: {name}")
21
+ self.plugin_name = name
22
+
23
+
24
+ @dataclass
25
+ class PluginSpec:
26
+ format: str
27
+ path: str
28
+ plugin_name: str | None
29
+ name: str
30
+ state_b64: str | None
31
+ # Bypass toggle — absent in the JSON means enabled (backward compatible
32
+ # with chain files written before the field existed). A disabled plugin
33
+ # is still CONSTRUCTED (so set-param/get-state indices stay aligned with
34
+ # the chain file) but excluded from the processing board — see
35
+ # `enabled_plugins`.
36
+ enabled: bool = True
37
+
38
+
39
+ @dataclass
40
+ class ChainSpec:
41
+ version: int
42
+ plugins: list[PluginSpec]
43
+
44
+
45
+ def load_chain_spec(json_text: str) -> ChainSpec:
46
+ raw = json.loads(json_text)
47
+ if raw.get("version") != 1:
48
+ raise ChainError(f"Unsupported chain version: {raw.get('version')}")
49
+ plugins = []
50
+ for p in raw.get("plugins", []):
51
+ fmt = p.get("format")
52
+ if fmt not in VALID_FORMATS:
53
+ raise ChainError(f"Unknown plugin format: {fmt}")
54
+ plugins.append(
55
+ PluginSpec(
56
+ format=fmt,
57
+ path=p["path"],
58
+ plugin_name=p.get("pluginName"),
59
+ name=p.get("name", p["path"]),
60
+ state_b64=p.get("stateB64"),
61
+ enabled=p.get("enabled", True) is not False,
62
+ )
63
+ )
64
+ return ChainSpec(version=1, plugins=plugins)
65
+
66
+
67
+ def _build_builtin(spec: PluginSpec):
68
+ cls = getattr(pedalboard, spec.path, None)
69
+ if cls is None:
70
+ raise PluginMissingError(spec.name)
71
+ plugin = cls()
72
+ if spec.state_b64:
73
+ params = json.loads(base64.b64decode(spec.state_b64))
74
+ for key, value in params.items():
75
+ setattr(plugin, key, value)
76
+ return plugin
77
+
78
+
79
+ def _build_external(spec: PluginSpec):
80
+ if not os.path.exists(spec.path):
81
+ raise PluginMissingError(spec.name)
82
+ try:
83
+ plugin = pedalboard.load_plugin(spec.path, plugin_name=spec.plugin_name)
84
+ except Exception as exc:
85
+ raise PluginMissingError(spec.name) from exc
86
+ if spec.state_b64:
87
+ plugin.raw_state = base64.b64decode(spec.state_b64)
88
+ return plugin
89
+
90
+
91
+ def build_chain(spec: ChainSpec) -> list:
92
+ return [_build_builtin(p) if p.format == "builtin" else _build_external(p) for p in spec.plugins]
93
+
94
+
95
+ def enabled_plugins(spec: ChainSpec, built: list) -> list:
96
+ """The subset of `built` (from `build_chain(spec)`, same order) that should
97
+ actually process audio. Disabled plugins are constructed but bypassed —
98
+ keeping the full list's indices aligned with the chain file for
99
+ set-param/get-state while the processing board skips them. An all-disabled
100
+ chain yields an empty board, which pedalboard treats as a passthrough."""
101
+ return [plugin for plugin, p in zip(built, spec.plugins) if p.enabled]
102
+
103
+
104
+ def _is_builtin(plugin) -> bool:
105
+ return not hasattr(plugin, "raw_state")
106
+
107
+
108
+ def serialize_states(plugins: list) -> list[str]:
109
+ states = []
110
+ for plugin in plugins:
111
+ if _is_builtin(plugin):
112
+ params = {
113
+ key: float(getattr(plugin, key))
114
+ for key in dir(plugin)
115
+ if not key.startswith("_") and isinstance(getattr(plugin, key), float)
116
+ }
117
+ states.append(base64.b64encode(json.dumps(params).encode()).decode())
118
+ else:
119
+ states.append(base64.b64encode(plugin.raw_state).decode())
120
+ return states
@@ -0,0 +1,88 @@
1
+ """Plugin discovery. Every bundle probe runs in a throwaway subprocess:
2
+ a malformed bundle can SIGBUS the probing process (observed with macOS
3
+ CoreAudio.component), so the sidecar process itself never loads candidates."""
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import subprocess
9
+ import sys
10
+
11
+ BUNDLE_EXTENSIONS = {".vst3": "vst3", ".component": "au"}
12
+ PROBE_TIMEOUT_SEC = 10
13
+
14
+ # pedalboard's built-in effects that host cleanly with no constructor args and
15
+ # no external plugin file — the reliable, always-available options (unlike
16
+ # third-party VST3/AU, which pedalboard's headless host can't run for a real
17
+ # subset; see the stability guard). Each maps to a `pedalboard.<name>` class in
18
+ # chain.py's `_build_builtin` (format "builtin", path = the class name).
19
+ # Ordered most-useful-first for the FX picker. Convolution and IIRFilter are
20
+ # excluded: they require constructor arguments (an impulse response / filter
21
+ # coefficients) this add flow can't supply.
22
+ BUILTIN_EFFECTS = [
23
+ "Reverb", "Delay", "Chorus", "Phaser", "Distortion", "Compressor",
24
+ "Limiter", "NoiseGate", "Gain", "PitchShift", "Bitcrush", "Clipping",
25
+ "LadderFilter", "LowpassFilter", "HighpassFilter", "PeakFilter",
26
+ "LowShelfFilter", "HighShelfFilter", "MP3Compressor", "Invert",
27
+ ]
28
+
29
+
30
+ def builtin_registry() -> list[dict]:
31
+ """The built-in effects, in FX-picker registry form. `pluginName` is None
32
+ (builtins take no sub-name), `path` is the pedalboard class name."""
33
+ return [{"path": name, "name": name, "format": "builtin"} for name in BUILTIN_EFFECTS]
34
+
35
+
36
+ def default_plugin_dirs() -> list[str]:
37
+ home = os.path.expanduser("~")
38
+ if sys.platform == "darwin":
39
+ return [
40
+ "/Library/Audio/Plug-Ins/VST3",
41
+ f"{home}/Library/Audio/Plug-Ins/VST3",
42
+ "/Library/Audio/Plug-Ins/Components",
43
+ f"{home}/Library/Audio/Plug-Ins/Components",
44
+ ]
45
+ if sys.platform == "win32":
46
+ return [r"C:\Program Files\Common Files\VST3"]
47
+ return []
48
+
49
+
50
+ def _probe(bundle: str, probe_cmd: list[str]) -> list[dict]:
51
+ try:
52
+ proc = subprocess.run(
53
+ probe_cmd + [bundle], capture_output=True, text=True, timeout=PROBE_TIMEOUT_SEC
54
+ )
55
+ except subprocess.TimeoutExpired:
56
+ return []
57
+ if proc.returncode != 0:
58
+ return []
59
+ try:
60
+ return json.loads(proc.stdout)
61
+ except json.JSONDecodeError:
62
+ return []
63
+
64
+
65
+ def scan_paths(dirs: list[str], probe_cmd: list[str] | None = None) -> list[dict]:
66
+ cmd = probe_cmd or [sys.executable, "-m", "hyperframes_vst", "probe"]
67
+ registry: list[dict] = []
68
+ for d in dirs:
69
+ if not os.path.isdir(d):
70
+ continue
71
+ for entry in sorted(os.listdir(d)):
72
+ _, ext = os.path.splitext(entry)
73
+ fmt = BUNDLE_EXTENSIONS.get(ext.lower())
74
+ if fmt is None:
75
+ continue
76
+ bundle = os.path.join(d, entry)
77
+ for item in _probe(bundle, cmd):
78
+ registry.append({"path": bundle, "name": item["name"], "format": fmt})
79
+ return registry
80
+
81
+
82
+ def probe_bundle(path: str) -> list[dict]:
83
+ """Runs IN the throwaway subprocess. May crash; caller tolerates."""
84
+ from pedalboard._pedalboard import AudioUnitPlugin, VST3Plugin
85
+
86
+ cls = AudioUnitPlugin if path.lower().endswith(".component") else VST3Plugin
87
+ names = cls.get_plugin_names_for_file(path)
88
+ return [{"name": n} for n in names]