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.
- hyperframes_vst/__init__.py +0 -0
- hyperframes_vst/__main__.py +78 -0
- hyperframes_vst/bounce.py +19 -0
- hyperframes_vst/carve.py +90 -0
- hyperframes_vst/chain.py +120 -0
- hyperframes_vst/scan.py +88 -0
- hyperframes_vst/server.py +417 -0
- hyperframes_vst/stream.py +101 -0
- hyperframes_vst_host-0.1.0.dist-info/METADATA +14 -0
- hyperframes_vst_host-0.1.0.dist-info/RECORD +13 -0
- hyperframes_vst_host-0.1.0.dist-info/WHEEL +4 -0
- hyperframes_vst_host-0.1.0.dist-info/entry_points.txt +2 -0
- hyperframes_vst_host-0.1.0.dist-info/licenses/LICENSE +675 -0
|
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))
|
hyperframes_vst/carve.py
ADDED
|
@@ -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)
|
hyperframes_vst/chain.py
ADDED
|
@@ -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
|
hyperframes_vst/scan.py
ADDED
|
@@ -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]
|