pycodedj 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.
- pycodedj/__init__.py +3 -0
- pycodedj/__main__.py +130 -0
- pycodedj/analyzer.py +65 -0
- pycodedj/block_parser.py +45 -0
- pycodedj/engine.py +36 -0
- pycodedj/examples/club_set.py +71 -0
- pycodedj/examples/demo.py +23 -0
- pycodedj/examples/hello_sc.py +29 -0
- pycodedj/mapper.py +46 -0
- pycodedj/osc_bridge.py +51 -0
- pycodedj/sc/synths.scd +151 -0
- pycodedj/watcher.py +108 -0
- pycodedj-0.1.0.dist-info/METADATA +11 -0
- pycodedj-0.1.0.dist-info/RECORD +16 -0
- pycodedj-0.1.0.dist-info/WHEEL +4 -0
- pycodedj-0.1.0.dist-info/entry_points.txt +2 -0
pycodedj/__init__.py
ADDED
pycodedj/__main__.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .block_parser import parse_blocks
|
|
8
|
+
from .engine import Engine
|
|
9
|
+
from .osc_bridge import OscBridge, OscEndpoint, OscError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
13
|
+
parser = argparse.ArgumentParser(prog="pycodedj")
|
|
14
|
+
sub = parser.add_subparsers(dest="command")
|
|
15
|
+
|
|
16
|
+
eval_p = sub.add_parser("eval", help="Evaluate a loop block and send OSC parameters")
|
|
17
|
+
eval_p.add_argument(
|
|
18
|
+
"target",
|
|
19
|
+
metavar="FILE::LOOP",
|
|
20
|
+
help="Path to source file and loop name, separated by ::",
|
|
21
|
+
)
|
|
22
|
+
eval_p.add_argument("--sc-host", default="127.0.0.1", help="SuperCollider host")
|
|
23
|
+
eval_p.add_argument("--sc-port", default=57120, type=int, help="SuperCollider port")
|
|
24
|
+
|
|
25
|
+
watch_p = sub.add_parser("watch", help="Watch a file and re-eval all loops on save")
|
|
26
|
+
watch_p.add_argument("file", metavar="FILE", help="Source file to watch")
|
|
27
|
+
watch_p.add_argument("--sc-host", default="127.0.0.1", help="SuperCollider host")
|
|
28
|
+
watch_p.add_argument("--sc-port", default=57120, type=int, help="SuperCollider port")
|
|
29
|
+
watch_p.add_argument(
|
|
30
|
+
"--debounce", default=0.3, type=float,
|
|
31
|
+
metavar="SECS", help="Debounce interval in seconds (default: 0.3)",
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
return parser
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _print_params(loop_name: str, params: object) -> None:
|
|
38
|
+
from .mapper import MusicParams
|
|
39
|
+
if not isinstance(params, MusicParams):
|
|
40
|
+
return
|
|
41
|
+
print(
|
|
42
|
+
f"[pycodedj] {loop_name}"
|
|
43
|
+
f" cutoff={params.cutoff:.0f}Hz"
|
|
44
|
+
f" lfo={params.lfo_rate:.2f}Hz"
|
|
45
|
+
f" reverb={params.reverb_mix:.2f}"
|
|
46
|
+
f" voices={params.voice_count}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _cmd_eval(args: argparse.Namespace) -> int:
|
|
51
|
+
if "::" not in args.target:
|
|
52
|
+
sys.stderr.write(f"[pycodedj] invalid target format (expected FILE::LOOP): {args.target}\n")
|
|
53
|
+
return 1
|
|
54
|
+
|
|
55
|
+
file_path, loop_name = args.target.split("::", 1)
|
|
56
|
+
path = Path(file_path)
|
|
57
|
+
|
|
58
|
+
if not path.exists():
|
|
59
|
+
sys.stderr.write(f"[pycodedj] file not found: {file_path}\n")
|
|
60
|
+
return 1
|
|
61
|
+
|
|
62
|
+
source = path.read_text(encoding="utf-8")
|
|
63
|
+
blocks = parse_blocks(source)
|
|
64
|
+
block_map = {b.name: b for b in blocks}
|
|
65
|
+
|
|
66
|
+
if loop_name not in block_map:
|
|
67
|
+
available = ", ".join(block_map.keys()) or "(none)"
|
|
68
|
+
sys.stderr.write(
|
|
69
|
+
f"[pycodedj] loop '{loop_name}' not found in {file_path}. "
|
|
70
|
+
f"Available: {available}\n"
|
|
71
|
+
)
|
|
72
|
+
return 1
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
bridge = OscBridge(audio=OscEndpoint(host=args.sc_host, port=args.sc_port))
|
|
76
|
+
engine = Engine(bridge=bridge)
|
|
77
|
+
params = engine.eval_block(block_map[loop_name])
|
|
78
|
+
except OscError as e:
|
|
79
|
+
sys.stderr.write(f"[pycodedj] OSC error: {e}\n")
|
|
80
|
+
return 1
|
|
81
|
+
|
|
82
|
+
if params is not None:
|
|
83
|
+
_print_params(loop_name, params)
|
|
84
|
+
return 0
|
|
85
|
+
return 1
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _cmd_watch(args: argparse.Namespace) -> int:
|
|
89
|
+
path = Path(args.file)
|
|
90
|
+
if not path.exists():
|
|
91
|
+
sys.stderr.write(f"[pycodedj] file not found: {args.file}\n")
|
|
92
|
+
return 1
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
from .watcher import watch
|
|
96
|
+
except ImportError as e:
|
|
97
|
+
sys.stderr.write(f"[pycodedj] {e}\n")
|
|
98
|
+
return 1
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
bridge = OscBridge(audio=OscEndpoint(host=args.sc_host, port=args.sc_port))
|
|
102
|
+
except OscError as e:
|
|
103
|
+
sys.stderr.write(f"[pycodedj] OSC error: {e}\n")
|
|
104
|
+
return 1
|
|
105
|
+
|
|
106
|
+
engine = Engine(bridge=bridge)
|
|
107
|
+
|
|
108
|
+
def _on_eval(file_path: str, loop_count: int) -> None:
|
|
109
|
+
print(f"[pycodedj] reloaded {Path(file_path).name} ({loop_count} loop(s))")
|
|
110
|
+
|
|
111
|
+
print(f"[pycodedj] watching {path} — save to reload (Ctrl+C to stop)")
|
|
112
|
+
watch(str(path), engine, debounce=args.debounce, on_eval=_on_eval)
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def main() -> None:
|
|
117
|
+
parser = _build_parser()
|
|
118
|
+
args = parser.parse_args()
|
|
119
|
+
|
|
120
|
+
if args.command == "eval":
|
|
121
|
+
sys.exit(_cmd_eval(args))
|
|
122
|
+
elif args.command == "watch":
|
|
123
|
+
sys.exit(_cmd_watch(args))
|
|
124
|
+
else:
|
|
125
|
+
parser.print_help()
|
|
126
|
+
sys.exit(1)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
main()
|
pycodedj/analyzer.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import io
|
|
5
|
+
import tokenize
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class CodeFeatures:
|
|
11
|
+
max_depth: int
|
|
12
|
+
control_flow_count: int
|
|
13
|
+
function_count: int
|
|
14
|
+
comment_ratio: float
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ブロック構造ノードだけをネスト深さとしてカウントする。
|
|
18
|
+
# BinOp・Call など式ノードは含めない(式の複雑さではなく制御構造の深さを測る)。
|
|
19
|
+
_BLOCK_NODES = (
|
|
20
|
+
ast.If, ast.For, ast.While, ast.With,
|
|
21
|
+
ast.Try, ast.FunctionDef, ast.AsyncFunctionDef,
|
|
22
|
+
ast.ClassDef, ast.ExceptHandler,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _ast_depth(node: ast.AST, current: int = 0) -> int:
|
|
27
|
+
children = list(ast.iter_child_nodes(node))
|
|
28
|
+
if not children:
|
|
29
|
+
return current
|
|
30
|
+
next_depth = current + 1 if isinstance(node, _BLOCK_NODES) else current
|
|
31
|
+
return max(_ast_depth(child, next_depth) for child in children)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _count_nodes(tree: ast.AST, *types: type) -> int:
|
|
35
|
+
return sum(isinstance(node, types) for node in ast.walk(tree))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _comment_ratio(source: str) -> float:
|
|
39
|
+
tokens = tokenize.generate_tokens(io.StringIO(source).readline)
|
|
40
|
+
comment_count = 0
|
|
41
|
+
try:
|
|
42
|
+
for tok in tokens:
|
|
43
|
+
if tok.type == tokenize.COMMENT:
|
|
44
|
+
comment_count += 1
|
|
45
|
+
except tokenize.TokenError:
|
|
46
|
+
pass
|
|
47
|
+
|
|
48
|
+
total_non_blank = sum(1 for line in source.splitlines() if line.strip())
|
|
49
|
+
if total_non_blank == 0:
|
|
50
|
+
return 0.0
|
|
51
|
+
return comment_count / total_non_blank
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def analyze(source: str) -> CodeFeatures:
|
|
55
|
+
try:
|
|
56
|
+
tree = ast.parse(source)
|
|
57
|
+
except SyntaxError:
|
|
58
|
+
raise
|
|
59
|
+
|
|
60
|
+
return CodeFeatures(
|
|
61
|
+
max_depth=_ast_depth(tree),
|
|
62
|
+
control_flow_count=_count_nodes(tree, ast.If, ast.For, ast.While),
|
|
63
|
+
function_count=_count_nodes(tree, ast.FunctionDef, ast.AsyncFunctionDef),
|
|
64
|
+
comment_ratio=_comment_ratio(source),
|
|
65
|
+
)
|
pycodedj/block_parser.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
_MARKER_RE = re.compile(r"^#\s*@loop\s+(\w+)(?:\s+interval=([\d.]+))?")
|
|
7
|
+
_DEFAULT_INTERVAL = 1.0
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class LoopBlock:
|
|
12
|
+
name: str
|
|
13
|
+
interval: float
|
|
14
|
+
source: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def parse_blocks(source: str) -> list[LoopBlock]:
|
|
18
|
+
blocks: list[LoopBlock] = []
|
|
19
|
+
current_name: str | None = None
|
|
20
|
+
current_interval: float = _DEFAULT_INTERVAL
|
|
21
|
+
current_lines: list[str] = []
|
|
22
|
+
|
|
23
|
+
for line in source.splitlines(keepends=True):
|
|
24
|
+
m = _MARKER_RE.match(line)
|
|
25
|
+
if m:
|
|
26
|
+
if current_name is not None:
|
|
27
|
+
blocks.append(LoopBlock(
|
|
28
|
+
name=current_name,
|
|
29
|
+
interval=current_interval,
|
|
30
|
+
source="".join(current_lines),
|
|
31
|
+
))
|
|
32
|
+
current_name = m.group(1)
|
|
33
|
+
current_interval = float(m.group(2)) if m.group(2) else _DEFAULT_INTERVAL
|
|
34
|
+
current_lines = []
|
|
35
|
+
elif current_name is not None:
|
|
36
|
+
current_lines.append(line)
|
|
37
|
+
|
|
38
|
+
if current_name is not None:
|
|
39
|
+
blocks.append(LoopBlock(
|
|
40
|
+
name=current_name,
|
|
41
|
+
interval=current_interval,
|
|
42
|
+
source="".join(current_lines),
|
|
43
|
+
))
|
|
44
|
+
|
|
45
|
+
return blocks
|
pycodedj/engine.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
|
|
6
|
+
from .analyzer import analyze
|
|
7
|
+
from .block_parser import LoopBlock
|
|
8
|
+
from .mapper import MusicParams, map_features
|
|
9
|
+
from .osc_bridge import OscBridge, OscError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Engine:
|
|
14
|
+
bridge: OscBridge
|
|
15
|
+
_loops: dict[str, MusicParams] = field(default_factory=dict, init=False, repr=False)
|
|
16
|
+
|
|
17
|
+
def eval_block(self, block: LoopBlock) -> MusicParams | None:
|
|
18
|
+
try:
|
|
19
|
+
features = analyze(block.source)
|
|
20
|
+
params = map_features(features)
|
|
21
|
+
self.bridge.send_params(block.name, params)
|
|
22
|
+
self._loops[block.name] = params
|
|
23
|
+
return params
|
|
24
|
+
except SyntaxError as e:
|
|
25
|
+
sys.stderr.write(f"[pycodedj] syntax error ({block.name}): {e}\n")
|
|
26
|
+
except OscError as e:
|
|
27
|
+
sys.stderr.write(f"[pycodedj] OSC send failed ({block.name}): {e}\n")
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
def stop_loop(self, name: str) -> None:
|
|
31
|
+
self._loops.pop(name, None)
|
|
32
|
+
stop_params = MusicParams(cutoff=200.0, lfo_rate=0.1, reverb_mix=0.0, voice_count=0)
|
|
33
|
+
self.bridge.send_params(name, stop_params)
|
|
34
|
+
|
|
35
|
+
def list_loops(self) -> list[str]:
|
|
36
|
+
return list(self._loops.keys())
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# PyCodeDJ club-style demo.
|
|
2
|
+
#
|
|
3
|
+
# Load sc/synths.scd in SuperCollider, then bring parts in and out:
|
|
4
|
+
# pycodedj eval examples/club_set.py::sub_bass
|
|
5
|
+
# pycodedj eval examples/club_set.py::hat_engine
|
|
6
|
+
# pycodedj eval examples/club_set.py::neon_stab
|
|
7
|
+
# pycodedj eval examples/club_set.py::acid_lead
|
|
8
|
+
# pycodedj eval examples/club_set.py::warehouse_air
|
|
9
|
+
#
|
|
10
|
+
# These blocks are meant as code-structure performance material. Edit and save
|
|
11
|
+
# them live, then re-run eval for a loop to push a new sound shape.
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# @loop sub_bass interval=1.0
|
|
15
|
+
def sub_bass():
|
|
16
|
+
pulse = [1, 0, 0, 1, 0, 1, 0, 0]
|
|
17
|
+
for step in range(8):
|
|
18
|
+
if pulse[step]:
|
|
19
|
+
if step in (0, 5):
|
|
20
|
+
drive = "heavy"
|
|
21
|
+
else:
|
|
22
|
+
drive = "tight"
|
|
23
|
+
_ = drive
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# @loop hat_engine interval=0.25
|
|
27
|
+
def hat_engine():
|
|
28
|
+
grid = range(16)
|
|
29
|
+
for tick in grid:
|
|
30
|
+
if tick % 2 == 0:
|
|
31
|
+
accent = "closed"
|
|
32
|
+
if tick in (3, 7, 11, 15):
|
|
33
|
+
accent = "open"
|
|
34
|
+
_ = accent
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# @loop neon_stab interval=2.0
|
|
38
|
+
def neon_stab():
|
|
39
|
+
def chord_root():
|
|
40
|
+
return "minor"
|
|
41
|
+
|
|
42
|
+
def chord_fifth():
|
|
43
|
+
return "pressure"
|
|
44
|
+
|
|
45
|
+
def chord_seventh():
|
|
46
|
+
return "glow"
|
|
47
|
+
|
|
48
|
+
return chord_root(), chord_fifth(), chord_seventh()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# @loop acid_lead interval=0.5
|
|
52
|
+
def acid_lead():
|
|
53
|
+
pattern = [0, 3, 7, 10, 12, 10, 7, 3]
|
|
54
|
+
for note in pattern:
|
|
55
|
+
if note > 9:
|
|
56
|
+
for slide in range(2):
|
|
57
|
+
if slide == 1:
|
|
58
|
+
bend = "up"
|
|
59
|
+
else:
|
|
60
|
+
bend = "down"
|
|
61
|
+
_ = bend
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# @loop warehouse_air interval=4.0
|
|
65
|
+
# smoke above the kick
|
|
66
|
+
# late reflections
|
|
67
|
+
# concrete room tail
|
|
68
|
+
# crowd heat
|
|
69
|
+
# blue strobes
|
|
70
|
+
def warehouse_air():
|
|
71
|
+
pass
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# PyCodeDJ demo — run each block independently:
|
|
2
|
+
# pycodedj eval examples/demo.py::bass
|
|
3
|
+
# pycodedj eval examples/demo.py::melody
|
|
4
|
+
# pycodedj eval examples/demo.py::pad
|
|
5
|
+
|
|
6
|
+
# @loop bass interval=2.0
|
|
7
|
+
def bass():
|
|
8
|
+
for i in range(8):
|
|
9
|
+
if i % 2 == 0:
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
# @loop melody interval=0.5
|
|
13
|
+
def melody():
|
|
14
|
+
x = 1
|
|
15
|
+
y = 2
|
|
16
|
+
return x + y
|
|
17
|
+
|
|
18
|
+
# @loop pad interval=4.0
|
|
19
|
+
# ここに余白を置く
|
|
20
|
+
# もう少し置く
|
|
21
|
+
# 静寂も音楽
|
|
22
|
+
def pad():
|
|
23
|
+
pass
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Single OSC ping to verify SuperCollider connectivity.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
python examples/hello_sc.py
|
|
5
|
+
python examples/hello_sc.py --host 127.0.0.1 --port 57120
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
|
|
12
|
+
from pycodedj.mapper import MusicParams
|
|
13
|
+
from pycodedj.osc_bridge import OscBridge, OscEndpoint
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> None:
|
|
17
|
+
parser = argparse.ArgumentParser()
|
|
18
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
19
|
+
parser.add_argument("--port", default=57120, type=int)
|
|
20
|
+
args = parser.parse_args()
|
|
21
|
+
|
|
22
|
+
bridge = OscBridge(audio=OscEndpoint(host=args.host, port=args.port))
|
|
23
|
+
params = MusicParams(cutoff=1000.0, lfo_rate=0.5, reverb_mix=0.2, voice_count=1)
|
|
24
|
+
bridge.send_params("hello", params)
|
|
25
|
+
print(f"Sent OSC to {args.host}:{args.port} — loop 'hello'")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
main()
|
pycodedj/mapper.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from .analyzer import CodeFeatures
|
|
6
|
+
|
|
7
|
+
_CUTOFF_MIN = 200.0
|
|
8
|
+
_CUTOFF_MAX = 4000.0
|
|
9
|
+
_CUTOFF_DEPTH_MAX = 10
|
|
10
|
+
|
|
11
|
+
_LFO_MIN = 0.1
|
|
12
|
+
_LFO_MAX = 5.0
|
|
13
|
+
_LFO_COUNT_MAX = 10
|
|
14
|
+
|
|
15
|
+
_REVERB_MIN = 0.0
|
|
16
|
+
_REVERB_MAX = 0.8
|
|
17
|
+
|
|
18
|
+
_VOICE_MIN = 1
|
|
19
|
+
_VOICE_MAX = 4
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class MusicParams:
|
|
24
|
+
cutoff: float
|
|
25
|
+
lfo_rate: float
|
|
26
|
+
reverb_mix: float
|
|
27
|
+
voice_count: int
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _lerp(value: float, in_max: float, out_min: float, out_max: float) -> float:
|
|
31
|
+
t = min(max(value / in_max, 0.0), 1.0)
|
|
32
|
+
return out_min + t * (out_max - out_min)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def map_features(features: CodeFeatures) -> MusicParams:
|
|
36
|
+
cutoff = _lerp(features.max_depth, _CUTOFF_DEPTH_MAX, _CUTOFF_MIN, _CUTOFF_MAX)
|
|
37
|
+
lfo_rate = _lerp(features.control_flow_count, _LFO_COUNT_MAX, _LFO_MIN, _LFO_MAX)
|
|
38
|
+
reverb_mix = _lerp(features.comment_ratio, 1.0, _REVERB_MIN, _REVERB_MAX)
|
|
39
|
+
voice_count = min(max(features.function_count, _VOICE_MIN), _VOICE_MAX)
|
|
40
|
+
|
|
41
|
+
return MusicParams(
|
|
42
|
+
cutoff=cutoff,
|
|
43
|
+
lfo_rate=lfo_rate,
|
|
44
|
+
reverb_mix=reverb_mix,
|
|
45
|
+
voice_count=voice_count,
|
|
46
|
+
)
|
pycodedj/osc_bridge.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from pythonosc import udp_client
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .mapper import MusicParams
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class OscError(Exception):
|
|
13
|
+
"""OSC クライアントの初期化・送信失敗を表す例外。"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class OscEndpoint:
|
|
18
|
+
host: str
|
|
19
|
+
port: int
|
|
20
|
+
_client: udp_client.SimpleUDPClient = field(init=False, repr=False)
|
|
21
|
+
|
|
22
|
+
def __post_init__(self) -> None:
|
|
23
|
+
try:
|
|
24
|
+
self._client = udp_client.SimpleUDPClient(self.host, self.port)
|
|
25
|
+
except (OSError, AttributeError) as e:
|
|
26
|
+
raise OscError(f"failed to create OSC client ({self.host}:{self.port}): {e}") from e
|
|
27
|
+
|
|
28
|
+
def send(self, address: str, *args: object) -> None:
|
|
29
|
+
try:
|
|
30
|
+
self._client.send_message(address, list(args))
|
|
31
|
+
except (OSError, AttributeError) as e:
|
|
32
|
+
raise OscError(f"failed to send OSC message to {address}: {e}") from e
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class OscBridge:
|
|
37
|
+
audio: OscEndpoint
|
|
38
|
+
visual: OscEndpoint | None = None
|
|
39
|
+
|
|
40
|
+
def send_params(self, name: str, params: "MusicParams") -> None:
|
|
41
|
+
base = f"/pycodedj/loop/{name}"
|
|
42
|
+
# voice_count を先に送り SC 側でシンセを起動してから各パラメーターを適用させる
|
|
43
|
+
self.audio.send(f"{base}/voice_count", params.voice_count)
|
|
44
|
+
self.audio.send(f"{base}/cutoff", params.cutoff)
|
|
45
|
+
self.audio.send(f"{base}/lfo_rate", params.lfo_rate)
|
|
46
|
+
self.audio.send(f"{base}/reverb", params.reverb_mix)
|
|
47
|
+
if self.visual is not None:
|
|
48
|
+
self.visual.send(f"{base}/voice_count", params.voice_count)
|
|
49
|
+
self.visual.send(f"{base}/cutoff", params.cutoff)
|
|
50
|
+
self.visual.send(f"{base}/lfo_rate", params.lfo_rate)
|
|
51
|
+
self.visual.send(f"{base}/reverb", params.reverb_mix)
|
pycodedj/sc/synths.scd
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// PyCodeDJ — SuperCollider synth definitions
|
|
2
|
+
// Run this file in the SuperCollider IDE before using pycodedj eval.
|
|
3
|
+
|
|
4
|
+
s.waitForBoot({
|
|
5
|
+
// Shared controls:
|
|
6
|
+
// cutoff = tone/brightness, lfoRate = pattern/modulation speed,
|
|
7
|
+
// reverbMix = space, gate = fade out when a loop is restarted/stopped.
|
|
8
|
+
SynthDef(\pycodedj_sub_bass, {
|
|
9
|
+
arg cutoff = 900, lfoRate = 1.0, reverbMix = 0.05, amp = 0.38, gate = 1;
|
|
10
|
+
var trig, freq, env, click, sig;
|
|
11
|
+
trig = Impulse.kr(lfoRate.clip(0.25, 4.0));
|
|
12
|
+
freq = Demand.kr(trig, 0, Dseq([36, 36, 43, 34, 36, 48, 43, 34].midicps, inf));
|
|
13
|
+
env = Decay2.kr(trig, 0.004, 0.32);
|
|
14
|
+
click = Decay2.kr(trig, 0.001, 0.018) * WhiteNoise.ar(0.08);
|
|
15
|
+
sig = (SinOsc.ar(freq) + (Saw.ar(freq * 0.5) * 0.28) + click) * env;
|
|
16
|
+
sig = RLPF.ar(sig.tanh, cutoff.clip(90, 1800), 0.28);
|
|
17
|
+
env = EnvGen.kr(Env.asr(0.1, 1, 0.2), gate, doneAction: 2);
|
|
18
|
+
sig = FreeVerb.ar(sig, reverbMix.clip(0, 0.8)) * env * amp;
|
|
19
|
+
Out.ar(0, sig ! 2);
|
|
20
|
+
}).add;
|
|
21
|
+
|
|
22
|
+
SynthDef(\pycodedj_hat_engine, {
|
|
23
|
+
arg cutoff = 6500, lfoRate = 1.5, reverbMix = 0.08, amp = 0.18, gate = 1;
|
|
24
|
+
var trig, openTrig, closedEnv, openEnv, tone, sig, env;
|
|
25
|
+
trig = Impulse.kr((lfoRate * 4).clip(3, 18));
|
|
26
|
+
openTrig = PulseDivider.kr(trig, 4);
|
|
27
|
+
closedEnv = Decay2.kr(trig, 0.001, 0.045);
|
|
28
|
+
openEnv = Decay2.kr(openTrig, 0.002, 0.18);
|
|
29
|
+
tone = cutoff.linlin(200, 4000, 5000, 14000).clip(5000, 14000);
|
|
30
|
+
sig = HPF.ar(WhiteNoise.ar, tone * 0.7);
|
|
31
|
+
sig = BPF.ar(sig, tone, 0.45) * (closedEnv + (openEnv * 0.75));
|
|
32
|
+
env = EnvGen.kr(Env.asr(0.01, 1, 0.08), gate, doneAction: 2);
|
|
33
|
+
sig = FreeVerb.ar(sig, reverbMix.clip(0, 0.45), 0.25, 0.15) * env * amp;
|
|
34
|
+
Out.ar(0, Pan2.ar(sig, SinOsc.kr(0.13).range(-0.25, 0.25)));
|
|
35
|
+
}).add;
|
|
36
|
+
|
|
37
|
+
SynthDef(\pycodedj_neon_stab, {
|
|
38
|
+
arg cutoff = 1200, lfoRate = 0.25, reverbMix = 0.25, amp = 0.22, gate = 1;
|
|
39
|
+
var trig, root, freqs, env, sig, gateEnv;
|
|
40
|
+
trig = Impulse.kr((lfoRate * 0.75).clip(0.08, 1.5));
|
|
41
|
+
root = Demand.kr(trig, 0, Dseq([48, 43, 46, 41], inf));
|
|
42
|
+
freqs = (root + [0, 3, 7, 10]).midicps;
|
|
43
|
+
env = Decay2.kr(trig, 0.01, 0.42);
|
|
44
|
+
sig = Splay.ar(VarSaw.ar(freqs * [1, 1.004, 0.997, 1.006], 0, 0.42)) * 0.2;
|
|
45
|
+
sig = RLPF.ar(sig, cutoff.clip(600, 4500), 0.32) * env;
|
|
46
|
+
gateEnv = EnvGen.kr(Env.asr(0.05, 1, 0.25), gate, doneAction: 2);
|
|
47
|
+
sig = FreeVerb.ar(sig, reverbMix.clip(0, 0.8), 0.55, 0.35) * gateEnv * amp;
|
|
48
|
+
Out.ar(0, sig);
|
|
49
|
+
}).add;
|
|
50
|
+
|
|
51
|
+
SynthDef(\pycodedj_acid_lead, {
|
|
52
|
+
arg cutoff = 2100, lfoRate = 2.0, reverbMix = 0.12, amp = 0.22, gate = 1;
|
|
53
|
+
var trig, note, freq, env, filterEnv, sig, gateEnv;
|
|
54
|
+
trig = Impulse.kr((lfoRate * 3).clip(2, 14));
|
|
55
|
+
note = Demand.kr(trig, 0, Dseq([48, 51, 55, 58, 60, 58, 55, 51], inf));
|
|
56
|
+
freq = Lag.kr(note.midicps, 0.045);
|
|
57
|
+
env = Decay2.kr(trig, 0.003, 0.16);
|
|
58
|
+
filterEnv = Decay2.kr(trig, 0.002, 0.12).range(0, 2600);
|
|
59
|
+
sig = (Pulse.ar(freq, 0.48) * 0.65) + (Saw.ar(freq * 2) * 0.18);
|
|
60
|
+
sig = RLPF.ar(sig, (cutoff + filterEnv).clip(500, 6200), 0.16).tanh * env;
|
|
61
|
+
gateEnv = EnvGen.kr(Env.asr(0.02, 1, 0.12), gate, doneAction: 2);
|
|
62
|
+
sig = FreeVerb.ar(sig, reverbMix.clip(0, 0.55), 0.35, 0.22) * gateEnv * amp;
|
|
63
|
+
Out.ar(0, Pan2.ar(sig, SinOsc.kr(0.21).range(-0.35, 0.35)));
|
|
64
|
+
}).add;
|
|
65
|
+
|
|
66
|
+
SynthDef(\pycodedj_warehouse_air, {
|
|
67
|
+
arg cutoff = 700, lfoRate = 0.1, reverbMix = 0.6, amp = 0.24, gate = 1;
|
|
68
|
+
var sweep, dust, tail, sig, env;
|
|
69
|
+
sweep = LFNoise1.kr(lfoRate.clip(0.03, 0.8)).range(0, 1).exprange(180, cutoff.clip(350, 3600));
|
|
70
|
+
dust = Decay2.kr(Dust.kr((lfoRate * 5).clip(0.5, 7)), 0.02, 1.2) * PinkNoise.ar(0.08);
|
|
71
|
+
tail = BPF.ar(PinkNoise.ar(0.22), sweep, 0.2);
|
|
72
|
+
sig = HPF.ar(tail + dust, 140);
|
|
73
|
+
sig = FreeVerb.ar(sig, reverbMix.clip(0.25, 0.9), 0.92, 0.65);
|
|
74
|
+
env = EnvGen.kr(Env.asr(1.5, 1, 2.0), gate, doneAction: 2);
|
|
75
|
+
Out.ar(0, Pan2.ar(sig * env * amp, LFNoise1.kr(0.08)));
|
|
76
|
+
}).add;
|
|
77
|
+
|
|
78
|
+
SynthDef(\pycodedj_base, {
|
|
79
|
+
arg cutoff = 800, lfoRate = 0.5, reverbMix = 0.2, amp = 0.3, gate = 1;
|
|
80
|
+
var sig, env, lfo;
|
|
81
|
+
lfo = SinOsc.kr(lfoRate, 0, 0.3, 1);
|
|
82
|
+
sig = Saw.ar(220 * lfo);
|
|
83
|
+
sig = RLPF.ar(sig, cutoff.clip(200, 4000), 0.5);
|
|
84
|
+
env = EnvGen.kr(Env.asr(0.1, 1, 0.2), gate, doneAction: 2);
|
|
85
|
+
sig = FreeVerb.ar(sig, reverbMix.clip(0, 0.8)) * env * amp;
|
|
86
|
+
Out.ar(0, sig ! 2);
|
|
87
|
+
}).add;
|
|
88
|
+
|
|
89
|
+
s.sync;
|
|
90
|
+
|
|
91
|
+
// Registry: loop name -> array of running Synth instances
|
|
92
|
+
~loops = Dictionary.new;
|
|
93
|
+
|
|
94
|
+
~synthForLoop = { |name|
|
|
95
|
+
switch (name,
|
|
96
|
+
"sub_bass", { \pycodedj_sub_bass },
|
|
97
|
+
"hat_engine", { \pycodedj_hat_engine },
|
|
98
|
+
"neon_stab", { \pycodedj_neon_stab },
|
|
99
|
+
"acid_lead", { \pycodedj_acid_lead },
|
|
100
|
+
"warehouse_air", { \pycodedj_warehouse_air },
|
|
101
|
+
{ \pycodedj_base }
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Helper: start or restart a loop with given voice count
|
|
106
|
+
~startLoop = { |name, voiceCount = 1|
|
|
107
|
+
var synthName, synths;
|
|
108
|
+
if (~loops[name].notNil) {
|
|
109
|
+
~loops[name].do({ |synth| synth.set(\gate, 0) });
|
|
110
|
+
};
|
|
111
|
+
synthName = ~synthForLoop.value(name);
|
|
112
|
+
synths = voiceCount.collect({ Synth(synthName) });
|
|
113
|
+
~loops[name] = synths;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Single handler for all /pycodedj/loop/<name>/<param> addresses.
|
|
117
|
+
// OSCdef does not interpret "*" as a wildcard — it registers the path literally.
|
|
118
|
+
// OSCFunc with nil path receives every incoming OSC message and we filter by prefix.
|
|
119
|
+
// var は SC では実行文より前に宣言しなければならないため、
|
|
120
|
+
// すべての var をクロージャ先頭にまとめる。
|
|
121
|
+
OSCFunc({ |msg, time, addr|
|
|
122
|
+
var parts, name, param, val;
|
|
123
|
+
parts = msg[0].asString.split($/);
|
|
124
|
+
// parts: ["", "pycodedj", "loop", <name>, <param>]
|
|
125
|
+
if (parts.size != 5) { ^nil };
|
|
126
|
+
if (parts[1] != "pycodedj") { ^nil };
|
|
127
|
+
if (parts[2] != "loop") { ^nil };
|
|
128
|
+
|
|
129
|
+
name = parts[3];
|
|
130
|
+
param = parts[4];
|
|
131
|
+
val = msg[1];
|
|
132
|
+
|
|
133
|
+
switch (param,
|
|
134
|
+
"voice_count", {
|
|
135
|
+
if (val.asInteger <= 0) {
|
|
136
|
+
if (~loops[name].notNil) {
|
|
137
|
+
~loops[name].do({ |synth| synth.set(\gate, 0) });
|
|
138
|
+
~loops[name] = nil;
|
|
139
|
+
};
|
|
140
|
+
} {
|
|
141
|
+
~startLoop.value(name, val.asInteger);
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
"cutoff", { ~loops[name].do({ |synth| synth.set(\cutoff, val.asFloat) }) },
|
|
145
|
+
"lfo_rate", { ~loops[name].do({ |synth| synth.set(\lfoRate, val.asFloat) }) },
|
|
146
|
+
"reverb", { ~loops[name].do({ |synth| synth.set(\reverbMix, val.asFloat) }) }
|
|
147
|
+
);
|
|
148
|
+
}); // nil path = receive all OSC messages
|
|
149
|
+
|
|
150
|
+
"PyCodeDJ synths loaded. Ready.".postln;
|
|
151
|
+
});
|
pycodedj/watcher.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import threading
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
from .block_parser import parse_blocks
|
|
9
|
+
from .engine import Engine
|
|
10
|
+
from .osc_bridge import OscError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class _LoopFileHandler:
|
|
14
|
+
"""ファイル変更イベントを受け取り、デバウンス後に全ループを再評価する。"""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
path: str,
|
|
19
|
+
engine: Engine,
|
|
20
|
+
on_eval: Callable[[str, int], None] | None = None,
|
|
21
|
+
debounce: float = 0.3,
|
|
22
|
+
) -> None:
|
|
23
|
+
self._path = os.path.abspath(path)
|
|
24
|
+
self._engine = engine
|
|
25
|
+
self._on_eval = on_eval
|
|
26
|
+
self._debounce = debounce
|
|
27
|
+
self._timer: threading.Timer | None = None
|
|
28
|
+
self._active_names: set[str] = set()
|
|
29
|
+
|
|
30
|
+
def dispatch(self, src_path: str) -> None:
|
|
31
|
+
"""watchdog の on_modified から呼ぶ。対象ファイル以外は無視する。"""
|
|
32
|
+
if os.path.abspath(src_path) != self._path:
|
|
33
|
+
return
|
|
34
|
+
if self._timer is not None:
|
|
35
|
+
self._timer.cancel()
|
|
36
|
+
self._timer = threading.Timer(self._debounce, self._eval_all)
|
|
37
|
+
self._timer.start()
|
|
38
|
+
|
|
39
|
+
def _eval_all(self) -> None:
|
|
40
|
+
try:
|
|
41
|
+
source = Path(self._path).read_text(encoding="utf-8")
|
|
42
|
+
except OSError:
|
|
43
|
+
return
|
|
44
|
+
blocks = parse_blocks(source)
|
|
45
|
+
current_names = {b.name for b in blocks}
|
|
46
|
+
|
|
47
|
+
for name in self._active_names - current_names:
|
|
48
|
+
try:
|
|
49
|
+
self._engine.stop_loop(name)
|
|
50
|
+
except OscError:
|
|
51
|
+
pass
|
|
52
|
+
self._active_names = current_names
|
|
53
|
+
|
|
54
|
+
for block in blocks:
|
|
55
|
+
self._engine.eval_block(block)
|
|
56
|
+
if self._on_eval is not None:
|
|
57
|
+
self._on_eval(self._path, len(blocks))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def watch(
|
|
61
|
+
path: str,
|
|
62
|
+
engine: Engine,
|
|
63
|
+
debounce: float = 0.3,
|
|
64
|
+
on_eval: Callable[[str, int], None] | None = None,
|
|
65
|
+
) -> None:
|
|
66
|
+
"""ファイルを監視し、変更のたびに全ループを再評価する。Ctrl+C で停止。
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
path: 監視するファイルのパス
|
|
70
|
+
engine: eval_block を呼び出す Engine インスタンス
|
|
71
|
+
debounce: 連続変更イベントをまとめる待機時間(秒)
|
|
72
|
+
on_eval: 再評価後に呼ばれるコールバック (file_path, loop_count)
|
|
73
|
+
"""
|
|
74
|
+
try:
|
|
75
|
+
from watchdog.events import FileSystemEventHandler
|
|
76
|
+
from watchdog.observers import Observer
|
|
77
|
+
except ImportError:
|
|
78
|
+
raise ImportError(
|
|
79
|
+
"watchdog が必要です: pip install 'pycodedj[watch]'"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
handler_wrapper = _LoopFileHandler(path, engine, on_eval=on_eval, debounce=debounce)
|
|
83
|
+
|
|
84
|
+
class _WatchdogAdapter(FileSystemEventHandler):
|
|
85
|
+
def on_modified(self, event: object) -> None:
|
|
86
|
+
src = getattr(event, "src_path", "")
|
|
87
|
+
handler_wrapper.dispatch(str(src))
|
|
88
|
+
|
|
89
|
+
# vim/emacs などのアトミックセーブは rename で完了するため on_moved も受ける
|
|
90
|
+
def on_moved(self, event: object) -> None:
|
|
91
|
+
src = getattr(event, "dest_path", "")
|
|
92
|
+
handler_wrapper.dispatch(str(src))
|
|
93
|
+
|
|
94
|
+
def on_created(self, event: object) -> None:
|
|
95
|
+
src = getattr(event, "src_path", "")
|
|
96
|
+
handler_wrapper.dispatch(str(src))
|
|
97
|
+
|
|
98
|
+
abs_path = os.path.abspath(path)
|
|
99
|
+
watch_dir = os.path.dirname(abs_path)
|
|
100
|
+
|
|
101
|
+
observer = Observer()
|
|
102
|
+
observer.schedule(_WatchdogAdapter(), watch_dir, recursive=False)
|
|
103
|
+
observer.start()
|
|
104
|
+
try:
|
|
105
|
+
observer.join()
|
|
106
|
+
except KeyboardInterrupt:
|
|
107
|
+
observer.stop()
|
|
108
|
+
observer.join()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pycodedj
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Requires-Python: >=3.10
|
|
5
|
+
Requires-Dist: python-osc>=1.8
|
|
6
|
+
Provides-Extra: dev
|
|
7
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
8
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
9
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
10
|
+
Provides-Extra: watch
|
|
11
|
+
Requires-Dist: watchdog>=3.0; extra == 'watch'
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
pycodedj/__init__.py,sha256=o4oFElgbHHWqJWmyO2VYO2xkp3k2aMRRl1TIvA9Dkh0,109
|
|
2
|
+
pycodedj/__main__.py,sha256=BPwKuza3CxiXeTfP1ScWOIyLma7psR3bpvh2IYdaML4,4018
|
|
3
|
+
pycodedj/analyzer.py,sha256=ph2IJu-RrYWJ3De4CFKP6KHid_en2pO9siZZpo_m-Rg,1863
|
|
4
|
+
pycodedj/block_parser.py,sha256=xFININqKVF9ZEoH4pIWWBllWr-qYKfCTla3TihW0yDg,1241
|
|
5
|
+
pycodedj/engine.py,sha256=40bHkBvg5GqLSuamIQ2LB7IdV-hCu_BPD_DXga9yHwA,1228
|
|
6
|
+
pycodedj/mapper.py,sha256=_9RS-mKyDP4WyNVVQRCcSrFUv77t6QO85lhmNP6Fec0,1102
|
|
7
|
+
pycodedj/osc_bridge.py,sha256=cm89GExpl8oVeJlkDcfGM2rFfazCD28CYGGZjfSTnBk,1835
|
|
8
|
+
pycodedj/watcher.py,sha256=v2AWaNGhe2Ek0dOEbYUkX8UO7m2Kg8cyIOuEzvZe-6I,3575
|
|
9
|
+
pycodedj/examples/club_set.py,sha256=mFfD20V5f-EdAaqdaFQ-xWzasfACAx7U_IDu1GqXgZw,1730
|
|
10
|
+
pycodedj/examples/demo.py,sha256=GXSGBWPKjM7PiwAFZ5w84GCAMco1ecFOwOd3TLhbJ14,468
|
|
11
|
+
pycodedj/examples/hello_sc.py,sha256=PIISxg0b7z_5ajVvzNepIHG3pPaK4ENQSR-GA1QkphA,825
|
|
12
|
+
pycodedj/sc/synths.scd,sha256=FsT8K2rzVHEdIN_GCeqhwTu44vxZ4KCll2_AnMBw7S4,6970
|
|
13
|
+
pycodedj-0.1.0.dist-info/METADATA,sha256=yW_BPRRaFI21q5uH6jst2Kj2epGMcEcU40N1stm0N3E,306
|
|
14
|
+
pycodedj-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
15
|
+
pycodedj-0.1.0.dist-info/entry_points.txt,sha256=00gIRaOWEf9DfkET0UBiK7wrRP8EfLKY6czyPGfoydQ,52
|
|
16
|
+
pycodedj-0.1.0.dist-info/RECORD,,
|