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 ADDED
@@ -0,0 +1,3 @@
1
+ """PyCodeDJ — live coding orchestrator that maps Python code structure to music."""
2
+
3
+ __version__ = "0.1.0"
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
+ )
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ pycodedj = pycodedj.__main__:main